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
39 changes: 39 additions & 0 deletions docs/api/button-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[]`

Expand Down Expand Up @@ -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.
10 changes: 7 additions & 3 deletions docs/api/panel-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions plugins/beta/use-location/src/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/App/components/Panel/Panel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
118 changes: 117 additions & 1 deletion src/App/components/PopupMenu/PopupMenu.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 } }
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading