From cfa814f1081a9b06768950089550f904bf496777 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 24 Apr 2026 13:55:25 +0100 Subject: [PATCH 1/5] Keyboard navigation basics --- src/App/components/Viewport/Features.jsx | 31 ++++++---- src/App/components/Viewport/Viewport.jsx | 9 ++- src/App/controls/keyboardActions.js | 9 ++- src/App/controls/keyboardActions.test.js | 14 +++++ src/App/controls/keyboardMappings.js | 2 + src/App/hooks/useFeatureFocus.js | 24 +++++++ src/App/hooks/useFeatureFocus.test.js | 79 ++++++++++++++++++++++++ src/App/hooks/useKeyboardShortcuts.js | 5 +- 8 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 src/App/hooks/useFeatureFocus.js create mode 100644 src/App/hooks/useFeatureFocus.test.js diff --git a/src/App/components/Viewport/Features.jsx b/src/App/components/Viewport/Features.jsx index e111b983..f1eb4fb0 100644 --- a/src/App/components/Viewport/Features.jsx +++ b/src/App/components/Viewport/Features.jsx @@ -1,13 +1,22 @@ -import React from 'react' +import React, { forwardRef } from 'react' +import { useConfig } from '../../store/configContext.js' import { Markers } from '../Markers/Markers' -export const Features = () => ( - -) +export const Features = forwardRef(({ activeFeatureId }, ref) => { + const { id } = useConfig() + return ( + + ) +}) + +Features.displayName = 'Features' diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx index 8248addc..5db66aff 100755 --- a/src/App/components/Viewport/Viewport.jsx +++ b/src/App/components/Viewport/Viewport.jsx @@ -1,4 +1,5 @@ import React, { useRef, useEffect, useState } from 'react' +import { useFeatureFocus } from '../../hooks/useFeatureFocus.js' import { EVENTS as events } from '../../../config/events.js' import { createPortal } from 'react-dom' import { useConfig } from '../../store/configContext.js' @@ -22,12 +23,15 @@ export const Viewport = () => { const mapContainerRef = useRef(null) const keyboardHintRef = useRef(null) + const featuresRef = useRef(null) // Local state for keyboard hint visibility const [keyboardHintVisible, setKeyboardHintVisible] = useState(false) + const { activeFeatureId, enterFeatures } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef }) + // Attach map keyboard controls - useKeyboardShortcuts(layoutRefs.viewportRef) + useKeyboardShortcuts(layoutRefs.viewportRef, { onEnterFeatures: enterFeatures }) // Attach map events useMapEvents({ @@ -63,6 +67,7 @@ export const Viewport = () => { onBlur={handleBlur} ref={layoutRefs.viewportRef} aria-describedby={`${id}-keyboard-hint`} + aria-controls={`${id}-features`} > {mainRef?.current && createPortal(
{
- + ) } diff --git a/src/App/controls/keyboardActions.js b/src/App/controls/keyboardActions.js index a8036ca3..8d934e3a 100755 --- a/src/App/controls/keyboardActions.js +++ b/src/App/controls/keyboardActions.js @@ -8,7 +8,8 @@ export const createKeyboardActions = (mapProvider, announce, { nudgePanDelta, zoomDelta, nudgeZoomDelta, - readMapText + readMapText, + onEnterFeatures }) => { const getPan = (shift) => (shift ? nudgePanDelta : panDelta) const getZoom = (shift) => (shift ? nudgeZoomDelta : zoomDelta) @@ -64,6 +65,10 @@ export const createKeyboardActions = (mapProvider, announce, { announce(label, 'core') }, - clearSelection: () => mapProvider?.clearHighlightedLabel?.() + clearSelection: () => mapProvider?.clearHighlightedLabel?.(), + + enterFeatures: () => { + if (onEnterFeatures) { onEnterFeatures() } + } } } diff --git a/src/App/controls/keyboardActions.test.js b/src/App/controls/keyboardActions.test.js index 959df1dd..6cb55566 100644 --- a/src/App/controls/keyboardActions.test.js +++ b/src/App/controls/keyboardActions.test.js @@ -128,6 +128,20 @@ describe('getInfo', () => { }) }) +describe('enterFeatures', () => { + test('calls onEnterFeatures when provided', () => { + const onEnterFeatures = jest.fn() + const { actions } = makeActions({ onEnterFeatures }) + actions.enterFeatures() + expect(onEnterFeatures).toHaveBeenCalled() + }) + + test('does nothing when onEnterFeatures is not provided', () => { + const { actions } = makeActions() + expect(() => actions.enterFeatures()).not.toThrow() + }) +}) + describe('label actions', () => { test('highlightNextLabel announces returned label', () => { const { actions, mapProvider, announce } = makeActions() diff --git a/src/App/controls/keyboardMappings.js b/src/App/controls/keyboardMappings.js index 990687a9..a7bd2f5d 100755 --- a/src/App/controls/keyboardMappings.js +++ b/src/App/controls/keyboardMappings.js @@ -20,6 +20,8 @@ export const keyboardMappings = { 'Alt+Enter': 'highlightLabelAtCenter', 'Alt+i': 'getInfo', 'Alt+I': 'getInfo', + 'Alt+f': 'enterFeatures', + 'Alt+F': 'enterFeatures', Escape: 'clearSelection' } } diff --git a/src/App/hooks/useFeatureFocus.js b/src/App/hooks/useFeatureFocus.js new file mode 100644 index 00000000..7d818a14 --- /dev/null +++ b/src/App/hooks/useFeatureFocus.js @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react' + +export function useFeatureFocus ({ viewportRef, featuresRef }) { + const [activeFeatureId, setActiveFeatureId] = useState(null) + + useEffect(() => { + const el = featuresRef.current + if (!el) { return undefined } + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + e.preventDefault() + viewportRef.current?.focus() + } + } + + el.addEventListener('keydown', handleKeyDown) + return () => { el.removeEventListener('keydown', handleKeyDown) } + }, [viewportRef, featuresRef]) + + const enterFeatures = () => { featuresRef.current?.focus() } + + return { activeFeatureId, setActiveFeatureId, enterFeatures } +} diff --git a/src/App/hooks/useFeatureFocus.test.js b/src/App/hooks/useFeatureFocus.test.js new file mode 100644 index 00000000..4ecc3590 --- /dev/null +++ b/src/App/hooks/useFeatureFocus.test.js @@ -0,0 +1,79 @@ +import { renderHook, act } from '@testing-library/react' +import { useFeatureFocus } from './useFeatureFocus.js' + +const makeRefs = ({ viewportFocus } = {}) => ({ + viewportRef: { current: { focus: viewportFocus ?? jest.fn() } }, + featuresRef: { current: document.createElement('div') } +}) + +describe('useFeatureFocus — initial state', () => { + it('activeFeatureId starts as null', () => { + const { result } = renderHook(() => useFeatureFocus(makeRefs())) + expect(result.current.activeFeatureId).toBeNull() + }) + + it('setActiveFeatureId updates activeFeatureId', () => { + const { result } = renderHook(() => useFeatureFocus(makeRefs())) + act(() => result.current.setActiveFeatureId('feature-1')) + expect(result.current.activeFeatureId).toBe('feature-1') + }) +}) + +describe('useFeatureFocus — keydown listener', () => { + it('does not attach listener when featuresRef.current is null', () => { + const refs = { viewportRef: { current: { focus: jest.fn() } }, featuresRef: { current: null } } + const spy = jest.spyOn(document.body, 'addEventListener') + renderHook(() => useFeatureFocus(refs)) + expect(spy).not.toHaveBeenCalledWith('keydown', expect.any(Function)) + spy.mockRestore() + }) + + it('attaches and removes keydown listener on the features element', () => { + const refs = makeRefs() + const el = refs.featuresRef.current + const addSpy = jest.spyOn(el, 'addEventListener') + const removeSpy = jest.spyOn(el, 'removeEventListener') + const { unmount } = renderHook(() => useFeatureFocus(refs)) + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) + unmount() + expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) + }) + + it('Escape key focuses the viewport', () => { + const viewportFocus = jest.fn() + const refs = makeRefs({ viewportFocus }) + const el = refs.featuresRef.current + document.body.appendChild(el) + renderHook(() => useFeatureFocus(refs)) + act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) }) + expect(viewportFocus).toHaveBeenCalled() + el.remove() + }) + + it('non-Escape keys do not focus the viewport', () => { + const viewportFocus = jest.fn() + const refs = makeRefs({ viewportFocus }) + const el = refs.featuresRef.current + document.body.appendChild(el) + renderHook(() => useFeatureFocus(refs)) + act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) }) + expect(viewportFocus).not.toHaveBeenCalled() + el.remove() + }) +}) + +describe('useFeatureFocus — enterFeatures', () => { + it('focuses the features element', () => { + const refs = makeRefs() + refs.featuresRef.current.focus = jest.fn() + const { result } = renderHook(() => useFeatureFocus(refs)) + act(() => result.current.enterFeatures()) + expect(refs.featuresRef.current.focus).toHaveBeenCalled() + }) + + it('does not throw when featuresRef.current is null', () => { + const refs = { viewportRef: { current: { focus: jest.fn() } }, featuresRef: { current: null } } + const { result } = renderHook(() => useFeatureFocus(refs)) + expect(() => act(() => result.current.enterFeatures())).not.toThrow() + }) +}) diff --git a/src/App/hooks/useKeyboardShortcuts.js b/src/App/hooks/useKeyboardShortcuts.js index 9f52c209..553384ee 100755 --- a/src/App/hooks/useKeyboardShortcuts.js +++ b/src/App/hooks/useKeyboardShortcuts.js @@ -5,7 +5,7 @@ import { useConfig } from '../store/configContext.js' import { useApp } from '../store/appContext.js' import { useService } from '../store/serviceContext.js' -export function useKeyboardShortcuts (containerRef) { +export function useKeyboardShortcuts (containerRef, { onEnterFeatures } = {}) { // NOSONAR: onEnterFeatures is captured in the useEffect closure below const { mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta, readMapText } = useConfig() const { interfaceType, dispatch } = useApp() const { announce } = useService() @@ -23,7 +23,8 @@ export function useKeyboardShortcuts (containerRef) { nudgePanDelta, zoomDelta, nudgeZoomDelta, - readMapText + readMapText, + onEnterFeatures }) const normalizeKey = (e) => { From dc97e9db8e241c29ac1145fdd1946b1c8677d0a1 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 24 Apr 2026 16:37:15 +0100 Subject: [PATCH 2/5] Markers moved inside viewport application --- .../src/hooks/useInteractionHandlers.js | 4 +-- src/App/components/Markers/MarkerItem.jsx | 11 -------- .../components/Markers/MarkerItem.test.jsx | 26 ------------------- src/App/components/Markers/Markers.jsx | 20 +++----------- src/App/components/Markers/Markers.test.jsx | 10 +++++++ src/App/components/Viewport/Features.jsx | 5 ++-- src/App/components/Viewport/Viewport.jsx | 4 +++ .../components/Viewport/Viewport.module.scss | 6 +++++ src/App/controls/keyboardMappings.js | 3 +-- 9 files changed, 28 insertions(+), 61 deletions(-) delete mode 100644 src/App/components/Markers/MarkerItem.jsx delete mode 100644 src/App/components/Markers/MarkerItem.test.jsx diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js index 3e976bf3..9c8f6228 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -8,7 +8,7 @@ import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js' * Returns the id of the first DOM marker whose visual bounds contain the given point. * * MAP_CLICK point is container-relative; getBoundingClientRect is viewport-relative. - * We convert by subtracting the features container's top-left (.im-c-features has + * We convert by subtracting the markers container's top-left (.im-c-viewport__markers has * inset:0 over the same area as the map canvas, giving the correct offset). * * @param {Object} markers - markers object from mapState (has .items and .markerRefs) @@ -23,7 +23,7 @@ const findMarkerAtPoint = (markers, point, scale) => { if (!el || isStandaloneLabel(marker)) { continue } - const container = el.closest('.im-c-features') || el.parentElement + const container = el.closest('.im-c-viewport__markers') || el.parentElement const parentRect = container ? container.getBoundingClientRect() : { left: 0, top: 0 } const { left, top, right, bottom } = el.getBoundingClientRect() const scaledX = point.x * scale diff --git a/src/App/components/Markers/MarkerItem.jsx b/src/App/components/Markers/MarkerItem.jsx deleted file mode 100644 index 758c1098..00000000 --- a/src/App/components/Markers/MarkerItem.jsx +++ /dev/null @@ -1,11 +0,0 @@ -const MarkerItem = ({ id, isSelected, children }) => ( // NOSONAR: project does not use PropTypes -
  • is only valid inside - aria-selected={isSelected} - > - {children} -
  • -) - -export default MarkerItem diff --git a/src/App/components/Markers/MarkerItem.test.jsx b/src/App/components/Markers/MarkerItem.test.jsx deleted file mode 100644 index 29b1a1eb..00000000 --- a/src/App/components/Markers/MarkerItem.test.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { render } from '@testing-library/react' -import MarkerItem from './MarkerItem.jsx' - -describe('MarkerItem', () => { - it('renders an li with role option and the given id', () => { - const { container } = render() - const li = container.querySelector('li') - expect(li.getAttribute('id')).toBe('test-id') - expect(li.getAttribute('role')).toBe('option') - }) - - it('sets aria-selected to true when isSelected is true', () => { - const { container } = render() - expect(container.querySelector('li').getAttribute('aria-selected')).toBe('true') - }) - - it('sets aria-selected to false when isSelected is false', () => { - const { container } = render() - expect(container.querySelector('li').getAttribute('aria-selected')).toBe('false') - }) - - it('renders children inside the li', () => { - const { getByText } = render(hello) - expect(getByText('hello')).toBeTruthy() - }) -}) diff --git a/src/App/components/Markers/Markers.jsx b/src/App/components/Markers/Markers.jsx index bb391a6e..9f344d40 100755 --- a/src/App/components/Markers/Markers.jsx +++ b/src/App/components/Markers/Markers.jsx @@ -6,7 +6,6 @@ import { useMap } from '../../store/mapContext.js' import { useService } from '../../store/serviceContext.js' import { scaleFactor } from '../../../config/appConfig.js' import { isStandaloneLabel } from '../../../utils/symbolUtils.js' -import MarkerItem from './MarkerItem.jsx' import LabelMarker from './LabelMarker.jsx' import SymbolLabelMarker from './SymbolLabelMarker.jsx' import SymbolMarker from './SymbolMarker.jsx' @@ -83,31 +82,18 @@ export const Markers = () => { <> {markers.items.map(marker => { const isSelected = selectedMarkers.includes(marker.id) - const featureId = `${id}-feature-${marker.id}` if (isStandaloneLabel(marker)) { - return ( - - - - ) + return } const symbolProps = resolveSymbolProps(marker, defaults, symbolRegistry, mapStyle, mapSize, isSelected) if (marker.showLabel && marker.label) { - return ( - - - - ) + return } - return ( - - - - ) + return })} ) diff --git a/src/App/components/Markers/Markers.test.jsx b/src/App/components/Markers/Markers.test.jsx index 78f6f170..244bd3a7 100644 --- a/src/App/components/Markers/Markers.test.jsx +++ b/src/App/components/Markers/Markers.test.jsx @@ -185,6 +185,16 @@ describe('Markers — selection', () => { expect(result.container.querySelector(SVG_SEL)).not.toHaveClass(SELECTED_CLASS) }) + it('handles interact:active with selectMarker in interactionModes', () => { + const { eb } = setup() + expect(() => act(() => eb.emit(INTERACT_ACTIVE, { active: true, interactionModes: ['selectMarker'] }))).not.toThrow() + }) + + it('handles interact:active with no interactionModes (uses default [])', () => { + const { eb } = setup() + expect(() => act(() => eb.emit(INTERACT_ACTIVE, { active: true }))).not.toThrow() + }) + it('wires interact:active and interact:selectionchange on mount and removes them on unmount', () => { const { eb, result } = setup() expect(eb.on).toHaveBeenCalledWith(INTERACT_ACTIVE, expect.any(Function)) diff --git a/src/App/components/Viewport/Features.jsx b/src/App/components/Viewport/Features.jsx index f1eb4fb0..af6ccd3f 100644 --- a/src/App/components/Viewport/Features.jsx +++ b/src/App/components/Viewport/Features.jsx @@ -1,11 +1,10 @@ import React, { forwardRef } from 'react' import { useConfig } from '../../store/configContext.js' -import { Markers } from '../Markers/Markers' export const Features = forwardRef(({ activeFeatureId }, ref) => { const { id } = useConfig() return ( -
      cannot host SVG marker elements +
        cannot host SVG marker elements id={`${id}-features`} ref={ref} role='listbox' // NOSONAR @@ -14,7 +13,7 @@ export const Features = forwardRef(({ activeFeatureId }, ref) => { aria-activedescendant={activeFeatureId || undefined} className='im-c-features' > - + {/* populated via features:setItems from plugins */}
      ) }) diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx index 5db66aff..64a0ffc9 100755 --- a/src/App/components/Viewport/Viewport.jsx +++ b/src/App/components/Viewport/Viewport.jsx @@ -12,6 +12,7 @@ import { useMapEvents } from '../../hooks/useMapEvents.js' import { MapStatus } from './MapStatus.jsx' import { CrossHair } from '../CrossHair/CrossHair' import { Features } from './Features' +import { Markers } from '../Markers/Markers' // eslint-disable-next-line camelcase, react/jsx-pascal-case // sonarjs/disable-next-line function-name @@ -83,6 +84,9 @@ export const Viewport = () => { + diff --git a/src/App/components/Viewport/Viewport.module.scss b/src/App/components/Viewport/Viewport.module.scss index f10c245a..feba3372 100755 --- a/src/App/components/Viewport/Viewport.module.scss +++ b/src/App/components/Viewport/Viewport.module.scss @@ -65,6 +65,12 @@ z-index: 1; } +.im-c-viewport__markers { + position: absolute; + inset: 0; + pointer-events: none; +} + // 3. Modifiers diff --git a/src/App/controls/keyboardMappings.js b/src/App/controls/keyboardMappings.js index a7bd2f5d..01096cd0 100755 --- a/src/App/controls/keyboardMappings.js +++ b/src/App/controls/keyboardMappings.js @@ -20,8 +20,7 @@ export const keyboardMappings = { 'Alt+Enter': 'highlightLabelAtCenter', 'Alt+i': 'getInfo', 'Alt+I': 'getInfo', - 'Alt+f': 'enterFeatures', - 'Alt+F': 'enterFeatures', + F6: 'enterFeatures', Escape: 'clearSelection' } } From fae29dbf255711d757eba2d1be9ad7f8e7c417dc Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 27 Apr 2026 09:32:58 +0100 Subject: [PATCH 3/5] Move from F6 to seperate tab stop --- src/App/components/Viewport/Features.jsx | 14 +- src/App/components/Viewport/Features.test.jsx | 69 ++++++++ src/App/components/Viewport/Viewport.jsx | 7 +- src/App/controls/keyboardActions.js | 9 +- src/App/controls/keyboardActions.test.js | 14 -- src/App/controls/keyboardMappings.js | 1 - src/App/hooks/useFeatureFocus.js | 33 +++- src/App/hooks/useFeatureFocus.test.js | 165 +++++++++++++++--- src/App/hooks/useKeyboardShortcuts.js | 5 +- 9 files changed, 259 insertions(+), 58 deletions(-) create mode 100644 src/App/components/Viewport/Features.test.jsx diff --git a/src/App/components/Viewport/Features.jsx b/src/App/components/Viewport/Features.jsx index af6ccd3f..fa592728 100644 --- a/src/App/components/Viewport/Features.jsx +++ b/src/App/components/Viewport/Features.jsx @@ -1,19 +1,27 @@ import React, { forwardRef } from 'react' import { useConfig } from '../../store/configContext.js' -export const Features = forwardRef(({ activeFeatureId }, ref) => { +export const Features = forwardRef(({ activeFeatureId, items = [], onFocus }, ref) => { const { id } = useConfig() return (
        cannot host SVG marker elements id={`${id}-features`} ref={ref} role='listbox' // NOSONAR - tabIndex='-1' + tabIndex='0' aria-label='Map features' aria-activedescendant={activeFeatureId || undefined} className='im-c-features' + onFocus={onFocus} > - {/* populated via features:setItems from plugins */} + {items.map(item => ( +
      • + {item.label} +
      • + ))}
      ) }) diff --git a/src/App/components/Viewport/Features.test.jsx b/src/App/components/Viewport/Features.test.jsx new file mode 100644 index 00000000..80fbc666 --- /dev/null +++ b/src/App/components/Viewport/Features.test.jsx @@ -0,0 +1,69 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import { Features } from './Features.jsx' +import { useConfig } from '../../store/configContext.js' + +jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() })) + +const APP_ID = 'test-app' +const ITEMS = [ + { id: 'f1', label: 'Feature One' }, + { id: 'f2', label: 'Feature Two' } +] + +beforeEach(() => { + useConfig.mockReturnValue({ id: APP_ID }) +}) + +// ─── Features — rendering ───────────────────────────────────────────────────── + +describe('Features — rendering', () => { + it('renders a listbox with the correct id', () => { + const { container } = render() + expect(container.querySelector(`#${APP_ID}-features`)).toBeTruthy() + expect(container.querySelector('[role="listbox"]')).toBeTruthy() + }) + + it('renders no options when items is empty', () => { + const { container } = render() + expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) + }) + + it('renders one option per item with correct id and label', () => { + const { container } = render() + const options = container.querySelectorAll('[role="option"]') + expect(options).toHaveLength(2) + expect(options[0].getAttribute('id')).toBe(`${APP_ID}-feature-f1`) + expect(options[0].textContent).toBe('Feature One') + expect(options[1].getAttribute('id')).toBe(`${APP_ID}-feature-f2`) + expect(options[1].textContent).toBe('Feature Two') + }) + + it('sets aria-selected on the active item', () => { + const { container } = render() + const options = container.querySelectorAll('[role="option"]') + expect(options[0]).toHaveAttribute('aria-selected', 'true') + expect(options[1]).toHaveAttribute('aria-selected', 'false') + }) + + it('sets aria-activedescendant when activeFeatureId is provided', () => { + const { container } = render() + expect(container.querySelector('[role="listbox"]').getAttribute('aria-activedescendant')).toBe('f2') + }) + + it('omits aria-activedescendant when activeFeatureId is absent', () => { + const { container } = render() + expect(container.querySelector('[role="listbox"]').getAttribute('aria-activedescendant')).toBeNull() + }) +}) + +// ─── Features — interactions ────────────────────────────────────────────────── + +describe('Features — interactions', () => { + it('calls onFocus when the listbox receives focus', () => { + const onFocus = jest.fn() + const { container } = render() + fireEvent.focus(container.querySelector('[role="listbox"]')) + expect(onFocus).toHaveBeenCalled() + }) +}) diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx index 64a0ffc9..02631537 100755 --- a/src/App/components/Viewport/Viewport.jsx +++ b/src/App/components/Viewport/Viewport.jsx @@ -29,10 +29,11 @@ export const Viewport = () => { // Local state for keyboard hint visibility const [keyboardHintVisible, setKeyboardHintVisible] = useState(false) - const { activeFeatureId, enterFeatures } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef }) + const featureItems = [] + const { activeFeatureId, onFocus: onFeaturesFocus } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems }) // Attach map keyboard controls - useKeyboardShortcuts(layoutRefs.viewportRef, { onEnterFeatures: enterFeatures }) + useKeyboardShortcuts(layoutRefs.viewportRef) // Attach map events useMapEvents({ @@ -88,7 +89,7 @@ export const Viewport = () => { - + ) } diff --git a/src/App/controls/keyboardActions.js b/src/App/controls/keyboardActions.js index 8d934e3a..a8036ca3 100755 --- a/src/App/controls/keyboardActions.js +++ b/src/App/controls/keyboardActions.js @@ -8,8 +8,7 @@ export const createKeyboardActions = (mapProvider, announce, { nudgePanDelta, zoomDelta, nudgeZoomDelta, - readMapText, - onEnterFeatures + readMapText }) => { const getPan = (shift) => (shift ? nudgePanDelta : panDelta) const getZoom = (shift) => (shift ? nudgeZoomDelta : zoomDelta) @@ -65,10 +64,6 @@ export const createKeyboardActions = (mapProvider, announce, { announce(label, 'core') }, - clearSelection: () => mapProvider?.clearHighlightedLabel?.(), - - enterFeatures: () => { - if (onEnterFeatures) { onEnterFeatures() } - } + clearSelection: () => mapProvider?.clearHighlightedLabel?.() } } diff --git a/src/App/controls/keyboardActions.test.js b/src/App/controls/keyboardActions.test.js index 6cb55566..959df1dd 100644 --- a/src/App/controls/keyboardActions.test.js +++ b/src/App/controls/keyboardActions.test.js @@ -128,20 +128,6 @@ describe('getInfo', () => { }) }) -describe('enterFeatures', () => { - test('calls onEnterFeatures when provided', () => { - const onEnterFeatures = jest.fn() - const { actions } = makeActions({ onEnterFeatures }) - actions.enterFeatures() - expect(onEnterFeatures).toHaveBeenCalled() - }) - - test('does nothing when onEnterFeatures is not provided', () => { - const { actions } = makeActions() - expect(() => actions.enterFeatures()).not.toThrow() - }) -}) - describe('label actions', () => { test('highlightNextLabel announces returned label', () => { const { actions, mapProvider, announce } = makeActions() diff --git a/src/App/controls/keyboardMappings.js b/src/App/controls/keyboardMappings.js index 01096cd0..990687a9 100755 --- a/src/App/controls/keyboardMappings.js +++ b/src/App/controls/keyboardMappings.js @@ -20,7 +20,6 @@ export const keyboardMappings = { 'Alt+Enter': 'highlightLabelAtCenter', 'Alt+i': 'getInfo', 'Alt+I': 'getInfo', - F6: 'enterFeatures', Escape: 'clearSelection' } } diff --git a/src/App/hooks/useFeatureFocus.js b/src/App/hooks/useFeatureFocus.js index 7d818a14..92f5b76b 100644 --- a/src/App/hooks/useFeatureFocus.js +++ b/src/App/hooks/useFeatureFocus.js @@ -1,24 +1,47 @@ import { useState, useEffect } from 'react' -export function useFeatureFocus ({ viewportRef, featuresRef }) { +const getNavigatedId = (id, key, items) => { + if (!items.length) { + return id + } + const idx = items.findIndex(item => item.id === id) + if (key === 'ArrowDown') { + return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id + } + return idx === -1 ? items[items.length - 1].id : items[Math.max(idx - 1, 0)].id +} + +export function useFeatureFocus ({ viewportRef, featuresRef, items = [] }) { const [activeFeatureId, setActiveFeatureId] = useState(null) useEffect(() => { const el = featuresRef.current - if (!el) { return undefined } + if (!el) { + return undefined + } const handleKeyDown = (e) => { if (e.key === 'Escape') { e.preventDefault() + setActiveFeatureId(null) viewportRef.current?.focus() + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + setActiveFeatureId(id => getNavigatedId(id, e.key, items)) + } else { + // No action } } el.addEventListener('keydown', handleKeyDown) return () => { el.removeEventListener('keydown', handleKeyDown) } - }, [viewportRef, featuresRef]) + }, [viewportRef, featuresRef, items]) - const enterFeatures = () => { featuresRef.current?.focus() } + const onFocus = () => { + if (items.length) { + setActiveFeatureId(items[0].id) + } + } - return { activeFeatureId, setActiveFeatureId, enterFeatures } + return { activeFeatureId, onFocus } } diff --git a/src/App/hooks/useFeatureFocus.test.js b/src/App/hooks/useFeatureFocus.test.js index 4ecc3590..5dd9807b 100644 --- a/src/App/hooks/useFeatureFocus.test.js +++ b/src/App/hooks/useFeatureFocus.test.js @@ -1,25 +1,54 @@ import { renderHook, act } from '@testing-library/react' import { useFeatureFocus } from './useFeatureFocus.js' +const ITEMS = [ + { id: 'a', label: 'Feature A' }, + { id: 'b', label: 'Feature B' }, + { id: 'c', label: 'Feature C' } +] + const makeRefs = ({ viewportFocus } = {}) => ({ viewportRef: { current: { focus: viewportFocus ?? jest.fn() } }, - featuresRef: { current: document.createElement('div') } + featuresRef: { current: document.createElement('ul') } }) +const fireKey = (el, key) => { + act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) }) +} + +// ─── useFeatureFocus — initial state ───────────────────────────────────────── + describe('useFeatureFocus — initial state', () => { it('activeFeatureId starts as null', () => { const { result } = renderHook(() => useFeatureFocus(makeRefs())) expect(result.current.activeFeatureId).toBeNull() }) - it('setActiveFeatureId updates activeFeatureId', () => { + it('exposes onFocus function', () => { + const { result } = renderHook(() => useFeatureFocus(makeRefs())) + expect(typeof result.current.onFocus).toBe('function') + }) +}) + +// ─── useFeatureFocus — onFocus ──────────────────────────────────────────────── + +describe('useFeatureFocus — onFocus', () => { + it('sets activeFeatureId to first item when items are present', () => { + const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS })) + act(() => result.current.onFocus()) + expect(result.current.activeFeatureId).toBe('a') + }) + + it('does nothing when items is empty', () => { const { result } = renderHook(() => useFeatureFocus(makeRefs())) - act(() => result.current.setActiveFeatureId('feature-1')) - expect(result.current.activeFeatureId).toBe('feature-1') + act(() => result.current.onFocus()) + expect(result.current.activeFeatureId).toBeNull() }) }) -describe('useFeatureFocus — keydown listener', () => { +// ─── useFeatureFocus — keydown listener lifecycle ──────────────────────────── + +describe('useFeatureFocus — keydown listener lifecycle', () => { it('does not attach listener when featuresRef.current is null', () => { const refs = { viewportRef: { current: { focus: jest.fn() } }, featuresRef: { current: null } } const spy = jest.spyOn(document.body, 'addEventListener') @@ -38,42 +67,134 @@ describe('useFeatureFocus — keydown listener', () => { unmount() expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) }) +}) + +// ─── useFeatureFocus — unhandled keys ──────────────────────────────────────── - it('Escape key focuses the viewport', () => { +describe('useFeatureFocus — unhandled keys', () => { + it('does nothing for keys that are not Escape or Arrow', () => { const viewportFocus = jest.fn() const refs = makeRefs({ viewportFocus }) const el = refs.featuresRef.current document.body.appendChild(el) - renderHook(() => useFeatureFocus(refs)) - act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) }) - expect(viewportFocus).toHaveBeenCalled() + const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS })) + fireKey(el, 'Tab') + expect(result.current.activeFeatureId).toBeNull() + expect(viewportFocus).not.toHaveBeenCalled() el.remove() }) +}) + +// ─── useFeatureFocus — Escape key ──────────────────────────────────────────── - it('non-Escape keys do not focus the viewport', () => { +describe('useFeatureFocus — Escape key', () => { + it('clears activeFeatureId and focuses viewport', () => { const viewportFocus = jest.fn() const refs = makeRefs({ viewportFocus }) const el = refs.featuresRef.current document.body.appendChild(el) + const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS })) + act(() => result.current.onFocus()) + fireKey(el, 'Escape') + expect(result.current.activeFeatureId).toBeNull() + expect(viewportFocus).toHaveBeenCalled() + el.remove() + }) + + it('does not throw when viewportRef.current is null', () => { + const refs = { viewportRef: { current: null }, featuresRef: { current: document.createElement('ul') } } + const el = refs.featuresRef.current + document.body.appendChild(el) renderHook(() => useFeatureFocus(refs)) - act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) }) - expect(viewportFocus).not.toHaveBeenCalled() + expect(() => fireKey(el, 'Escape')).not.toThrow() el.remove() }) }) -describe('useFeatureFocus — enterFeatures', () => { - it('focuses the features element', () => { +// ─── useFeatureFocus — ArrowDown navigation ─────────────────────────────────── + +describe('useFeatureFocus — ArrowDown navigation', () => { + const setup = () => { const refs = makeRefs() - refs.featuresRef.current.focus = jest.fn() - const { result } = renderHook(() => useFeatureFocus(refs)) - act(() => result.current.enterFeatures()) - expect(refs.featuresRef.current.focus).toHaveBeenCalled() + const el = refs.featuresRef.current + document.body.appendChild(el) + const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS })) + return { result, el, unmount } + } + + it('selects first item when no item is active', () => { + const { result, el, unmount } = setup() + fireKey(el, 'ArrowDown') + expect(result.current.activeFeatureId).toBe('a') + unmount(); el.remove() }) - it('does not throw when featuresRef.current is null', () => { - const refs = { viewportRef: { current: { focus: jest.fn() } }, featuresRef: { current: null } } - const { result } = renderHook(() => useFeatureFocus(refs)) - expect(() => act(() => result.current.enterFeatures())).not.toThrow() + it('advances to next item', () => { + const { result, el, unmount } = setup() + act(() => result.current.onFocus()) + fireKey(el, 'ArrowDown') + expect(result.current.activeFeatureId).toBe('b') + unmount(); el.remove() + }) + + it('clamps at last item', () => { + const refs = makeRefs() + const el = refs.featuresRef.current + document.body.appendChild(el) + const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS })) + act(() => result.current.onFocus()) + fireKey(el, 'ArrowDown') + fireKey(el, 'ArrowDown') + fireKey(el, 'ArrowDown') + expect(result.current.activeFeatureId).toBe('c') + unmount(); el.remove() + }) + + it('does nothing when items is empty', () => { + const refs = makeRefs() + const el = refs.featuresRef.current + document.body.appendChild(el) + const { result, unmount } = renderHook(() => useFeatureFocus(refs)) + fireKey(el, 'ArrowDown') + expect(result.current.activeFeatureId).toBeNull() + unmount(); el.remove() + }) +}) + +// ─── useFeatureFocus — ArrowUp navigation ──────────────────────────────────── + +describe('useFeatureFocus — ArrowUp navigation', () => { + it('selects last item when no item is active', () => { + const refs = makeRefs() + const el = refs.featuresRef.current + document.body.appendChild(el) + const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS })) + fireKey(el, 'ArrowUp') + expect(result.current.activeFeatureId).toBe('c') + unmount(); el.remove() + }) + + it('moves to previous item', () => { + const refs = makeRefs() + const el = refs.featuresRef.current + document.body.appendChild(el) + const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS })) + act(() => result.current.onFocus()) + fireKey(el, 'ArrowDown') + fireKey(el, 'ArrowUp') + expect(result.current.activeFeatureId).toBe('a') + unmount(); el.remove() + }) + + it('clamps at first item', () => { + const refs = makeRefs() + const el = refs.featuresRef.current + document.body.appendChild(el) + const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS })) + act(() => result.current.onFocus()) + fireKey(el, 'ArrowUp') + fireKey(el, 'ArrowUp') + expect(result.current.activeFeatureId).toBe('a') + unmount(); el.remove() }) }) diff --git a/src/App/hooks/useKeyboardShortcuts.js b/src/App/hooks/useKeyboardShortcuts.js index 553384ee..9f52c209 100755 --- a/src/App/hooks/useKeyboardShortcuts.js +++ b/src/App/hooks/useKeyboardShortcuts.js @@ -5,7 +5,7 @@ import { useConfig } from '../store/configContext.js' import { useApp } from '../store/appContext.js' import { useService } from '../store/serviceContext.js' -export function useKeyboardShortcuts (containerRef, { onEnterFeatures } = {}) { // NOSONAR: onEnterFeatures is captured in the useEffect closure below +export function useKeyboardShortcuts (containerRef) { const { mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta, readMapText } = useConfig() const { interfaceType, dispatch } = useApp() const { announce } = useService() @@ -23,8 +23,7 @@ export function useKeyboardShortcuts (containerRef, { onEnterFeatures } = {}) { nudgePanDelta, zoomDelta, nudgeZoomDelta, - readMapText, - onEnterFeatures + readMapText }) const normalizeKey = (e) => { From d9855a49f9d60191af78d5dc89e7ca31bf28e128 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 27 Apr 2026 09:42:26 +0100 Subject: [PATCH 4/5] useFeatureItems hook added --- src/App/components/Viewport/Features.jsx | 4 +- src/App/components/Viewport/Features.test.jsx | 28 +++++-- src/App/components/Viewport/Viewport.jsx | 5 +- src/App/hooks/useFeatureItems.js | 20 +++++ src/App/hooks/useFeatureItems.test.js | 75 +++++++++++++++++++ 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 src/App/hooks/useFeatureItems.js create mode 100644 src/App/hooks/useFeatureItems.test.js diff --git a/src/App/components/Viewport/Features.jsx b/src/App/components/Viewport/Features.jsx index fa592728..7d92bc5f 100644 --- a/src/App/components/Viewport/Features.jsx +++ b/src/App/components/Viewport/Features.jsx @@ -3,12 +3,14 @@ import { useConfig } from '../../store/configContext.js' export const Features = forwardRef(({ activeFeatureId, items = [], onFocus }, ref) => { const { id } = useConfig() + const hasItems = items.length > 0 return (
        cannot host SVG marker elements id={`${id}-features`} ref={ref} role='listbox' // NOSONAR - tabIndex='0' + tabIndex={hasItems ? '0' : '-1'} + aria-hidden={hasItems ? undefined : true} aria-label='Map features' aria-activedescendant={activeFeatureId || undefined} className='im-c-features' diff --git a/src/App/components/Viewport/Features.test.jsx b/src/App/components/Viewport/Features.test.jsx index 80fbc666..e96dc356 100644 --- a/src/App/components/Viewport/Features.test.jsx +++ b/src/App/components/Viewport/Features.test.jsx @@ -21,17 +21,17 @@ describe('Features — rendering', () => { it('renders a listbox with the correct id', () => { const { container } = render() expect(container.querySelector(`#${APP_ID}-features`)).toBeTruthy() - expect(container.querySelector('[role="listbox"]')).toBeTruthy() + expect(container.querySelector('[role="listbox"]')).toBeTruthy() // NOSONAR }) it('renders no options when items is empty', () => { const { container } = render() - expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) + expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR }) it('renders one option per item with correct id and label', () => { const { container } = render() - const options = container.querySelectorAll('[role="option"]') + const options = container.querySelectorAll('[role="option"]') // NOSONAR expect(options).toHaveLength(2) expect(options[0].getAttribute('id')).toBe(`${APP_ID}-feature-f1`) expect(options[0].textContent).toBe('Feature One') @@ -41,19 +41,33 @@ describe('Features — rendering', () => { it('sets aria-selected on the active item', () => { const { container } = render() - const options = container.querySelectorAll('[role="option"]') + const options = container.querySelectorAll('[role="option"]') // NOSONAR expect(options[0]).toHaveAttribute('aria-selected', 'true') expect(options[1]).toHaveAttribute('aria-selected', 'false') }) it('sets aria-activedescendant when activeFeatureId is provided', () => { const { container } = render() - expect(container.querySelector('[role="listbox"]').getAttribute('aria-activedescendant')).toBe('f2') + expect(container.querySelector('[role="listbox"]').getAttribute('aria-activedescendant')).toBe('f2') // NOSONAR }) it('omits aria-activedescendant when activeFeatureId is absent', () => { const { container } = render() - expect(container.querySelector('[role="listbox"]').getAttribute('aria-activedescendant')).toBeNull() + expect(container.querySelector('[role="listbox"]').getAttribute('aria-activedescendant')).toBeNull() // NOSONAR + }) + + it('is tabIndex 0 and not aria-hidden when items are present', () => { + const { container } = render() + const ul = container.querySelector('[role="listbox"]') // NOSONAR + expect(ul.getAttribute('tabIndex')).toBe('0') + expect(ul.getAttribute('aria-hidden')).toBeNull() + }) + + it('is tabIndex -1 and aria-hidden when items is empty', () => { + const { container } = render() + const ul = container.querySelector('[role="listbox"]') // NOSONAR + expect(ul.getAttribute('tabIndex')).toBe('-1') + expect(ul.getAttribute('aria-hidden')).toBe('true') }) }) @@ -63,7 +77,7 @@ describe('Features — interactions', () => { it('calls onFocus when the listbox receives focus', () => { const onFocus = jest.fn() const { container } = render() - fireEvent.focus(container.querySelector('[role="listbox"]')) + fireEvent.focus(container.querySelector('[role="listbox"]')) // NOSONAR expect(onFocus).toHaveBeenCalled() }) }) diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx index 02631537..ec592db8 100755 --- a/src/App/components/Viewport/Viewport.jsx +++ b/src/App/components/Viewport/Viewport.jsx @@ -1,10 +1,12 @@ import React, { useRef, useEffect, useState } from 'react' import { useFeatureFocus } from '../../hooks/useFeatureFocus.js' +import { useFeatureItems } from '../../hooks/useFeatureItems.js' import { EVENTS as events } from '../../../config/events.js' import { createPortal } from 'react-dom' import { useConfig } from '../../store/configContext.js' import { useApp } from '../../store/appContext.js' import { useMap } from '../../store/mapContext.js' +import { useService } from '../../store/serviceContext.js' import { MapController } from './MapController.jsx' import { useKeyboardHint } from '../../hooks/useKeyboardHint.js' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js' @@ -21,6 +23,7 @@ export const Viewport = () => { const { interfaceType, mode, previousMode, layoutRefs, safeZoneInset } = useApp() const { mainRef } = layoutRefs const { mapSize } = useMap() + const { eventBus } = useService() const mapContainerRef = useRef(null) const keyboardHintRef = useRef(null) @@ -29,7 +32,7 @@ export const Viewport = () => { // Local state for keyboard hint visibility const [keyboardHintVisible, setKeyboardHintVisible] = useState(false) - const featureItems = [] + const featureItems = useFeatureItems(eventBus) const { activeFeatureId, onFocus: onFeaturesFocus } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems }) // Attach map keyboard controls diff --git a/src/App/hooks/useFeatureItems.js b/src/App/hooks/useFeatureItems.js new file mode 100644 index 00000000..66cc85ff --- /dev/null +++ b/src/App/hooks/useFeatureItems.js @@ -0,0 +1,20 @@ +import { useState, useEffect } from 'react' + +export function useFeatureItems (eventBus) { + const [items, setItems] = useState([]) + + useEffect(() => { + if (!eventBus) { + return undefined + } + const handle = ({ items: next = [] }) => { + setItems(next) + } + eventBus.on('features:setItems', handle) + return () => { + eventBus.off('features:setItems', handle) + } + }, [eventBus]) + + return items +} diff --git a/src/App/hooks/useFeatureItems.test.js b/src/App/hooks/useFeatureItems.test.js new file mode 100644 index 00000000..ff35b836 --- /dev/null +++ b/src/App/hooks/useFeatureItems.test.js @@ -0,0 +1,75 @@ +import { renderHook, act } from '@testing-library/react' +import { useFeatureItems } from './useFeatureItems.js' + +const makeEventBus = () => { + const listeners = {} + return { + on: jest.fn((e, fn) => { listeners[e] = fn }), + off: jest.fn(), + emit: (e, payload) => listeners[e]?.(payload) + } +} + +// ─── useFeatureItems — initial state ───────────────────────────────────────── + +describe('useFeatureItems — initial state', () => { + it('returns an empty array before any event is received', () => { + const { result } = renderHook(() => useFeatureItems(makeEventBus())) + expect(result.current).toEqual([]) + }) + + it('returns an empty array when eventBus is undefined', () => { + const { result } = renderHook(() => useFeatureItems(undefined)) + expect(result.current).toEqual([]) + }) +}) + +// ─── useFeatureItems — event subscription ──────────────────────────────────── + +describe('useFeatureItems — event subscription', () => { + it('subscribes to features:setItems on mount', () => { + const eb = makeEventBus() + renderHook(() => useFeatureItems(eb)) + expect(eb.on).toHaveBeenCalledWith('features:setItems', expect.any(Function)) + }) + + it('unsubscribes on unmount', () => { + const eb = makeEventBus() + const { unmount } = renderHook(() => useFeatureItems(eb)) + unmount() + expect(eb.off).toHaveBeenCalledWith('features:setItems', expect.any(Function)) + }) + + it('does not subscribe when eventBus is undefined', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + renderHook(() => useFeatureItems(undefined)) + spy.mockRestore() + }) +}) + +// ─── useFeatureItems — updates ──────────────────────────────────────────────── + +describe('useFeatureItems — updates', () => { + it('updates items when features:setItems is emitted', () => { + const eb = makeEventBus() + const { result } = renderHook(() => useFeatureItems(eb)) + const items = [{ id: 'a', label: 'Feature A' }, { id: 'b', label: 'Feature B' }] + act(() => eb.emit('features:setItems', { items })) + expect(result.current).toEqual(items) + }) + + it('clears items when emitted with an empty array', () => { + const eb = makeEventBus() + const { result } = renderHook(() => useFeatureItems(eb)) + act(() => eb.emit('features:setItems', { items: [{ id: 'a', label: 'A' }] })) + act(() => eb.emit('features:setItems', { items: [] })) + expect(result.current).toEqual([]) + }) + + it('defaults to empty array when items key is missing from payload', () => { + const eb = makeEventBus() + const { result } = renderHook(() => useFeatureItems(eb)) + act(() => eb.emit('features:setItems', {})) + expect(result.current).toEqual([]) + }) +}) From a4e0c32a6286453960a7476afa2cecb595b571e0 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 27 Apr 2026 10:00:45 +0100 Subject: [PATCH 5/5] Var names amended to be more readable --- src/App/hooks/useFeatureFocus.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/App/hooks/useFeatureFocus.js b/src/App/hooks/useFeatureFocus.js index 92f5b76b..dd040be5 100644 --- a/src/App/hooks/useFeatureFocus.js +++ b/src/App/hooks/useFeatureFocus.js @@ -15,26 +15,26 @@ export function useFeatureFocus ({ viewportRef, featuresRef, items = [] }) { const [activeFeatureId, setActiveFeatureId] = useState(null) useEffect(() => { - const el = featuresRef.current - if (!el) { + const listboxEl = featuresRef.current + if (!listboxEl) { return undefined } - const handleKeyDown = (e) => { - if (e.key === 'Escape') { - e.preventDefault() + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault() setActiveFeatureId(null) viewportRef.current?.focus() - } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault() - setActiveFeatureId(id => getNavigatedId(id, e.key, items)) + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setActiveFeatureId(id => getNavigatedId(id, event.key, items)) } else { // No action } } - el.addEventListener('keydown', handleKeyDown) - return () => { el.removeEventListener('keydown', handleKeyDown) } + listboxEl.addEventListener('keydown', handleKeyDown) + return () => { listboxEl.removeEventListener('keydown', handleKeyDown) } }, [viewportRef, featuresRef, items]) const onFocus = () => {