From e50b8deedbc16d379fd387ae76744ae6a2195bb6 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 30 Apr 2026 14:09:51 +0100 Subject: [PATCH 01/15] Hint position fix --- src/App/hooks/useLayoutMeasurements.js | 14 +++++++--- src/App/hooks/useLayoutMeasurements.test.js | 30 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/App/hooks/useLayoutMeasurements.js b/src/App/hooks/useLayoutMeasurements.js index 69256b06..81913466 100755 --- a/src/App/hooks/useLayoutMeasurements.js +++ b/src/App/hooks/useLayoutMeasurements.js @@ -55,7 +55,7 @@ function calculateLayout (layoutRefs) { const { appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef, bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef, - rightTopRef, rightBottomRef + rightTopRef, rightBottomRef, actionsRef } = layoutRefs const appContainer = appContainerRef.current @@ -98,9 +98,15 @@ function calculateLayout (layoutRefs) { appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`) // === Keyboard hint bottom offset === - // Distance from the bottom of im-o-app__bottom to the bottom of im-o-app__main. - // Used to position the hint above the bottom bar (and above drawers on mobile). - appContainer.style.setProperty('--keyboard-hint-bottom', `${main.offsetHeight - bottom.offsetTop - bottom.offsetHeight}px`) + // On mobile the actions bar is in-flow so baseBottom already accounts for it. + // On tablet/desktop the actions bar is position:absolute (not in flow), so baseBottom + // only sees the bottom padding. actionsOffset measures the gap from the actions bar's + // top edge to the bottom of main, ensuring the hint always clears the floating bar. + const actionsEl = actionsRef?.current + const actionsHeight = actionsEl?.offsetHeight ?? 0 + const baseBottom = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight + const actionsOffset = actionsHeight > 0 ? main.offsetHeight - actionsEl.offsetTop : 0 + appContainer.style.setProperty('--keyboard-hint-bottom', `${Math.max(baseBottom, actionsOffset + dividerGap)}px`) // === Sub-slot panel max-heights === appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`) diff --git a/src/App/hooks/useLayoutMeasurements.test.js b/src/App/hooks/useLayoutMeasurements.test.js index c862bb8a..7c7b16b1 100644 --- a/src/App/hooks/useLayoutMeasurements.test.js +++ b/src/App/hooks/useLayoutMeasurements.test.js @@ -100,6 +100,36 @@ describe('useLayoutMeasurements', () => { expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith(varName, expected) }) + test.each([ + [ + 'no actions (height 0) — uses base gap', + { main: { offsetHeight: 500 }, bottom: { offsetTop: 400 }, actions: { offsetTop: 450, offsetHeight: 0 } }, + '100px' // baseBottom = 500 - 400 - 0 = 100 + ], + [ + 'actions floating above base gap (tablet/desktop) — uses actionsOffset + dividerGap', + { main: { offsetHeight: 500 }, bottom: { offsetTop: 440, offsetHeight: 20 }, actions: { offsetTop: 408, offsetHeight: 60 } }, + '100px' // actionsOffset = 500 - 408 = 92, 92 + 8 = 100 > baseBottom (40) + ], + [ + 'base gap already larger than actionsOffset (mobile in-flow) — base gap wins', + { main: { offsetHeight: 500 }, bottom: { offsetTop: 350 }, actions: { offsetTop: 430, offsetHeight: 40 } }, + '150px' // baseBottom = 150 > actionsOffset (70) + dividerGap (8) = 78 + ] + ])('calculates --keyboard-hint-bottom for %s', (_, refOverrides, expected) => { + const { layoutRefs } = setup({ refs: refOverrides }) + renderHook(() => useLayoutMeasurements()) + expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--keyboard-hint-bottom', expected) + }) + + test('calculates --keyboard-hint-bottom when actionsRef.current is null', () => { + const { layoutRefs } = setup() + layoutRefs.actionsRef.current = null + renderHook(() => useLayoutMeasurements()) + // actionsHeight = 0, falls back to baseBottom = 500 - 400 - 0 = 100 + expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--keyboard-hint-bottom', '100px') + }) + test.each([ [{ offsetWidth: 250 }, { offsetWidth: 200 }, '250px'], [{ offsetWidth: 0 }, { offsetWidth: 200 }, '200px'], From 568b35ece8713e721fb1e8656124ab6761e2a420 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 30 Apr 2026 14:59:12 +0100 Subject: [PATCH 02/15] Hints refactor inc new service --- .../KeyboardHints/KeyboardHints.jsx | 43 +++++++ .../KeyboardHints/KeyboardHints.module.scss | 30 +++++ .../KeyboardHints/KeyboardHints.test.jsx | 96 +++++++++++++++ src/App/components/Viewport/Viewport.jsx | 36 ++---- .../components/Viewport/Viewport.module.scss | 20 ---- src/App/components/Viewport/Viewport.test.jsx | 56 ++++----- src/App/hooks/useKeyboardHint.js | 39 ++----- src/App/hooks/useKeyboardHint.test.js | 109 +++++------------ src/App/layout/Layout.jsx | 2 + src/App/layout/Layout.test.jsx | 3 + src/App/store/ServiceProvider.jsx | 6 +- src/App/store/ServiceProvider.test.jsx | 11 ++ src/scss/main.scss | 1 + src/services/hintManager.js | 47 ++++++++ src/services/hintManager.test.js | 110 ++++++++++++++++++ 15 files changed, 422 insertions(+), 187 deletions(-) create mode 100644 src/App/components/KeyboardHints/KeyboardHints.jsx create mode 100644 src/App/components/KeyboardHints/KeyboardHints.module.scss create mode 100644 src/App/components/KeyboardHints/KeyboardHints.test.jsx create mode 100644 src/services/hintManager.js create mode 100644 src/services/hintManager.test.js diff --git a/src/App/components/KeyboardHints/KeyboardHints.jsx b/src/App/components/KeyboardHints/KeyboardHints.jsx new file mode 100644 index 00000000..b7861910 --- /dev/null +++ b/src/App/components/KeyboardHints/KeyboardHints.jsx @@ -0,0 +1,43 @@ +import React, { useState, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { useConfig } from '../../store/configContext.js' +import { useService } from '../../store/serviceContext.js' +import { useApp } from '../../store/appContext.js' + +/** + * Renders the active keyboard hint as a toast portaled into im-o-app__main. + * Positioned above the actions bar using --keyboard-hint-bottom. All visual + * hints pass through here; screen reader announcements are handled internally + * by hintManager so callers only need services.hint(). + * + * The container element (id="${mapId}-hints") is always in the DOM after mount + * so aria-describedby references remain valid even when no hint is showing. + */ +export const KeyboardHints = () => { + const { id } = useConfig() + const { hintManager } = useService() + const { layoutRefs } = useApp() + const [activeHint, setActiveHint] = useState(null) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + return hintManager.subscribe(setActiveHint) + }, [hintManager]) + + if (!mounted || !layoutRefs.mainRef?.current) { + return null + } + + return createPortal( +
+ {activeHint && ( +
+ )} +
, + layoutRefs.mainRef.current + ) +} diff --git a/src/App/components/KeyboardHints/KeyboardHints.module.scss b/src/App/components/KeyboardHints/KeyboardHints.module.scss new file mode 100644 index 00000000..af46dd3c --- /dev/null +++ b/src/App/components/KeyboardHints/KeyboardHints.module.scss @@ -0,0 +1,30 @@ +// =================================================== +// Component: KeyboardHints +// =================================================== + +.im-c-keyboard-hints { + position: absolute; + bottom: var(--keyboard-hint-bottom, var(--primary-gap)); + left: 50%; + transform: translateX(-50%); + z-index: 1001; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--divider-gap); +} + +.im-c-keyboard-hints__hint { + text-wrap: nowrap; + + color: var(--tooltip-foreground-color); + background-color: var(--tooltip-background-color); + border-radius: var(--tooltip-border-radius); + padding: var(--tooltip-padding); + font-size: var(--tooltip-font-size); + line-height: 1.2; +} + +.im-c-keyboard-hints__hint kbd { + border: var(--kbd-tooltip-border); +} diff --git a/src/App/components/KeyboardHints/KeyboardHints.test.jsx b/src/App/components/KeyboardHints/KeyboardHints.test.jsx new file mode 100644 index 00000000..7af07c63 --- /dev/null +++ b/src/App/components/KeyboardHints/KeyboardHints.test.jsx @@ -0,0 +1,96 @@ +import React from 'react' +import { render, act } from '@testing-library/react' +import { KeyboardHints } from './KeyboardHints.jsx' +import { useConfig } from '../../store/configContext.js' +import { useService } from '../../store/serviceContext.js' +import { useApp } from '../../store/appContext.js' + +jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() })) +jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() })) +jest.mock('../../store/appContext.js', () => ({ useApp: jest.fn() })) + +const CONTAINER_ID = '#test-map-hints' +const HINT_CLASS = '.im-c-keyboard-hints__hint' + +let capturedSubscriber +let mockHintManager + +const setup = ({ mainEl = document.createElement('div') } = {}) => { + capturedSubscriber = null + mockHintManager = { + subscribe: jest.fn((fn) => { + capturedSubscriber = fn + return () => {} + }) + } + useConfig.mockReturnValue({ id: 'test-map' }) + useService.mockReturnValue({ hintManager: mockHintManager }) + useApp.mockReturnValue({ layoutRefs: { mainRef: { current: mainEl } } }) + if (mainEl) document.body.appendChild(mainEl) + return mainEl +} + +afterEach(() => { + document.body.innerHTML = '' + jest.clearAllMocks() +}) + +// ─── rendering ─────────────────────────────────────────────────────────────── + +describe('KeyboardHints — rendering', () => { + it('renders the container into mainRef after mount', () => { + const mainEl = setup() + render() + expect(mainEl.querySelector(CONTAINER_ID)).toBeTruthy() + }) + + it('renders no hint content when there is no active hint', () => { + const mainEl = setup() + render() + expect(mainEl.querySelector(HINT_CLASS)).toBeNull() + }) + + it('renders hint content when subscriber fires with a hint', () => { + const mainEl = setup() + render() + act(() => capturedSubscriber({ html: 'Enter to select' })) + const hint = mainEl.querySelector(HINT_CLASS) + expect(hint).toBeTruthy() + expect(hint.innerHTML).toBe('Enter to select') + }) + + it('removes hint content when subscriber fires with null', () => { + const mainEl = setup() + render() + act(() => capturedSubscriber({ html: 'hello' })) + act(() => capturedSubscriber(null)) + expect(mainEl.querySelector(HINT_CLASS)).toBeNull() + }) +}) + +// ─── lifecycle ─────────────────────────────────────────────────────────────── + +describe('KeyboardHints — lifecycle', () => { + it('subscribes to hintManager on mount', () => { + setup() + render() + expect(mockHintManager.subscribe).toHaveBeenCalled() + }) + + it('unsubscribes on unmount', () => { + const unsub = jest.fn() + setup() + mockHintManager = { subscribe: jest.fn(() => unsub) } + useService.mockReturnValue({ hintManager: mockHintManager }) + const { unmount } = render() + unmount() + expect(unsub).toHaveBeenCalled() + }) + + it('renders nothing when mainRef is null', () => { + setup({ mainEl: null }) + useApp.mockReturnValue({ layoutRefs: { mainRef: { current: null } } }) + const { container } = render() + expect(container.innerHTML).toBe('') + }) +}) diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx index 17b72da5..3187d780 100755 --- a/src/App/components/Viewport/Viewport.jsx +++ b/src/App/components/Viewport/Viewport.jsx @@ -1,8 +1,7 @@ -import React, { useRef, useEffect, useState } from 'react' +import React, { useRef, useEffect } 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' @@ -21,35 +20,31 @@ import { Markers } from '../Markers/Markers' export const Viewport = () => { const { id, mapProvider, mapLabel, keyboardHintText } = useConfig() const { interfaceType, mode, previousMode, layoutRefs, safeZoneInset } = useApp() - const { mainRef } = layoutRefs const { mapSize } = useMap() - const { eventBus } = useService() + const { eventBus, hint, hintManager } = 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 { items: featureItems, multiselectable } = useFeatureItems(eventBus) const { activeFeatureId, selectedIds, onFocus: onFeaturesFocus, onBlur: onFeaturesBlur } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus }) - // Attach map keyboard controls useKeyboardShortcuts(layoutRefs.viewportRef) - // Attach map events useMapEvents({ [events.MAP_CLICK]: () => mapProvider?.clearHighlightedLabel?.() }) - // Manage keyboard hint visibility using local state - const { showHint, handleFocus, handleBlur } = useKeyboardHint({ + const { handleFocus, handleBlur } = useKeyboardHint({ interfaceType, containerRef: layoutRefs.viewportRef, - keyboardHintRef, - keyboardHintVisible, - onViewportFocusChange: setKeyboardHintVisible // update local state only + onViewportFocusChange: (visible) => { + if (visible) { + hint(keyboardHintText, { duration: 0 }) + } else { + hintManager.dismiss() + } + } }) // Set focus on viewport on mode change @@ -71,18 +66,9 @@ export const Viewport = () => { onFocus={handleFocus} onBlur={handleBlur} ref={layoutRefs.viewportRef} - aria-describedby={`${id}-keyboard-hint`} + aria-describedby={`${id}-hints`} aria-controls={`${id}-features`} > - {mainRef?.current && createPortal( -
, - mainRef.current - )}
+
) diff --git a/src/App/layout/Layout.test.jsx b/src/App/layout/Layout.test.jsx index 782f1ebe..9cdb1689 100755 --- a/src/App/layout/Layout.test.jsx +++ b/src/App/layout/Layout.test.jsx @@ -21,6 +21,9 @@ jest.mock('../components/Attributions/Attributions', () => ({ jest.mock('../renderer/SlotRenderer', () => ({ SlotRenderer: jest.fn(({ slot }) =>
) })) +jest.mock('../components/KeyboardHints/KeyboardHints', () => ({ + KeyboardHints: jest.fn(() => null) +})) // Mock hooks jest.mock('../store/configContext', () => ({ useConfig: jest.fn() })) diff --git a/src/App/store/ServiceProvider.jsx b/src/App/store/ServiceProvider.jsx index 35c2ddc7..918b6099 100755 --- a/src/App/store/ServiceProvider.jsx +++ b/src/App/store/ServiceProvider.jsx @@ -1,6 +1,7 @@ // src/App/store/ServiceProvider.jsx import React, { createContext, useMemo, useRef } from 'react' import { createAnnouncer } from '../../services/announcer.js' +import { createHintManager } from '../../services/hintManager.js' import { reverseGeocode } from '../../services/reverseGeocode.js' import { useConfig } from '../store/configContext.js' import { closeApp } from '../../services/closeApp.js' @@ -13,18 +14,21 @@ export const ServiceProvider = ({ eventBus, children }) => { const { id, handleExitClick, symbolDefaults: constructorSymbolDefaults } = useConfig() const mapStatusRef = useRef(null) const announce = useMemo(() => createAnnouncer(mapStatusRef), []) + const hintManager = useMemo(() => createHintManager(announce), [announce]) symbolRegistry.setDefaults(constructorSymbolDefaults || {}) const services = useMemo(() => ({ announce, + hint: (html, options) => hintManager.hint(html, options), + hintManager, reverseGeocode: (zoom, center) => reverseGeocode(zoom, center), eventBus, mapStatusRef, closeApp: () => closeApp(id, handleExitClick, eventBus), symbolRegistry, patternRegistry - }), [announce]) + }), [announce, hintManager]) return ( diff --git a/src/App/store/ServiceProvider.test.jsx b/src/App/store/ServiceProvider.test.jsx index d1ce769a..f61762ec 100755 --- a/src/App/store/ServiceProvider.test.jsx +++ b/src/App/store/ServiceProvider.test.jsx @@ -11,6 +11,11 @@ jest.mock('../../services/announcer.js', () => ({ createAnnouncer: jest.fn(() => jest.fn()) })) +const mockHint = jest.fn() +jest.mock('../../services/hintManager.js', () => ({ + createHintManager: jest.fn(() => ({ hint: mockHint, dismiss: jest.fn(), subscribe: jest.fn() })) +})) + jest.mock('../../services/reverseGeocode.js', () => ({ reverseGeocode: jest.fn(() => 'mockedReverseGeocode') })) @@ -57,6 +62,12 @@ describe('ServiceProvider', () => { expect(result.current).toBeTruthy() }) + test('hint() delegates to hintManager.hint', () => { + const { result } = renderHook(() => React.useContext(ServiceContext), { wrapper }) + result.current.hint('test', { duration: 2000 }) + expect(mockHint).toHaveBeenCalledWith('test', { duration: 2000 }) + }) + test('closeApp calls closeApp service with id and handleExitClick', () => { const mockHandleExitClick = jest.fn() const { useConfig } = require('../store/configContext.js') diff --git a/src/scss/main.scss b/src/scss/main.scss index 4ce6b390..a071381f 100755 --- a/src/scss/main.scss +++ b/src/scss/main.scss @@ -25,6 +25,7 @@ @use '../App/components/PopupMenu/PopupMenu.module'; @use '../App/components/Attributions/Attributions.module'; @use '../App/components/Logo/Logo.module'; +@use '../App/components/KeyboardHints/KeyboardHints.module'; @use '../App/components/CrossHair/CrossHair.module'; @use '../App/components/Markers/Markers.module'; @use '../App/components/Viewport/Features.module'; diff --git a/src/services/hintManager.js b/src/services/hintManager.js new file mode 100644 index 00000000..f1f0b269 --- /dev/null +++ b/src/services/hintManager.js @@ -0,0 +1,47 @@ +const stripHtml = (html) => html.replace(/<[^>]*>/g, '') + +/** + * Creates the hint service. Manages a single active toast hint shown in the + * KeyboardHints container. Calling hint() replaces any current hint and + * restarts the dismiss timer. Internally calls announce() so screen readers + * receive the message through the live region without callers needing to pair + * the two calls manually. + */ +export function createHintManager (announce) { + const subscribers = new Set() + let current = null + let timer = null + + const notify = () => subscribers.forEach(fn => fn(current)) + + const clearTimer = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + const dismiss = () => { + clearTimer() + current = null + notify() + } + + const hint = (html, options = {}) => { + const { duration = 4000, announce: announceText } = options + clearTimer() + current = { html } + notify() + announce(announceText ?? stripHtml(html), 'plugin') + if (duration > 0) { + timer = setTimeout(dismiss, duration) + } + } + + const subscribe = (fn) => { + subscribers.add(fn) + return () => subscribers.delete(fn) + } + + return { hint, dismiss, subscribe } +} diff --git a/src/services/hintManager.test.js b/src/services/hintManager.test.js new file mode 100644 index 00000000..ee8de167 --- /dev/null +++ b/src/services/hintManager.test.js @@ -0,0 +1,110 @@ +import { createHintManager } from './hintManager.js' + +let announce +let manager + +beforeEach(() => { + jest.useFakeTimers() + announce = jest.fn() + manager = createHintManager(announce) +}) + +afterEach(() => { + jest.useRealTimers() +}) + +// ─── hint() ────────────────────────────────────────────────────────────────── + +describe('hint', () => { + it('notifies subscribers with the html payload', () => { + const sub = jest.fn() + manager.subscribe(sub) + manager.hint('Enter to select') + expect(sub).toHaveBeenCalledWith({ html: 'Enter to select' }) + }) + + it('calls announce with stripped html', () => { + manager.hint('Enter to select') + expect(announce).toHaveBeenCalledWith('Enter to select', 'plugin') + }) + + it('calls announce with custom text when announce option is provided', () => { + manager.hint('Alt+K help', { announce: 'Press Alt+K for keyboard controls' }) + expect(announce).toHaveBeenCalledWith('Press Alt+K for keyboard controls', 'plugin') + }) + + it('replaces an existing hint and resets the timer', () => { + const sub = jest.fn() + manager.subscribe(sub) + manager.hint('first', { duration: 2000 }) + manager.hint('second', { duration: 2000 }) + jest.advanceTimersByTime(2000) + expect(sub).toHaveBeenLastCalledWith(null) + // Only one dismiss fired (the second hint's timer) + expect(sub).toHaveBeenCalledTimes(3) // hint1, hint2, dismiss + }) + + it('auto-dismisses after duration', () => { + const sub = jest.fn() + manager.subscribe(sub) + manager.hint('hello', { duration: 3000 }) + jest.advanceTimersByTime(3000) + expect(sub).toHaveBeenLastCalledWith(null) + }) + + it('does not auto-dismiss when duration is 0', () => { + const sub = jest.fn() + manager.subscribe(sub) + manager.hint('persistent', { duration: 0 }) + jest.advanceTimersByTime(60000) + expect(sub).not.toHaveBeenCalledWith(null) + }) +}) + +// ─── dismiss() ─────────────────────────────────────────────────────────────── + +describe('dismiss', () => { + it('clears the active hint and notifies', () => { + const sub = jest.fn() + manager.subscribe(sub) + manager.hint('hello') + manager.dismiss() + expect(sub).toHaveBeenLastCalledWith(null) + }) + + it('cancels the auto-dismiss timer', () => { + const sub = jest.fn() + manager.subscribe(sub) + manager.hint('hello', { duration: 3000 }) + manager.dismiss() + sub.mockClear() + jest.advanceTimersByTime(3000) + expect(sub).not.toHaveBeenCalled() + }) + + it('is safe to call when no hint is active', () => { + expect(() => manager.dismiss()).not.toThrow() + }) +}) + +// ─── subscribe / unsubscribe ────────────────────────────────────────────────── + +describe('subscribe', () => { + it('returns an unsubscribe function', () => { + const sub = jest.fn() + const unsub = manager.subscribe(sub) + unsub() + manager.hint('hello') + expect(sub).not.toHaveBeenCalled() + }) + + it('notifies multiple subscribers', () => { + const sub1 = jest.fn() + const sub2 = jest.fn() + manager.subscribe(sub1) + manager.subscribe(sub2) + manager.hint('hello') + expect(sub1).toHaveBeenCalled() + expect(sub2).toHaveBeenCalled() + }) +}) From 316417c302a094cd1c90c4913cfa288faa2f3f2c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 30 Apr 2026 15:22:11 +0100 Subject: [PATCH 03/15] Keyboard hint amends --- .../KeyboardHints/KeyboardHints.jsx | 23 ++++++++++------ .../KeyboardHints/KeyboardHints.module.scss | 11 ++++++-- .../KeyboardHints/KeyboardHints.test.jsx | 10 ++++++- src/App/components/Viewport/Features.jsx | 1 + src/App/components/Viewport/Features.test.jsx | 18 ++++++++----- src/App/components/Viewport/Viewport.jsx | 7 +++-- src/App/components/Viewport/Viewport.test.jsx | 26 +++++++++++++++---- 7 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/App/components/KeyboardHints/KeyboardHints.jsx b/src/App/components/KeyboardHints/KeyboardHints.jsx index b7861910..0e2b47c6 100644 --- a/src/App/components/KeyboardHints/KeyboardHints.jsx +++ b/src/App/components/KeyboardHints/KeyboardHints.jsx @@ -14,7 +14,7 @@ import { useApp } from '../../store/appContext.js' * so aria-describedby references remain valid even when no hint is showing. */ export const KeyboardHints = () => { - const { id } = useConfig() + const { id, keyboardHintText } = useConfig() const { hintManager } = useService() const { layoutRefs } = useApp() const [activeHint, setActiveHint] = useState(null) @@ -30,13 +30,20 @@ export const KeyboardHints = () => { } return createPortal( -
- {activeHint && ( -
- )} +
+
+ {activeHint && ( +
+ )} +
+
, layoutRefs.mainRef.current ) diff --git a/src/App/components/KeyboardHints/KeyboardHints.module.scss b/src/App/components/KeyboardHints/KeyboardHints.module.scss index af46dd3c..84923614 100644 --- a/src/App/components/KeyboardHints/KeyboardHints.module.scss +++ b/src/App/components/KeyboardHints/KeyboardHints.module.scss @@ -1,13 +1,20 @@ // =================================================== -// Component: KeyboardHints +// Object: KeyboardHints (layout wrapper) // =================================================== -.im-c-keyboard-hints { +.im-o-keyboard-hints { position: absolute; bottom: var(--keyboard-hint-bottom, var(--primary-gap)); left: 50%; transform: translateX(-50%); z-index: 1001; +} + +// =================================================== +// Component: KeyboardHints +// =================================================== + +.im-c-keyboard-hints { display: flex; flex-direction: column; align-items: center; diff --git a/src/App/components/KeyboardHints/KeyboardHints.test.jsx b/src/App/components/KeyboardHints/KeyboardHints.test.jsx index 7af07c63..4e58788f 100644 --- a/src/App/components/KeyboardHints/KeyboardHints.test.jsx +++ b/src/App/components/KeyboardHints/KeyboardHints.test.jsx @@ -23,7 +23,7 @@ const setup = ({ mainEl = document.createElement('div') } = {}) => { return () => {} }) } - useConfig.mockReturnValue({ id: 'test-map' }) + useConfig.mockReturnValue({ id: 'test-map', keyboardHintText: 'Alt + K' }) useService.mockReturnValue({ hintManager: mockHintManager }) useApp.mockReturnValue({ layoutRefs: { mainRef: { current: mainEl } } }) if (mainEl) document.body.appendChild(mainEl) @@ -44,6 +44,14 @@ describe('KeyboardHints — rendering', () => { expect(mainEl.querySelector(CONTAINER_ID)).toBeTruthy() }) + it('renders a persistent keyboard-desc span with the hint text', () => { + const mainEl = setup() + render() + const desc = mainEl.querySelector('#test-map-keyboard-desc') + expect(desc).toBeTruthy() + expect(desc.innerHTML).toBe('Alt + K') + }) + it('renders no hint content when there is no active hint', () => { const mainEl = setup() render() diff --git a/src/App/components/Viewport/Features.jsx b/src/App/components/Viewport/Features.jsx index 1766e1b2..d240dee9 100644 --- a/src/App/components/Viewport/Features.jsx +++ b/src/App/components/Viewport/Features.jsx @@ -12,6 +12,7 @@ export const Features = forwardRef(({ activeFeatureId, selectedIds = [], multise tabIndex={hasItems ? '0' : '-1'} aria-hidden={hasItems ? undefined : true} aria-label='Map features' + aria-describedby={`${id}-keyboard-desc`} aria-multiselectable={multiselectable || undefined} aria-activedescendant={activeFeatureId ? `${id}-feature-${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 e752125b..b2fc568f 100644 --- a/src/App/components/Viewport/Features.test.jsx +++ b/src/App/components/Viewport/Features.test.jsx @@ -8,6 +8,7 @@ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() })) const APP_ID = 'test-app' const LISTBOX = '[role="listbox"]' // NOSONAR const OPTION = '[role="option"]' // NOSONAR +const ARIA_SELECTED = 'aria-selected' const ITEMS = [ { id: 'f1', label: 'Feature One' }, { id: 'f2', label: 'Feature Two' } @@ -46,22 +47,22 @@ describe('Features — rendering', () => { it('sets aria-selected on items present in selectedIds', () => { const { container } = render() const options = container.querySelectorAll(OPTION) - expect(options[0]).toHaveAttribute('aria-selected', 'true') - expect(options[1]).toHaveAttribute('aria-selected', 'false') + expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true') + expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false') }) it('sets aria-selected on multiple items when selectedIds has multiple entries', () => { const { container } = render() const options = container.querySelectorAll(OPTION) - expect(options[0]).toHaveAttribute('aria-selected', 'true') - expect(options[1]).toHaveAttribute('aria-selected', 'true') + expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true') + expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') }) it('does not set aria-selected from activeFeatureId alone', () => { const { container } = render() const options = container.querySelectorAll(OPTION) - expect(options[0]).toHaveAttribute('aria-selected', 'false') - expect(options[1]).toHaveAttribute('aria-selected', 'false') + expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false') + expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false') }) it('sets aria-activedescendant to the option element id when activeFeatureId is provided', () => { @@ -97,6 +98,11 @@ describe('Features — rendering', () => { expect(ul.getAttribute('tabIndex')).toBe('-1') expect(ul.getAttribute('aria-hidden')).toBe('true') }) + + it('sets aria-describedby to the shared hints container id', () => { + const { container } = render() + expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR + }) }) // ─── Features — interactions ────────────────────────────────────────────────── diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx index 3187d780..01fc1605 100755 --- a/src/App/components/Viewport/Viewport.jsx +++ b/src/App/components/Viewport/Viewport.jsx @@ -27,7 +27,10 @@ export const Viewport = () => { const featuresRef = useRef(null) const { items: featureItems, multiselectable } = useFeatureItems(eventBus) - const { activeFeatureId, selectedIds, onFocus: onFeaturesFocus, onBlur: onFeaturesBlur } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus }) + const { activeFeatureId, selectedIds, onFocus: handleFeaturesFocus, onBlur: handleFeaturesBlur } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus }) + + const onFeaturesFocus = () => { handleFeaturesFocus(); hint(keyboardHintText, { duration: 0 }) } + const onFeaturesBlur = () => { handleFeaturesBlur(); hintManager.dismiss() } useKeyboardShortcuts(layoutRefs.viewportRef) @@ -66,7 +69,7 @@ export const Viewport = () => { onFocus={handleFocus} onBlur={handleBlur} ref={layoutRefs.viewportRef} - aria-describedby={`${id}-hints`} + aria-describedby={`${id}-keyboard-desc`} aria-controls={`${id}-features`} >