+
{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */}
{directionsGroup}
{zoomGroup}
diff --git a/src/App/components/MoveControl/MoveControl.module.scss b/src/App/components/MoveControl/MoveControl.module.scss
index efff04827..887b161a5 100644
--- a/src/App/components/MoveControl/MoveControl.module.scss
+++ b/src/App/components/MoveControl/MoveControl.module.scss
@@ -12,7 +12,7 @@
gap: var(--divider-gap);
}
-.im-c-move-control--collapsed {
+.im-c-move-control.im-c-move-control--collapsed {
display: none;
}
@@ -99,7 +99,17 @@
// aria-expanded rather than aria-pressed — the trigger is correctly a disclosure
// button (it reveals the control below), not a toggle button, so it doesn't carry
// aria-pressed itself; the colour is purely a visual echo of its expanded state.
-.im-c-map-button--move-control[aria-expanded="true"] {
+//
+// Compound selector (base class + variant class), not the variant class alone —
+// same reasoning as the --collapsed rule above. This selector's specificity was
+// tied with .im-c-map-button:hover (MapButton.module.scss), both (0,2,0), so
+// hovering the expanded trigger button was a coin-flip decided by cascade order —
+// one cssnano mergeRules reshuffle in production and hover started winning,
+// showing the hover tint instead of the pressed colours. The sibling
+// .im-c-move-control .im-c-map-button[aria-pressed="true"] rule above never had
+// this problem (it's already (0,3,0), safely ahead of hover); this one needed the
+// same headroom.
+.im-c-map-button.im-c-map-button--move-control[aria-expanded="true"] {
color: var(--pressed-button-foreground-color);
border-color: var(--pressed-button-border-color);
background-color: var(--pressed-button-background-color);
diff --git a/src/App/components/MoveControl/MoveControl.test.jsx b/src/App/components/MoveControl/MoveControl.test.jsx
index 54035d485..933bbc124 100644
--- a/src/App/components/MoveControl/MoveControl.test.jsx
+++ b/src/App/components/MoveControl/MoveControl.test.jsx
@@ -25,6 +25,7 @@ describe('MoveControl', () => {
dispatch,
expandedButtons: new Set(['moveControl']),
nudgeStepSize: 'large',
+ layoutRefs: { viewportRef: { current: { focus: jest.fn() } } },
...overrides
})
@@ -100,6 +101,31 @@ describe('MoveControl', () => {
expect(announce).toHaveBeenCalledWith('Nudged up')
})
+ it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => {
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
+ expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true)
+ expect(mapProvider.panBy).not.toHaveBeenCalled()
+ expect(announce).toHaveBeenCalledWith('Moved vertex right')
+ })
+
+ it('falls back to panning once activeMoveTarget is released', () => {
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
+ const { rerender } = render(
)
+ mapProvider.activeMoveTarget = null
+ rerender(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
+ expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
+ })
+
+ it('omits the target label from the announcement when activeMoveTarget has none', () => {
+ mapProvider.activeMoveTarget = { move: jest.fn() }
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Move up' }))
+ expect(announce).toHaveBeenCalledWith('Moved up')
+ })
+
it('zooms in and out by the large delta by default and announces the action', () => {
render(
)
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
@@ -139,6 +165,116 @@ describe('MoveControl', () => {
expect(mapProvider.zoomOut).not.toHaveBeenCalled()
})
+ describe('returning focus to the viewport after a click', () => {
+ let rafSpy
+
+ beforeEach(() => {
+ rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 })
+ })
+
+ afterEach(() => rafSpy.mockRestore())
+
+ it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => {
+ const appState = buildAppState({ interfaceType: 'mouse' })
+ useApp.mockReturnValue(appState)
+ render(
)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
+ expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
+ })
+
+ it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => {
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
+ expect(rafSpy).not.toHaveBeenCalled()
+ })
+
+ it('also returns focus after zooming on mouse/touch, but not on keyboard', () => {
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' }))
+ const { rerender } = render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
+ expect(rafSpy).toHaveBeenCalledTimes(1)
+
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
+ rerender(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
+ expect(rafSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => {
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' }))
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
+ expect(rafSpy).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('arrow keys while focus is anywhere within the control', () => {
+ // A keyboard user who tabs to a direction button and repeat-presses Enter keeps
+ // focus there (see the describe block above) — this lets them fall back to raw
+ // arrow keys without first tabbing all the way back out to the map.
+ it('pans the map on an arrow key, regardless of which button currently has focus', () => {
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
+ render(
)
+ // Focus a button unrelated to the direction being pressed, to prove this
+ // isn't just reading the focused button's own handler.
+ fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' }))
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Zoom in' }), { key: 'ArrowRight' })
+ expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
+ expect(announce).toHaveBeenCalledWith('Moved right')
+ })
+
+ it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => {
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
+ render(
)
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' })
+ expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true)
+ expect(mapProvider.panBy).not.toHaveBeenCalled()
+ })
+
+ it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => {
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
+ render(
)
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' })
+ expect(mapProvider.panBy).not.toHaveBeenCalled()
+ })
+
+ it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => {
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
+ render(
)
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true })
+ expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
+ expect(announce).toHaveBeenCalledWith('Nudged right')
+ })
+
+ it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => {
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' }))
+ render(
)
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true })
+ expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
+ expect(announce).toHaveBeenCalledWith('Nudged right')
+ })
+
+ it('shift+arrow overrides activeMoveTarget.move to the small step too', () => {
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
+ render(
)
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true })
+ expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false)
+ })
+
+ it('arrow key without shift still follows the Precision toggle as before', () => {
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
+ render(
)
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' })
+ expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
+ expect(announce).toHaveBeenCalledWith('Moved right')
+ })
+ })
+
it('has a stable "Precision" label regardless of state', () => {
const { rerender } = render(
)
expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
diff --git a/src/utils/detectInterfaceType.js b/src/utils/detectInterfaceType.js
index 900497c9f..0fa1b57cc 100755
--- a/src/utils/detectInterfaceType.js
+++ b/src/utils/detectInterfaceType.js
@@ -59,7 +59,11 @@ function createInterfaceDetector () {
}
const handleKeyDown = e => {
- if (e.key === 'Tab') {
+ // Recognize keyboard mode from Tab (explicit focus), arrow keys (navigation),
+ // Enter (confirmation), or other significant keys. This allows the interface type
+ // to update even when keyboard input happens during drawing (where focus is on map).
+ const keyboardModeKeys = new Set(['Tab', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape'])
+ if (keyboardModeKeys.has(e.key)) {
notifyListeners('keyboard')
}
}
diff --git a/src/utils/isMac.js b/src/utils/isMac.js
new file mode 100644
index 000000000..df5211645
--- /dev/null
+++ b/src/utils/isMac.js
@@ -0,0 +1,15 @@
+/**
+ * Detects whether the current platform is macOS.
+ *
+ * Prefers the modern User-Agent Client Hints API and falls back to the legacy
+ * `navigator.platform`. Guarded so it is safe to call in non-browser
+ * environments (Node/SSR/tests), where it returns `false`.
+ *
+ * @returns {boolean} True when running on macOS.
+ */
+export const isMac = () => {
+ if (typeof navigator === 'undefined') {
+ return false
+ }
+ return /mac/i.test(navigator.userAgentData?.platform || navigator.platform || '')
+}
diff --git a/src/utils/isMac.test.js b/src/utils/isMac.test.js
new file mode 100644
index 000000000..8f7345b9a
--- /dev/null
+++ b/src/utils/isMac.test.js
@@ -0,0 +1,37 @@
+import { isMac } from './isMac.js'
+
+describe('isMac', () => {
+ const original = Object.getOwnPropertyDescriptor(global, 'navigator')
+
+ afterEach(() => {
+ if (original) {
+ Object.defineProperty(global, 'navigator', original)
+ } else {
+ delete global.navigator
+ }
+ })
+
+ const setNavigator = (value) => {
+ Object.defineProperty(global, 'navigator', { value, configurable: true, writable: true })
+ }
+
+ test('returns false when navigator is undefined', () => {
+ setNavigator(undefined)
+ expect(isMac()).toBe(false)
+ })
+
+ test('detects macOS via userAgentData.platform', () => {
+ setNavigator({ userAgentData: { platform: 'macOS' }, platform: '' })
+ expect(isMac()).toBe(true)
+ })
+
+ test('falls back to navigator.platform', () => {
+ setNavigator({ platform: 'MacIntel' })
+ expect(isMac()).toBe(true)
+ })
+
+ test('returns false on non-mac platforms', () => {
+ setNavigator({ userAgentData: { platform: 'Windows' }, platform: 'Win32' })
+ expect(isMac()).toBe(false)
+ })
+})
diff --git a/webpack.umd.mjs b/webpack.umd.mjs
index b67609617..07bc4c8ed 100755
--- a/webpack.umd.mjs
+++ b/webpack.umd.mjs
@@ -146,6 +146,7 @@ const ALL_BUILDS = [
{ entryPath: './plugins/interact/src/index.js', libraryPath: 'interactPlugin', outDir: 'plugins/interact/dist/umd' },
{ entryPath: './plugins/datasets/src/index.js', libraryPath: 'datasetsPlugin', outDir: 'plugins/datasets/dist/umd', cssOutDir: 'plugins/datasets/dist' },
{ entryPath: './plugins/beta/map-styles/src/index.js', libraryPath: 'mapStylesPlugin', outDir: 'plugins/beta/map-styles/dist/umd' },
+ { entryPath: './plugins/draw/src/index.js', libraryPath: 'drawPlugin', outDir: 'plugins/draw/dist/umd' },
{ entryPath: './plugins/beta/draw-ml/src/index.js', libraryPath: 'drawMLPlugin', outDir: 'plugins/beta/draw-ml/dist/umd' },
{ entryPath: './plugins/beta/frame/src/index.js', libraryPath: 'framePlugin', outDir: 'plugins/beta/frame/dist/umd' }
]