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
-)
-
-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 e111b983..7d92bc5f 100644
--- a/src/App/components/Viewport/Features.jsx
+++ b/src/App/components/Viewport/Features.jsx
@@ -1,13 +1,31 @@
-import React from 'react'
-import { Markers } from '../Markers/Markers'
+import React, { forwardRef } from 'react'
+import { useConfig } from '../../store/configContext.js'
-export const Features = () => (
- cannot host SVG marker elements
- role='listbox' // NOSONAR
- tabIndex='-1'
- aria-label='Map features'
- className='im-c-features'
- >
-
-
-)
+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={hasItems ? '0' : '-1'}
+ aria-hidden={hasItems ? undefined : true}
+ aria-label='Map features'
+ aria-activedescendant={activeFeatureId || undefined}
+ className='im-c-features'
+ onFocus={onFocus}
+ >
+ {items.map(item => (
+ -
+ {item.label}
+
+ ))}
+
+ )
+})
+
+Features.displayName = 'Features'
diff --git a/src/App/components/Viewport/Features.test.jsx b/src/App/components/Viewport/Features.test.jsx
new file mode 100644
index 00000000..e96dc356
--- /dev/null
+++ b/src/App/components/Viewport/Features.test.jsx
@@ -0,0 +1,83 @@
+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() // NOSONAR
+ })
+
+ it('renders no options when items is empty', () => {
+ const { container } = render()
+ 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"]') // NOSONAR
+ 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"]') // 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') // NOSONAR
+ })
+
+ it('omits aria-activedescendant when activeFeatureId is absent', () => {
+ const { container } = render()
+ 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')
+ })
+})
+
+// ─── 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"]')) // NOSONAR
+ expect(onFocus).toHaveBeenCalled()
+ })
+})
diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx
index 8248addc..ec592db8 100755
--- a/src/App/components/Viewport/Viewport.jsx
+++ b/src/App/components/Viewport/Viewport.jsx
@@ -1,9 +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'
@@ -11,6 +14,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
@@ -19,13 +23,18 @@ 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)
+ const featuresRef = useRef(null)
// Local state for keyboard hint visibility
const [keyboardHintVisible, setKeyboardHintVisible] = useState(false)
+ const featureItems = useFeatureItems(eventBus)
+ const { activeFeatureId, onFocus: onFeaturesFocus } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems })
+
// Attach map keyboard controls
useKeyboardShortcuts(layoutRefs.viewportRef)
@@ -63,6 +72,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/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/hooks/useFeatureFocus.js b/src/App/hooks/useFeatureFocus.js
new file mode 100644
index 00000000..dd040be5
--- /dev/null
+++ b/src/App/hooks/useFeatureFocus.js
@@ -0,0 +1,47 @@
+import { useState, useEffect } from 'react'
+
+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 listboxEl = featuresRef.current
+ if (!listboxEl) {
+ return undefined
+ }
+
+ const handleKeyDown = (event) => {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setActiveFeatureId(null)
+ viewportRef.current?.focus()
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ setActiveFeatureId(id => getNavigatedId(id, event.key, items))
+ } else {
+ // No action
+ }
+ }
+
+ listboxEl.addEventListener('keydown', handleKeyDown)
+ return () => { listboxEl.removeEventListener('keydown', handleKeyDown) }
+ }, [viewportRef, featuresRef, items])
+
+ const onFocus = () => {
+ if (items.length) {
+ setActiveFeatureId(items[0].id)
+ }
+ }
+
+ return { activeFeatureId, onFocus }
+}
diff --git a/src/App/hooks/useFeatureFocus.test.js b/src/App/hooks/useFeatureFocus.test.js
new file mode 100644
index 00000000..5dd9807b
--- /dev/null
+++ b/src/App/hooks/useFeatureFocus.test.js
@@ -0,0 +1,200 @@
+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('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('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.onFocus())
+ expect(result.current.activeFeatureId).toBeNull()
+ })
+})
+
+// ─── 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')
+ 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))
+ })
+})
+
+// ─── useFeatureFocus — unhandled keys ────────────────────────────────────────
+
+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)
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
+ fireKey(el, 'Tab')
+ expect(result.current.activeFeatureId).toBeNull()
+ expect(viewportFocus).not.toHaveBeenCalled()
+ el.remove()
+ })
+})
+
+// ─── useFeatureFocus — Escape key ────────────────────────────────────────────
+
+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))
+ expect(() => fireKey(el, 'Escape')).not.toThrow()
+ el.remove()
+ })
+})
+
+// ─── useFeatureFocus — ArrowDown navigation ───────────────────────────────────
+
+describe('useFeatureFocus — ArrowDown navigation', () => {
+ const setup = () => {
+ const refs = makeRefs()
+ 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('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/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([])
+ })
+})