Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/interact/src/hooks/useInteractionHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
11 changes: 0 additions & 11 deletions src/App/components/Markers/MarkerItem.jsx

This file was deleted.

26 changes: 0 additions & 26 deletions src/App/components/Markers/MarkerItem.test.jsx

This file was deleted.

20 changes: 3 additions & 17 deletions src/App/components/Markers/Markers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<MarkerItem key={marker.id} id={featureId} isSelected={false}>
<LabelMarker marker={marker} mapId={id} markerRef={markerRef} />
</MarkerItem>
)
return <LabelMarker key={marker.id} marker={marker} mapId={id} markerRef={markerRef} />
}

const symbolProps = resolveSymbolProps(marker, defaults, symbolRegistry, mapStyle, mapSize, isSelected)

if (marker.showLabel && marker.label) {
return (
<MarkerItem key={marker.id} id={featureId} isSelected={isSelected}>
<SymbolLabelMarker marker={marker} mapId={id} markerRef={markerRef} isSelected={isSelected} symbolProps={symbolProps} />
</MarkerItem>
)
return <SymbolLabelMarker key={marker.id} marker={marker} mapId={id} markerRef={markerRef} isSelected={isSelected} symbolProps={symbolProps} />
}

return (
<MarkerItem key={marker.id} id={featureId} isSelected={isSelected}>
<SymbolMarker marker={marker} mapId={id} markerRef={markerRef} isSelected={isSelected} symbolProps={symbolProps} />
</MarkerItem>
)
return <SymbolMarker key={marker.id} marker={marker} mapId={id} markerRef={markerRef} isSelected={isSelected} symbolProps={symbolProps} />
})}
</>
)
Expand Down
10 changes: 10 additions & 0 deletions src/App/components/Markers/Markers.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
42 changes: 30 additions & 12 deletions src/App/components/Viewport/Features.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<ul // NOSONAR: role='listbox' is correct for a custom composite widget; native <select> cannot host SVG marker elements
role='listbox' // NOSONAR
tabIndex='-1'
aria-label='Map features'
className='im-c-features'
>
<Markers />
</ul>
)
export const Features = forwardRef(({ activeFeatureId, items = [], onFocus }, ref) => {
const { id } = useConfig()
const hasItems = items.length > 0
return (
<ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> 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 => (
<li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern
key={item.id} id={`${id}-feature-${item.id}`} role='option' // NOSONAR
aria-selected={activeFeatureId === item.id}
>
{item.label}
</li>
))}
</ul>
)
})

Features.displayName = 'Features'
83 changes: 83 additions & 0 deletions src/App/components/Viewport/Features.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<Features />)
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(<Features />)
expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
})

it('renders one option per item with correct id and label', () => {
const { container } = render(<Features items={ITEMS} />)
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(<Features items={ITEMS} activeFeatureId='f1' />)
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(<Features items={ITEMS} activeFeatureId='f2' />)
expect(container.querySelector('[role="listbox"]').getAttribute('aria-activedescendant')).toBe('f2') // NOSONAR
})

it('omits aria-activedescendant when activeFeatureId is absent', () => {
const { container } = render(<Features items={ITEMS} />)
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(<Features items={ITEMS} />)
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(<Features />)
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(<Features onFocus={onFocus} />)
fireEvent.focus(container.querySelector('[role="listbox"]')) // NOSONAR
expect(onFocus).toHaveBeenCalled()
})
})
15 changes: 14 additions & 1 deletion src/App/components/Viewport/Viewport.jsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
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'
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
Expand All @@ -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)

Expand Down Expand Up @@ -63,6 +72,7 @@ export const Viewport = () => {
onBlur={handleBlur}
ref={layoutRefs.viewportRef}
aria-describedby={`${id}-keyboard-hint`}
aria-controls={`${id}-features`}
>
{mainRef?.current && createPortal(
<div
Expand All @@ -78,8 +88,11 @@ export const Viewport = () => {
<div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef} aria-hidden='true'>
<CrossHair />
</div>
<div className='im-c-viewport__markers' aria-hidden='true'>
<Markers />
</div>
</div>
<Features />
<Features ref={featuresRef} activeFeatureId={activeFeatureId} items={featureItems} onFocus={onFeaturesFocus} />
</>
)
}
6 changes: 6 additions & 0 deletions src/App/components/Viewport/Viewport.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@
z-index: 1;
}

.im-c-viewport__markers {
position: absolute;
inset: 0;
pointer-events: none;
}


// 3. Modifiers

Expand Down
47 changes: 47 additions & 0 deletions src/App/hooks/useFeatureFocus.js
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading