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
13 changes: 13 additions & 0 deletions docs/api/button-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ Associated panel identifier. When set, clicking the button toggles the panel ope

---

### `ariaControls`
**Type:** `string | function`

Id of a custom control this button toggles, applied as `aria-controls`. Use when the button opens or expands a custom control rendered elsewhere by the plugin. Can be a string or a function that receives the [Context](./context.md) and returns the id.

```js
ariaControls: (context) => `${context.appConfig.id}-search-form`
```

> Pair this with [`expandedWhen`](#expandedwhen) to also reflect the open/closed state via `aria-expanded`.

---

### `keepFocus`
**Type:** `boolean`
**Default:** `false`
Expand Down
12 changes: 10 additions & 2 deletions src/App/components/MapButton/MapButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu }) => {
* @param {boolean} options.isPanelOpen - Whether the controlled panel is open
* @param {boolean} options.isPopupOpen - Whether the popup menu is open
* @param {Object|null} options.controlledElement - The element controlled by this button
* @param {string} [options.ariaControls] - Explicit id for aria-controls when the button controls
* inline UI that is neither a panel nor a popup (e.g. a plugin-owned control). Ignored when
* controlledElement is set, which takes precedence.
* @param {string} options.href - URL for anchor element (if provided, renders as <a> instead of <button>)
* @returns {Object} Props object suitable for button or anchor element
*/
Expand All @@ -140,6 +143,7 @@ const buildButtonProps = ({
isPanelOpen,
isPopupOpen,
controlledElement,
ariaControls,
href
}) => {
let ariaExpanded
Expand All @@ -163,7 +167,7 @@ const buildButtonProps = ({
'aria-disabled': isDisabled || undefined,
'aria-expanded': ariaExpanded,
'aria-pressed': typeof isPressed === 'boolean' ? isPressed : undefined,
'aria-controls': controlledElement?.id,
'aria-controls': controlledElement?.id ?? ariaControls,
'aria-haspopup': controlledElement?.type === 'popup' ? 'menu' : undefined,
...(href
? { href, target: '_blank', onKeyUp: handleKeyUp, role: 'button' }
Expand Down Expand Up @@ -193,6 +197,8 @@ const buildButtonProps = ({
* @param {Array<Object>} [props.menuItems] - Array of items for popup menu
* @param {string} [props.idPrefix=''] - Prefix for generated panel/popup IDs
* @param {string} [props.href] - URL for anchor element; if provided, renders as <a> instead of <button>
* @param {string} [props.ariaControls] - Explicit aria-controls target id for buttons that toggle
* inline UI which is neither a panel nor a popup (a panelId/menuItems always takes precedence).
* @returns {JSX.Element} The rendered button component
*/
export const MapButton = ({
Expand All @@ -211,7 +217,8 @@ export const MapButton = ({
panelId,
menuItems,
idPrefix,
href
href,
ariaControls
}) => {
const { id: appId } = useConfig()
const { buttonRefs } = useApp()
Expand Down Expand Up @@ -275,6 +282,7 @@ export const MapButton = ({
isPanelOpen,
isPopupOpen,
controlledElement,
ariaControls,
href
})

Expand Down
10 changes: 10 additions & 0 deletions src/App/components/MapButton/MapButton.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ describe('MapButton', () => {
expect(getButton()).toHaveAttribute('aria-expanded', 'true')
})

it('sets aria-controls from ariaControls when there is no panel or popup', () => {
renderButton({ ariaControls: 'map-search-form' })
expect(getButton()).toHaveAttribute('aria-controls', 'map-search-form')
})

it('prefers a controlled panel/popup id over ariaControls', () => {
renderButton({ panelId: 'Settings', idPrefix: 'prefix', ariaControls: 'ignored' })
expect(getButton()).toHaveAttribute('aria-controls', 'prefix-panel-settings')
})

it('handles click events', () => {
const onClick = jest.fn()
renderButton({ onClick })
Expand Down
7 changes: 6 additions & 1 deletion src/App/hooks/useEvaluateProp.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ export function useEvaluateProp () {
// Framework entries like 'appConfig' have no reducer so their buttons get pluginStates instead.
if (Object.hasOwn(pluginContext?.state ?? {}, pluginId)) {
const stateForPlugin = pluginContext?.state?.[pluginId] ?? {}
const pluginState = { ...stateForPlugin, dispatch: pluginContext?.dispatch }
// Scope dispatch to this plugin, mirroring usePlugin — the combined reducer
// routes by action.pluginId, so an unscoped dispatch would be a no-op.
const pluginState = {
...stateForPlugin,
dispatch: (action) => pluginContext?.dispatch?.({ ...action, pluginId })
}
return { pluginConfig, pluginState }
}

Expand Down
12 changes: 10 additions & 2 deletions src/App/hooks/useEvaluateProp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,15 @@ describe('useEvaluateProp — plugin state isolation', () => {
const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext })
const ctx = result.current(c => c, 'myPlugin')
expect(ctx.pluginConfig).toEqual({ pluginId: 'myPlugin', includeModes: ['edit'], excludeModes: ['view'] })
expect(ctx.pluginState).toMatchObject({ foo: 'bar', dispatch: pluginDispatch })
expect(ctx.pluginState).toMatchObject({ foo: 'bar', dispatch: expect.any(Function) })
})

it('scopes pluginState.dispatch to the plugin by injecting pluginId', () => {
mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: {} })
const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext })
const ctx = result.current(c => c, 'myPlugin')
ctx.pluginState.dispatch({ type: 'DO_THING', payload: 1 })
expect(pluginDispatch).toHaveBeenCalledWith({ type: 'DO_THING', payload: 1, pluginId: 'myPlugin' })
})

it('does not include pluginStates for real plugin buttons', () => {
Expand All @@ -123,6 +131,6 @@ describe('useEvaluateProp — plugin state isolation', () => {
)
const { result } = renderHook(() => useEvaluateProp(), { wrapper })
const ctx = result.current(c => c, 'myPlugin')
expect(ctx.pluginState).toMatchObject({ dispatch: pluginDispatch })
expect(ctx.pluginState).toMatchObject({ dispatch: expect.any(Function) })
})
})
1 change: 1 addition & 0 deletions src/App/renderer/mapButtons.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ function renderButton ({ btn, appState, appConfig, evaluateProp }) {
panelId={config.panelId}
menuItems={config.menuItems}
idPrefix={appConfig.id}
ariaControls={evaluateProp(config.ariaControls, config.pluginId)}
/>
)
}
Expand Down
5 changes: 5 additions & 0 deletions src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
*
* @typedef {Object} ButtonDefinition
*
* @property {string | ((context: PluginContext) => string)} [ariaControls]
* Id of a custom control this button toggles, set as aria-controls. Use when the button
* opens or expands a custom control rendered elsewhere by the plugin. Text or a function
* returning the id.
*
* @property {ButtonBreakpointConfig} [desktop]
* Desktop breakpoint configuration.
*
Expand Down
Loading