diff --git a/docs/api/button-definition.md b/docs/api/button-definition.md index d00c0c66..499f4b0d 100644 --- a/docs/api/button-definition.md +++ b/docs/api/button-definition.md @@ -90,6 +90,31 @@ Associated panel identifier. When set, clicking the button toggles the panel ope --- +### `keepFocus` +**Type:** `boolean` +**Default:** `false` + +When `true`, focus remains on the button after activation rather than moving elsewhere. + +- For **action buttons** (with `onClick`): focus stays on the button instead of returning to the map viewport. +- For **panel buttons** (with `panelId`): focus stays on the button instead of moving to the panel. The panel still opens. + +Useful for buttons the user may press repeatedly (e.g. zoom controls), or to keep focus on a visible trigger while a panel opens alongside it. + +Toggle buttons (`isPressed` / `pressedWhen`) always keep focus regardless of this flag. + +```js +// Zoom — may be pressed repeatedly; keep focus on button +{ id: 'zoomIn', keepFocus: true, onClick: () => map.zoomIn() } + +// Panel trigger — panel opens but focus stays on button +{ id: 'layers', keepFocus: true, panelId: 'layerPanel' } +``` + +> To apply this behaviour for all buttons that open a given panel, set [`focus: false`](./panel-definition.md#focus) on the panel definition instead. + +--- + ### `menuItems` **Type:** `MenuItemDefinition[]` @@ -310,3 +335,17 @@ Reactive callback to determine if the item should appear checked. When set, the ```js pressedWhen: (context) => context.pluginState.selectedOption === 'opt-a' ``` + +--- + +### `panelId` +**Type:** `string` + +Associated panel identifier. When set, selecting the item opens the panel and moves focus to it. The item's `onClick` is not called. + +--- + +### `keepFocus` +**Type:** `boolean` + +When `true`, focus returns to the menu's trigger button after the item is selected, rather than moving to the panel (if `panelId` is set) or the map viewport. diff --git a/docs/api/panel-definition.md b/docs/api/panel-definition.md index 0ab454a5..766a2a87 100644 --- a/docs/api/panel-definition.md +++ b/docs/api/panel-definition.md @@ -50,16 +50,20 @@ Desktop breakpoint configuration. See [Breakpoint Configuration](#breakpoint-con **Type:** `boolean` **Default:** `true` -Whether to move focus to the panel when it is added. Set to `false` when adding a panel on page load to avoid disrupting the user's current focus position. +Whether to move focus to the panel when it opens. Set to `false` to prevent the panel from receiving focus — useful for panels present on page load, or informational panels that should not interrupt the user's current flow. + +Modal panels always receive focus regardless of this setting. ```js -// Page load — no focus +// Page load — panel visible immediately, no focus steal map.addPanel('info', { focus: false, desktop: { slot: 'left-top' } }) -// User-triggered — focus the panel (default) +// User-triggered — focus moves to panel (default) map.addPanel('info', { desktop: { slot: 'left-top' } }) ``` +> For per-button control, set [`keepFocus: true`](./button-definition.md#keepfocus) on the triggering button instead. This prevents focus from moving to the panel for that specific button while other buttons that open the same panel still behave normally. + --- ### `render` diff --git a/plugins/beta/use-location/src/manifest.js b/plugins/beta/use-location/src/manifest.js index 1905f799..78f5f1aa 100755 --- a/plugins/beta/use-location/src/manifest.js +++ b/plugins/beta/use-location/src/manifest.js @@ -21,6 +21,7 @@ export const manifest = { group: { name: 'location', label: 'Location', order: 0 }, label: 'Use your location', iconId: 'locateFixed', + keepFocus: true, hiddenWhen: () => !navigator.geolocation, mobile: buttonSlot, tablet: buttonSlot, diff --git a/src/App/components/Panel/Panel.jsx b/src/App/components/Panel/Panel.jsx index 0e569b98..3c955ac4 100755 --- a/src/App/components/Panel/Panel.jsx +++ b/src/App/components/Panel/Panel.jsx @@ -11,7 +11,7 @@ const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => { const isDialog = !isAside && bpConfig.dismissible const isModal = bpConfig.modal === true const isDismissible = bpConfig.dismissible !== false - const shouldFocus = isModal || (focusOnOpen !== false && (focusOnOpen === true || focus === true || Boolean(triggeringElement))) + const shouldFocus = isModal || focusOnOpen === true || (focusOnOpen !== false && focus !== false && (focus === true || Boolean(triggeringElement))) const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } } diff --git a/src/App/components/PopupMenu/PopupMenu.test.jsx b/src/App/components/PopupMenu/PopupMenu.test.jsx index f18e1b5d..1316c7c9 100644 --- a/src/App/components/PopupMenu/PopupMenu.test.jsx +++ b/src/App/components/PopupMenu/PopupMenu.test.jsx @@ -23,7 +23,11 @@ const mockUseApp = { hiddenButtons: new Set(), disabledButtons: new Set(), pressedButtons: new Set(), - layoutRefs: { appContainerRef: { current: document.body } } + dispatch: jest.fn(), + layoutRefs: { + appContainerRef: { current: document.body }, + viewportRef: { current: { focus: jest.fn() } } + } } jest.mock('../../store/appContext', () => ({ useApp: jest.fn(() => mockUseApp) @@ -148,6 +152,36 @@ describe('PopupMenu', () => { expect(mockSetIsOpen).toHaveBeenCalled() }) + it('click on item with panelId dispatches OPEN_PANEL and closes menu', () => { + mockUseApp.buttonConfig = { item1: { panelId: 'myPanel' } } + renderMenu() + fireEvent.click(screen.getByText('Item 1')) + expect(mockUseApp.dispatch).toHaveBeenCalledWith({ + type: 'OPEN_PANEL', + payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator } } + }) + expect(mockSetIsOpen).toHaveBeenCalledWith(false) + }) + + it('click on item with panelId and keepFocus includes focusOnOpen: false in dispatch', () => { + mockUseApp.buttonConfig = { item1: { panelId: 'myPanel', keepFocus: true } } + renderMenu() + fireEvent.click(screen.getByText('Item 1')) + expect(mockUseApp.dispatch).toHaveBeenCalledWith({ + type: 'OPEN_PANEL', + payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator }, focusOnOpen: false } + }) + }) + + it('click on item with keepFocus returns focus to instigator instead of viewport', () => { + const focusSpy = jest.fn() + mockUseApp.buttonRefs.current.instigator.focus = focusSpy + mockUseApp.buttonConfig = { item1: { keepFocus: true } } + renderMenu() + fireEvent.click(screen.getByText('Item 1')) + expect(focusSpy).toHaveBeenCalled() + }) + it('calls buttonConfig.onClick with evaluateProp if defined', () => { const mockOnClick = jest.fn() mockUseApp.buttonConfig = { item2: { onClick: mockOnClick } } @@ -186,6 +220,47 @@ describe('PopupMenu', () => { expect(mockSetIsOpen).toHaveBeenCalled() }) + it('Enter on item with panelId dispatches OPEN_PANEL and closes menu', () => { + mockUseApp.buttonConfig = { item1: { panelId: 'myPanel' } } + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' }) + expect(mockUseApp.dispatch).toHaveBeenCalledWith({ + type: 'OPEN_PANEL', + payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator } } + }) + expect(mockSetIsOpen).toHaveBeenCalledWith(false) + }) + + it('Enter on item with panelId and keepFocus includes focusOnOpen: false in dispatch', () => { + mockUseApp.buttonConfig = { item1: { panelId: 'myPanel', keepFocus: true } } + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' }) + expect(mockUseApp.dispatch).toHaveBeenCalledWith({ + type: 'OPEN_PANEL', + payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator }, focusOnOpen: false } + }) + }) + + it('Enter on item with keepFocus returns focus to instigator instead of viewport', () => { + const focusSpy = jest.fn() + mockUseApp.buttonRefs.current.instigator.focus = focusSpy + mockUseApp.buttonConfig = { item1: { keepFocus: true } } + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' }) + expect(focusSpy).toHaveBeenCalled() + expect(mockSetIsOpen).toHaveBeenCalledWith(false) + }) + + it('Enter on regular item focuses viewport via requestAnimationFrame', () => { + const rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 }) + const focusSpy = jest.spyOn(mockUseApp.layoutRefs.viewportRef.current, 'focus') + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' }) + expect(focusSpy).toHaveBeenCalled() + rafSpy.mockRestore() + focusSpy.mockRestore() + }) + it('does nothing if click is inside menu', () => { renderMenu() const element = document.createElement('div') @@ -387,6 +462,47 @@ describe('PopupMenu', () => { expect(items[1].onClick).not.toHaveBeenCalled() expect(mockSetIsOpen).not.toHaveBeenCalled() }) + + it('Space on item with panelId dispatches OPEN_PANEL and closes menu', () => { + mockUseApp.buttonConfig = { item1: { panelId: 'myPanel' } } + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' }) + expect(mockUseApp.dispatch).toHaveBeenCalledWith({ + type: 'OPEN_PANEL', + payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator } } + }) + expect(mockSetIsOpen).toHaveBeenCalledWith(false) + }) + + it('Space on item with panelId and keepFocus includes focusOnOpen: false in dispatch', () => { + mockUseApp.buttonConfig = { item1: { panelId: 'myPanel', keepFocus: true } } + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' }) + expect(mockUseApp.dispatch).toHaveBeenCalledWith({ + type: 'OPEN_PANEL', + payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator }, focusOnOpen: false } + }) + }) + + it('Space on item with keepFocus returns focus to instigator instead of viewport', () => { + const focusSpy = jest.fn() + mockUseApp.buttonRefs.current.instigator.focus = focusSpy + mockUseApp.buttonConfig = { item1: { keepFocus: true } } + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' }) + expect(focusSpy).toHaveBeenCalled() + expect(mockSetIsOpen).toHaveBeenCalledWith(false) + }) + + it('Space on regular item focuses viewport via requestAnimationFrame', () => { + const rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 }) + const focusSpy = jest.spyOn(mockUseApp.layoutRefs.viewportRef.current, 'focus') + renderMenu({ startIndex: 0 }) + fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' }) + expect(focusSpy).toHaveBeenCalled() + rafSpy.mockRestore() + focusSpy.mockRestore() + }) }) describe('buttonRect positioning', () => { diff --git a/src/App/components/PopupMenu/usePopupMenu.js b/src/App/components/PopupMenu/usePopupMenu.js index 4b3ee4bf..c71b700b 100644 --- a/src/App/components/PopupMenu/usePopupMenu.js +++ b/src/App/components/PopupMenu/usePopupMenu.js @@ -1,5 +1,6 @@ import { useState, useMemo, useEffect } from 'react' import { stringToKebab } from '../../../utils/stringToKebab.js' +import { useApp } from '../../store/appContext.js' /** * Computes the position and alignment style for the popup menu based on the @@ -44,121 +45,164 @@ const getMenuStyle = (buttonRect) => { * The synthetic event is marked _fromKeyboardActivation so handleItemClick can * ignore it and avoid double-activation. * - * @param {React.SyntheticEvent} e - The triggering React event. - * @param {object} item - The item being activated. - * @param {object} ctx - Dependencies: { buttonConfig, evaluateProp, pluginId, id }. + * @param {React.SyntheticEvent} event - The triggering React event. + * @param {object} item - The item being activated. + * @param {object} ctx - Dependencies: { buttonConfig, evaluateProp, pluginId, id }. */ -const activateItem = (e, item, { buttonConfig, evaluateProp, pluginId, id }) => { +const activateItem = (event, item, { buttonConfig, evaluateProp, pluginId, id }) => { const menuItemConfig = buttonConfig[item.id] if (typeof menuItemConfig?.onClick === 'function') { - menuItemConfig.onClick(e, evaluateProp(ctx => ctx, pluginId)) + menuItemConfig.onClick(event, evaluateProp(ctx => ctx, pluginId)) } else if (typeof item.onClick === 'function') { - item.onClick(e.nativeEvent) + item.onClick(event.nativeEvent) } else { // No action } - if (e.nativeEvent instanceof KeyboardEvent) { - const el = document.getElementById(`${id}-${stringToKebab(item.id)}`) - if (el) { + if (event.nativeEvent instanceof KeyboardEvent) { + const menuItemEl = document.getElementById(`${id}-${stringToKebab(item.id)}`) + if (menuItemEl) { const click = new MouseEvent('click', { bubbles: true, cancelable: true }) click._fromKeyboardActivation = true - el.dispatchEvent(click) + menuItemEl.dispatchEvent(click) } } } +const resolveInitialIndex = (startIndex, startPos, visibleIndices) => { + if (typeof startIndex === 'number') { + return startIndex + } + if (startPos === 'first') { + return visibleIndices[0] ?? -1 + } + if (startPos === 'last') { + return visibleIndices[visibleIndices.length - 1] ?? -1 + } + return -1 +} + +const resolveItemFocus = (item, buttonConfig) => ({ + panelId: buttonConfig[item.id]?.panelId ?? item.panelId, + keepFocus: buttonConfig[item.id]?.keepFocus ?? item.keepFocus +}) + +const handleMenuEnter = (event, { items, index, disabledButtons, activateCtx, instigator, dispatch, viewportRef, setIsOpen }) => { + event.preventDefault() + const item = items[index] + if (item && !disabledButtons.has(item.id)) { + const { panelId, keepFocus } = resolveItemFocus(item, activateCtx.buttonConfig) + if (panelId) { + dispatch({ type: 'OPEN_PANEL', payload: { panelId, props: { triggeringElement: instigator }, ...(keepFocus && { focusOnOpen: false }) } }) + setIsOpen(false) + return + } + activateItem(event, item, activateCtx) + if (keepFocus) { + instigator.focus() + setIsOpen(false) + return + } + } + requestAnimationFrame(() => viewportRef.current?.focus()) + setIsOpen(false) +} + +const handleMenuSpace = (event, { items, index, disabledButtons, activateCtx, instigator, dispatch, viewportRef, setIsOpen }) => { + event.preventDefault() + const item = items[index] + if (!item || disabledButtons.has(item.id)) { + return + } + const { panelId, keepFocus } = resolveItemFocus(item, activateCtx.buttonConfig) + if (panelId) { + dispatch({ type: 'OPEN_PANEL', payload: { panelId, props: { triggeringElement: instigator }, ...(keepFocus && { focusOnOpen: false }) } }) + setIsOpen(false) + return + } + activateItem(event, item, activateCtx) + if (!(item.isPressed !== undefined || item.pressedWhen)) { + if (keepFocus) { + instigator.focus() + } else { + requestAnimationFrame(() => viewportRef.current?.focus()) + } + setIsOpen(false) + } +} + /** * Builds the keydown handler for the menu UL. Handles Escape/Tab (close & focus), * ArrowDown/Up (navigate visible items), Home/End (jump to ends), * Enter (activate and close), Space (activate; close only for non-checkbox items). * - * @param {object} p - * @param {Array} p.items - All menu item descriptors. - * @param {number[]} p.visibleIndices - Indices of non-hidden items. - * @param {number} p.index - Currently highlighted index. - * @param {Function} p.setIndex - State setter for highlighted index. - * @param {Set} p.disabledButtons - IDs of disabled items. - * @param {object} p.instigator - DOM node of the trigger button. - * @param {Function} p.setIsOpen - Callback to close the menu. - * @param {object} p.activateCtx - Context passed through to activateItem. + * @param {object} params + * @param {Array} params.items - All menu item descriptors. + * @param {number[]} params.visibleIndices - Indices of non-hidden items. + * @param {number} params.index - Currently highlighted index. + * @param {Function} params.setIndex - State setter for highlighted index. + * @param {Set} params.disabledButtons - IDs of disabled items. + * @param {object} params.instigator - DOM node of the trigger button. + * @param {Function} params.setIsOpen - Callback to close the menu. + * @param {object} params.activateCtx - Context passed through to activateItem. + * @param {Function} params.dispatch - App dispatch for OPEN_PANEL actions. + * @param {object} params.viewportRef - Ref to the map viewport element. * @returns {Function} onKeyDown handler for the menu element. */ -const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx }) => { - const closeAndFocus = (e, preventDefault = false) => { - if (preventDefault && e?.preventDefault) { - e.preventDefault() +const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx, dispatch, viewportRef }) => { + const closeAndFocus = (event, preventDefault = false) => { + if (preventDefault && event?.preventDefault) { + event.preventDefault() } instigator.focus() setIsOpen(false) } - const navigateVisible = (e) => { - e.preventDefault() - const n = visibleIndices.length - if (n === 0) { + const navigateVisible = (event) => { + event.preventDefault() + const visibleCount = visibleIndices.length + if (visibleCount === 0) { return } const pos = visibleIndices.indexOf(index) let nextPos - if (e.key === 'ArrowDown') { - nextPos = pos === -1 ? 0 : (pos + 1) % n + if (event.key === 'ArrowDown') { + nextPos = pos === -1 ? 0 : (pos + 1) % visibleCount } else if (pos === -1) { - nextPos = n - 1 + nextPos = visibleCount - 1 } else { - nextPos = (pos - 1 + n) % n + nextPos = (pos - 1 + visibleCount) % visibleCount } setIndex(visibleIndices[nextPos]) } - const handleEnter = (e) => { - e.preventDefault() - const item = items[index] - if (item && !disabledButtons.has(item.id)) { - activateItem(e, item, activateCtx) - } - instigator.focus() - setIsOpen(false) - } - - const handleSpace = (e) => { - e.preventDefault() - const item = items[index] - if (!item || disabledButtons.has(item.id)) { - return - } - activateItem(e, item, activateCtx) - if (!(item.isPressed !== undefined || item.pressedWhen)) { - instigator.focus() - setIsOpen(false) - } - } + const actionCtx = { items, index, disabledButtons, activateCtx, instigator, dispatch, viewportRef, setIsOpen } - return (e) => { - if (['Escape', 'Esc'].includes(e.key)) { - closeAndFocus(e, true) + return (event) => { + if (['Escape', 'Esc'].includes(event.key)) { + closeAndFocus(event, true) return } - if (e.key === 'Tab') { - closeAndFocus(e) + if (event.key === 'Tab') { + closeAndFocus(event) return } - if (['ArrowDown', 'ArrowUp'].includes(e.key)) { - navigateVisible(e) + if (['ArrowDown', 'ArrowUp'].includes(event.key)) { + navigateVisible(event) return } - if (e.key === 'Home' && visibleIndices.length) { + if (event.key === 'Home' && visibleIndices.length) { setIndex(visibleIndices[0]) return } - if (e.key === 'End' && visibleIndices.length) { + if (event.key === 'End' && visibleIndices.length) { setIndex(visibleIndices[visibleIndices.length - 1]) return } - if (e.key === 'Enter') { - handleEnter(e) + if (event.key === 'Enter') { + handleMenuEnter(event, actionCtx) } - if (e.key === ' ') { - handleSpace(e) + if (event.key === ' ') { + handleMenuSpace(event, actionCtx) } } } @@ -174,7 +218,7 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa * @param {object} params.instigator - DOM node of the button that opened the menu. * @param {string} params.instigatorKey - Key used to look up instigator in buttonRefs. * @param {object} params.buttonRefs - Ref map of all registered button DOM nodes. - * @param {object} params.buttonConfig - Config map that may override item onClick handlers. + * @param {object} params.buttonConfig - Config map that may override item onClick, panelId, and keepFocus. * @param {Set} params.disabledButtons - IDs of currently disabled items. * @param {string} params.pluginId - Plugin context passed to evaluateProp. * @param {Function} params.evaluateProp - Context evaluator from useEvaluateProp. @@ -189,6 +233,9 @@ export const usePopupMenu = ({ items, hiddenButtons, startIndex, startPos, instigator, instigatorKey, buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect }) => { + const { dispatch, layoutRefs } = useApp() + const viewportRef = layoutRefs.viewportRef + const visibleIndices = useMemo(() => { const visible = [] items.forEach((item, idx) => { @@ -199,38 +246,38 @@ export const usePopupMenu = ({ return visible }, [items, hiddenButtons]) - const [index, setIndex] = useState(() => { - if (typeof startIndex === 'number') { - return startIndex - } - if (startPos === 'first') { - return visibleIndices[0] ?? -1 - } - if (startPos === 'last') { - return visibleIndices[visibleIndices.length - 1] ?? -1 - } - return -1 - }) + const [index, setIndex] = useState(() => resolveInitialIndex(startIndex, startPos, visibleIndices)) const activateCtx = { buttonConfig, evaluateProp, pluginId, id } const handleMenuKeyDown = createMenuKeyDownHandler({ - items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx + items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx, dispatch, viewportRef }) - const handleOutside = (e) => { - if (menuRef.current?.contains(e.target) || buttonRefs.current[instigatorKey]?.contains(e.target)) { + const handleOutside = (event) => { + if (menuRef.current?.contains(event.target) || buttonRefs.current[instigatorKey]?.contains(event.target)) { return } setIsOpen(false) } - const handleItemClick = (e, item) => { - if (e.nativeEvent._fromKeyboardActivation || disabledButtons.has(item.id)) { + const handleItemClick = (event, item) => { + if (event.nativeEvent._fromKeyboardActivation || disabledButtons.has(item.id)) { + return + } + const { panelId, keepFocus } = resolveItemFocus(item, buttonConfig) + if (panelId) { + dispatch({ type: 'OPEN_PANEL', payload: { panelId, props: { triggeringElement: instigator }, ...(keepFocus && { focusOnOpen: false }) } }) + setIsOpen(false) return } setIsOpen(false) - activateItem(e, item, activateCtx) + activateItem(event, item, activateCtx) + if (keepFocus) { + instigator.focus() + } else { + viewportRef.current?.focus() + } } useEffect(() => { diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index 41898b4b..334df2bf 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -52,10 +52,14 @@ function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { function createButtonClickHandler (btn, appState, evaluateProp) { const [, config] = btn const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId]) + const isToggle = config.isPressed !== undefined || !!config.pressedWhen return (e) => { if (typeof config.onClick === 'function') { config.onClick(e, evaluateProp(ctx => ctx, config.pluginId)) + if (!isToggle && !config.keepFocus) { + requestAnimationFrame(() => appState.layoutRefs.viewportRef.current?.focus()) + } return } @@ -65,7 +69,7 @@ function createButtonClickHandler (btn, appState, evaluateProp) { type: isPanelOpen ? 'CLOSE_PANEL' : 'OPEN_PANEL', payload: isPanelOpen ? config.panelId - : { panelId: config.panelId, props: { triggeringElement } } + : { panelId: config.panelId, props: { triggeringElement }, ...(config.keepFocus && { focusOnOpen: false }) } }) } } diff --git a/src/App/renderer/mapButtons.test.js b/src/App/renderer/mapButtons.test.js index ffacd856..fb54153c 100755 --- a/src/App/renderer/mapButtons.test.js +++ b/src/App/renderer/mapButtons.test.js @@ -38,7 +38,8 @@ describe('mapButtons module', () => { pressedButtons: new Set(), expandedButtons: new Set(), buttonConfig: {}, - panelConfig: {} + panelConfig: {}, + layoutRefs: { viewportRef: { current: { focus: jest.fn() } } } } appState.buttonConfig = ({}) getPanelConfig.mockReturnValue({}) @@ -227,6 +228,41 @@ describe('mapButtons module', () => { // dispatch should NOT be called because panelId is missing expect(appState.dispatch).not.toHaveBeenCalled() }) + + it('focuses viewport via requestAnimationFrame after onClick when not toggle and no keepFocus', () => { + const rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 }) + const viewportFocusSpy = jest.spyOn(appState.layoutRefs.viewportRef.current, 'focus') + const onClick = jest.fn() + render({ ...baseBtn, onClick }).props.onClick({}) + expect(viewportFocusSpy).toHaveBeenCalled() + rafSpy.mockRestore() + viewportFocusSpy.mockRestore() + }) + + it('does not call requestAnimationFrame when keepFocus is true', () => { + const rafSpy = jest.spyOn(global, 'requestAnimationFrame') + const onClick = jest.fn() + render({ ...baseBtn, onClick, keepFocus: true }).props.onClick({}) + expect(rafSpy).not.toHaveBeenCalled() + rafSpy.mockRestore() + }) + + it('does not call requestAnimationFrame when button is a toggle (pressedWhen set)', () => { + const rafSpy = jest.spyOn(global, 'requestAnimationFrame') + const onClick = jest.fn() + render({ ...baseBtn, onClick, pressedWhen: jest.fn() }).props.onClick({}) + expect(rafSpy).not.toHaveBeenCalled() + rafSpy.mockRestore() + }) + + it('includes focusOnOpen: false in OPEN_PANEL payload when keepFocus is true', () => { + const mockButtonEl = document.createElement('button') + render({ ...baseBtn, panelId: 'p1', keepFocus: true }).props.onClick({ currentTarget: mockButtonEl }) + expect(appState.dispatch).toHaveBeenCalledWith({ + type: 'OPEN_PANEL', + payload: { panelId: 'p1', props: { triggeringElement: mockButtonEl }, focusOnOpen: false } + }) + }) }) // ------------------------- diff --git a/src/App/store/appActionsMap.js b/src/App/store/appActionsMap.js index 9de2a118..64c506ca 100755 --- a/src/App/store/appActionsMap.js +++ b/src/App/store/appActionsMap.js @@ -23,7 +23,7 @@ function buildOpenPanels (state, panelId, breakpoint, props, focusOnOpen) { return { ...(isExclusiveNonModal ? {} : filteredPanels), ...(isModal ? state.openPanels : {}), - [panelId]: { props, ...(focusOnOpen && { focusOnOpen: true }) } + [panelId]: { props, ...(focusOnOpen !== undefined && { focusOnOpen }) } } } diff --git a/src/config/appConfig.js b/src/config/appConfig.js index e9ef6de6..fa36e52e 100755 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -45,6 +45,7 @@ export const defaultAppConfig = { group: { name: 'zoom', label: 'Zoom controls', order: 0 }, label: 'Zoom in', iconId: 'plus', + keepFocus: true, onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta), excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch', enableWhen: ({ mapState }) => !mapState.isAtMaxZoom, @@ -56,6 +57,7 @@ export const defaultAppConfig = { group: { name: 'zoom', label: 'Zoom controls', order: 0 }, label: 'Zoom out', iconId: 'minus', + keepFocus: true, onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta), excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch', enableWhen: ({ mapState }) => !mapState.isAtMinZoom, diff --git a/src/types.js b/src/types.js index 0ee765dc..de0e6683 100644 --- a/src/types.js +++ b/src/types.js @@ -91,6 +91,41 @@ * Core services (announce, reverseGeocode, closeApp, etc.). */ +/** + * Defines an item in a button's popup menu. + * + * @typedef {Object} MenuItemDefinition + * + * @property {string} id + * Unique item identifier. Used to control state via toggleButtonState(). + * + * @property {string} label + * Display text for the item. + * + * @property {string} [iconId] + * Icon identifier from the icon registry. + * + * @property {string} [iconSvgContent] + * Raw SVG content for the item icon. The outer SVG tag should be excluded. + * + * @property {boolean} [isPressed] + * Initial checked state. When set, the item renders as menuitemcheckbox. + * + * @property {boolean} [keepFocus=false] + * When true, focus returns to the menu's trigger button after selection instead of + * moving to the panel (if panelId is set) or the map viewport. + * + * @property {(e: MouseEvent) => void} [onClick] + * Click handler. Receives the native event. Not called when panelId is set. + * + * @property {string} [panelId] + * Associated panel identifier. When set, selecting the item opens the panel and moves + * focus to it. onClick is not called. + * + * @property {(context: PluginContext) => boolean} [pressedWhen] + * Reactive callback to determine if the item should appear checked. Plugin buttons only. + */ + /** * Defines a button that can be rendered in the UI at various breakpoints. * @@ -133,12 +168,21 @@ * @property {string | (() => string)} label * Accesible label. Text or a function returning the text. Used for the label or tooltip if 'showLabel' is false. * + * @property {MenuItemDefinition[]} [menuItems] + * Items for the button's popup menu. When provided, the button acts as a menu trigger. + * * @property {ButtonBreakpointConfig} mobile * Mobile breakpoint configuration. * * @property {(event: MouseEvent, context: PluginContext) => void} [onClick] * Click handler for the button. * + * @property {boolean} [keepFocus=false] + * When true, focus remains on this button after activation instead of moving to the panel or viewport. + * Use for repeated incremental actions (e.g. zoom) where the user may activate the button + * multiple times. Toggle buttons (isPressed/pressedWhen) always keep focus regardless of this flag. + * When combined with panelId, the panel opens but focus stays on the button. + * * @property {string} [panelId] * Associated panel identifier to toggle open when clicked. * @@ -465,6 +509,11 @@ * @property {PanelBreakpointConfig} desktop * Desktop breakpoint configuration. * + * @property {boolean} [focus=true] + * Whether to move focus to the panel when it opens. Set to false to prevent the panel from + * receiving focus — useful for panels present on page load or panels that should not interrupt + * the user's current flow. Modal panels always receive focus regardless of this setting. + * * @property {string} [html] * HTML content. *