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
6 changes: 3 additions & 3 deletions src/App/controls/keyboardActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,23 @@ export const createKeyboardActions = (mapProvider, announce, {
const place = await reverseGeocode(mapProvider.getZoom(), coord)
const area = mapProvider.getAreaDimensions?.()
const message = area ? `${place}. Covering ${area}.` : `${place}.`
announce(message, 'core')
announce(message, 'action')
},

highlightNextLabel: (e) => {
if (!readMapText || !mapProvider.highlightNextLabel) {
return
}
const label = mapProvider.highlightNextLabel(e.key)
announce(label, 'core')
announce(label, 'action')
},

highlightLabelAtCenter: () => {
if (!readMapText || !mapProvider.highlightLabelAtCenter) {
return
}
const label = mapProvider.highlightLabelAtCenter()
announce(label, 'core')
announce(label, 'action')
},

clearSelection: () => mapProvider?.clearHighlightedLabel?.()
Expand Down
8 changes: 4 additions & 4 deletions src/App/controls/keyboardActions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,15 @@ describe('getInfo', () => {
const { actions, announce } = makeActions()
await actions.getInfo()
expect(reverseGeocode).toHaveBeenCalledWith(ZOOM_LEVEL, { lng: 1, lat: 2 })
expect(announce).toHaveBeenCalledWith('London. Covering 5km².', 'core')
expect(announce).toHaveBeenCalledWith('London. Covering 5km².', 'action')
})

test('announces result without area when getAreaDimensions is absent', async () => {
reverseGeocode.mockResolvedValue('Paris')
const { actions, mapProvider, announce } = makeActions()
delete mapProvider.getAreaDimensions
await actions.getInfo()
expect(announce).toHaveBeenCalledWith('Paris.', 'core')
expect(announce).toHaveBeenCalledWith('Paris.', 'action')
})
})

Expand All @@ -148,7 +148,7 @@ describe('label actions', () => {
const { actions, mapProvider, announce } = makeActions()
mapProvider.highlightNextLabel.mockReturnValue('Label A')
actions.highlightNextLabel({ key: 'A' })
expect(announce).toHaveBeenCalledWith('Label A', 'core')
expect(announce).toHaveBeenCalledWith('Label A', 'action')
})

test('highlightNextLabel is no-op when readMapText is false', () => {
Expand All @@ -162,7 +162,7 @@ describe('label actions', () => {
const { actions, mapProvider, announce } = makeActions()
mapProvider.highlightLabelAtCenter.mockReturnValue('Center Label')
actions.highlightLabelAtCenter()
expect(announce).toHaveBeenCalledWith('Center Label', 'core')
expect(announce).toHaveBeenCalledWith('Center Label', 'action')
})

test('highlightLabelAtCenter is no-op when readMapText is false', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/App/hooks/useMapAnnouncements.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function useMapAnnouncements () {

const message = resolveMessage(previous, current, mapProvider)
if (message) {
announce(message, 'core')
announce(message, 'action')
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/App/hooks/useMapAnnouncements.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('useMapAnnouncements', () => {
direction: 'north',
areaDimensions: { width: 100, height: 100 }
})
expect(announce).toHaveBeenCalledWith('Moved north', 'core')
expect(announce).toHaveBeenCalledWith('Moved north', 'action')
})

test('announces zoomed message when only zoom changes', () => {
Expand All @@ -97,7 +97,7 @@ describe('useMapAnnouncements', () => {
to: 12,
areaDimensions: { width: 100, height: 100 }
})
expect(announce).toHaveBeenCalledWith('Zoomed in', 'core')
expect(announce).toHaveBeenCalledWith('Zoomed in', 'action')
})

test('announces no change message when neither center nor zoom changes', () => {
Expand All @@ -114,7 +114,7 @@ describe('useMapAnnouncements', () => {
center: [0, 0],
zoom: 10
})
expect(announce).toHaveBeenCalledWith('No change', 'core')
expect(announce).toHaveBeenCalledWith('No change', 'action')
})

test('announces new area message when both center and zoom change', () => {
Expand All @@ -132,7 +132,7 @@ describe('useMapAnnouncements', () => {
zoom: 12,
areaDimensions: { width: 100, height: 100 }
})
expect(announce).toHaveBeenCalledWith('New area', 'core')
expect(announce).toHaveBeenCalledWith('New area', 'action')
})

test('detects center change correctly for lat or lng', () => {
Expand Down
52 changes: 41 additions & 11 deletions src/services/announcer.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
// services/announcer.js
import { debounce } from '../utils/debounce.js'

/**
* Live-region announcer for screen readers.
*
* Priority model — the axis that matters is "did the user ask for this?", not
* which part of the code sent it:
*
* - 'action' Direct response to a deliberate user keypress (pan/zoom result,
* Alt+I reverse-geocode, a plugin shortcut result, search results).
* The latest action always wins: rapid actions are debounced down
* to the most recent, and an action is never blocked by an ambient
* message. This is the default.
*
* - 'ambient' Passive/system-initiated message the user did not directly ask
* for (e.g. the "Press Shift+? for keyboard controls" hint shown on
* focus). Ambient messages yield: they are skipped while a recent
* action result is holding priority so they can never clobber what
* the user actually requested.
*/
export function createAnnouncer (mapStatusRef) {
const CLEAR_DELAY = 100
const DEBOUNCE_DELAY = 500
let priorityLock = false
// How long an action result keeps priority over ambient messages. Covers the
// debounce + clear write (600ms) plus a short buffer so a hint firing right
// after a user action can't stomp the action's message before it is read.
const ACTION_HOLD_DELAY = 1000

let actionHoldTimer = null

// Core function to write to the live region
const setLiveRegion = (msg) => {
Expand All @@ -22,27 +45,34 @@ export function createAnnouncer (mapStatusRef) {
}, CLEAR_DELAY)
}

// Debounced announcer to group rapid events
// Debounced announcer to group rapid action events down to the latest one
const debouncedAnnounce = debounce(setLiveRegion, DEBOUNCE_DELAY)

// Hold (or refresh) action priority so ambient messages yield for a window
const holdActionPriority = () => {
clearTimeout(actionHoldTimer)
actionHoldTimer = setTimeout(() => {
actionHoldTimer = null
}, ACTION_HOLD_DELAY)
}

// Public announce function
const announce = (msg, type = 'core') => {
const announce = (msg, kind = 'action') => {
if (!msg) {
return
}

if (type === 'plugin') {
priorityLock = true
if (kind === 'ambient') {
// Yield to a recent user-action result rather than clobber it
if (actionHoldTimer) {
return
}
setLiveRegion(msg)
return
}

if (priorityLock) {
// skip this one but release lock for next time
priorityLock = false
return
}

// Action: latest deliberate user action wins and is always read
holdActionPriority()
debouncedAnnounce(msg)
}

Expand Down
90 changes: 59 additions & 31 deletions src/services/announcer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ describe('createAnnouncer', () => {
it('does nothing if msg is falsy', () => {
announce(null)
expect(mapStatusRef.current.textContent).toBe('')
announce(undefined, 'core')
announce(undefined, 'action')
expect(mapStatusRef.current.textContent).toBe('')
announce('', 'ambient')
expect(mapStatusRef.current.textContent).toBe('')
})

Expand All @@ -31,59 +33,85 @@ describe('createAnnouncer', () => {
})

it('returns early if mapStatusRef.current becomes null during setTimeout', () => {
announce('Test message', 'plugin')
announce('Test message', 'ambient')
expect(mapStatusRef.current.textContent).toBe('') // cleared immediately

mapStatusRef.current = null

expect(() => jest.advanceTimersByTime(100)).not.toThrow()
})

it('sets textContent for a plugin message immediately', () => {
announce('Plugin message', 'plugin')
it('sets textContent for an ambient message immediately (after clear)', () => {
announce('Hint message', 'ambient')
expect(mapStatusRef.current.textContent).toBe('') // cleared immediately
jest.advanceTimersByTime(100)
expect(mapStatusRef.current.textContent).toBe('Plugin message')
expect(mapStatusRef.current.textContent).toBe('Hint message')
})

it('sets textContent for a core message after debounce + clear', () => {
announce('Core message', 'core')
expect(mapStatusRef.current.textContent).toBe('') // cleared immediately
it('sets textContent for an action message after debounce + clear', () => {
announce('Action message', 'action')
expect(mapStatusRef.current.textContent).toBe('')

// advance only CLEAR_DELAY should still be empty due to debounce
// advance only CLEAR_DELAY: still empty, debounce has not fired yet
jest.advanceTimersByTime(100)
expect(mapStatusRef.current.textContent).toBe('')

// advance DEBOUNCE_DELAY to trigger actual announcement
// advance DEBOUNCE_DELAY to trigger the actual announcement
jest.advanceTimersByTime(500)
expect(mapStatusRef.current.textContent).toBe('Action message')
})

it('defaults to an action message when no kind is given', () => {
announce('Default message')
jest.advanceTimersByTime(100)
// action is debounced, so nothing yet
expect(mapStatusRef.current.textContent).toBe('')
jest.advanceTimersByTime(500)
expect(mapStatusRef.current.textContent).toBe('Core message')
expect(mapStatusRef.current.textContent).toBe('Default message')
})

it('respects priority lock when plugin message is sent', () => {
// plugin sets lock
announce('Plugin message', 'plugin')
it('lets an action always announce even after an ambient message (first-time fix)', () => {
// ambient hint shown first (e.g. keyboard-controls hint on focus)
announce('Keyboard hint', 'ambient')
jest.advanceTimersByTime(100)
expect(mapStatusRef.current.textContent).toBe('Plugin message')

// core message should be skipped due to lock
announce('Core message', 'core')
jest.advanceTimersByTime(600) // DEBOUNCE_DELAY + CLEAR_DELAY
expect(mapStatusRef.current.textContent).toBe('Plugin message')

// next core message should work
announce('Next core message', 'core')
jest.advanceTimersByTime(100) // clear first
jest.advanceTimersByTime(500) // debounce
expect(mapStatusRef.current.textContent).toBe('Next core message')
expect(mapStatusRef.current.textContent).toBe('Keyboard hint')

// user action must not be blocked by the earlier ambient message
announce('Reverse geocode result', 'action')
jest.advanceTimersByTime(600) // debounce + clear
expect(mapStatusRef.current.textContent).toBe('Reverse geocode result')
})

it('plugin messages always set priority lock and show immediately', () => {
announce('Plugin 1', 'plugin')
it('gives a recent action priority: an ambient message is skipped', () => {
announce('Map moved', 'action')
jest.advanceTimersByTime(600) // debounce + clear
expect(mapStatusRef.current.textContent).toBe('Map moved')

// ambient arrives within the action hold window and must be ignored
announce('Keyboard hint', 'ambient')
jest.advanceTimersByTime(100)
expect(mapStatusRef.current.textContent).toBe('Plugin 1')
expect(mapStatusRef.current.textContent).toBe('Map moved')
})

it('lets an ambient message through once the action hold window expires', () => {
announce('Map moved', 'action')
jest.advanceTimersByTime(600)
expect(mapStatusRef.current.textContent).toBe('Map moved')

announce('Plugin 2', 'plugin')
// wait out the full ACTION_HOLD_DELAY (1000ms from the action call)
jest.advanceTimersByTime(400)

announce('Keyboard hint', 'ambient')
jest.advanceTimersByTime(100)
expect(mapStatusRef.current.textContent).toBe('Plugin 2')
expect(mapStatusRef.current.textContent).toBe('Keyboard hint')
})

it('latest action wins: rapid actions announce only the most recent', () => {
announce('First', 'action')
jest.advanceTimersByTime(200) // still within debounce window
announce('Second', 'action') // resets the debounce

jest.advanceTimersByTime(600) // let the second one land
expect(mapStatusRef.current.textContent).toBe('Second')
})
})
2 changes: 1 addition & 1 deletion src/services/hints.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function createHints (announce) {
clearTimer()
current = { html }
notify()
announce(announceText ?? stripHtml(html), 'plugin')
announce(announceText ?? stripHtml(html), 'ambient')
if (duration > 0) {
timer = setTimeout(dismiss, duration)
}
Expand Down
4 changes: 2 additions & 2 deletions src/services/hints.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ describe('show', () => {

it('calls announce with stripped html', () => {
hints.show('<kbd>Enter</kbd> to select')
expect(announce).toHaveBeenCalledWith('Enter to select', 'plugin')
expect(announce).toHaveBeenCalledWith('Enter to select', 'ambient')
})

it('calls announce with custom text when announce option is provided', () => {
hints.show('<kbd>Alt+K</kbd> help', { announce: 'Press Alt+K for keyboard controls' })
expect(announce).toHaveBeenCalledWith('Press Alt+K for keyboard controls', 'plugin')
expect(announce).toHaveBeenCalledWith('Press Alt+K for keyboard controls', 'ambient')
})

it('replaces an existing hint and resets the timer', () => {
Expand Down
Loading