From 17f0dc970c73d991c08035d322e779a27857c895 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 25 Sep 2026 12:14:33 +0100 Subject: [PATCH 1/3] Replace hasExclusiveControl with plugin-scoped exclusive control --- docs/plugins/plugin-context.md | 33 +++++++++++++++++++ plugins/search/src/Search.jsx | 6 ++-- plugins/search/src/Search.test.jsx | 14 +++++++- plugins/search/src/search.scss | 2 +- src/App/layout/Layout.jsx | 15 +++++++-- src/App/layout/Layout.test.jsx | 43 ++++++++++++++++++++++--- src/App/renderer/pluginWrapper.js | 9 +++++- src/App/renderer/pluginWrapper.test.js | 18 +++++++++++ src/App/store/AppProvider.test.jsx | 2 +- src/App/store/appActionsMap.js | 20 +++++++++--- src/App/store/appActionsMap.test.js | 44 +++++++++++++++++++++++--- src/App/store/appReducer.js | 2 +- 12 files changed, 184 insertions(+), 24 deletions(-) diff --git a/docs/plugins/plugin-context.md b/docs/plugins/plugin-context.md index 2348f04fc..0335b42c7 100644 --- a/docs/plugins/plugin-context.md +++ b/docs/plugins/plugin-context.md @@ -65,3 +65,36 @@ const { isActive } = context.pluginState // Update state context.pluginState.dispatch({ type: 'setActive', payload: true }) ``` + +--- + +### `setExclusiveControl` +**Type:** `(value: boolean | string | null) => void` + +Available to plugin components (InitComponent, panel and control render components) as a prop. + +Tells the app that your plugin has taken control of the interface, e.g. while a search form is expanded. You don't pass your plugin's id: it's added for you. + +- `setExclusiveControl(true)` adds `im-o-app--exclusive-control-{pluginId}` to the app root. +- `setExclusiveControl('some-name')` adds `im-o-app--exclusive-control-{pluginId}--some-name` instead. The name is used as-is, so pass something class-safe. +- `setExclusiveControl(false)` (or `null`/`undefined`) releases your claim. + +Only one class is ever present, for the most recent claim. Claims stack: if another plugin claims control while yours holds it, its class replaces yours, and when it releases, your class comes back. Releasing only removes your own claim, so it never affects another plugin's. Each plugin holds one claim at a time, so claiming with a new name replaces your previous one rather than stacking on it. Release in your effect's cleanup too, so a claim isn't left behind if your component unmounts. + +Your plugin's own CSS decides what to hide in response, so it can pick what suits its focus behaviour. For example, it could use `opacity: 0` to keep hidden buttons in the tab order, or `display: none` to free up their space. + +```js +// Claim while expanded; release when collapsed or unmounted +useLayoutEffect(() => { + setExclusiveControl(isExpanded) + return () => setExclusiveControl(false) +}, [isExpanded]) +``` + +```scss +.im-o-app--exclusive-control-my-plugin { + .im-o-app__right .im-c-button-wrapper { + display: none; + } +} +``` diff --git a/plugins/search/src/Search.jsx b/plugins/search/src/Search.jsx index 951bcc930..3a76de1ca 100755 --- a/plugins/search/src/Search.jsx +++ b/plugins/search/src/Search.jsx @@ -6,7 +6,7 @@ import { SubmitButton } from './components/SubmitButton/SubmitButton' import { createDatasets } from './datasets.js' import { attachEvents } from './events/index.js' -export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, appState, mapState, services, mapProvider }) { +export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, appState, mapState, services, mapProvider, setExclusiveControl }) { const { id } = appConfig const { interfaceType } = appState const { expanded: defaultExpanded, customDatasets, osNamesURL, regions, maxSuggestions } = pluginConfig @@ -55,11 +55,11 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap }, [isExpanded]) // Manage focus outside the search control - // useLayoutEffect (not useEffect) so hasExclusiveControl flips in the same paint as the + // useLayoutEffect (not useEffect) so exclusiveControl flips in the same paint as the // form expanding - otherwise the browser paints once with the other buttons still visible, // then again once this dispatch lands, producing a visible flicker. useLayoutEffect(() => { - appState.dispatch({ type: 'TOGGLE_HAS_EXCLUSIVE_CONTROL', payload: isExpanded }) + setExclusiveControl(isExpanded) if (!searchOpen) { return undefined diff --git a/plugins/search/src/Search.test.jsx b/plugins/search/src/Search.test.jsx index 30c9f15e6..30e2e33e1 100644 --- a/plugins/search/src/Search.test.jsx +++ b/plugins/search/src/Search.test.jsx @@ -68,7 +68,8 @@ describe('Search component', () => { }, mapState: { markers: {} }, services: {}, - mapProvider: { crs: 'EPSG:3857' } + mapProvider: { crs: 'EPSG:3857' }, + setExclusiveControl: jest.fn() } }) @@ -102,6 +103,17 @@ describe('Search component', () => { expect(container.querySelector('.im-c-search')).not.toHaveClass('im-c-search--collapsed') }) + it('claims exclusive control as search while expanded', () => { + props.pluginState.isExpanded = true + render() + expect(props.setExclusiveControl).toHaveBeenCalledWith(true) + }) + + it('releases exclusive control when collapsed', () => { + render() + expect(props.setExclusiveControl).toHaveBeenCalledWith(false) + }) + it('does not collapse the wrapper in default-expanded mode', () => { props.pluginConfig.expanded = true const { container } = render() diff --git a/plugins/search/src/search.scss b/plugins/search/src/search.scss index c280fe5be..da54c811b 100755 --- a/plugins/search/src/search.scss +++ b/plugins/search/src/search.scss @@ -112,7 +112,7 @@ } // 3. Modifiers -.im-o-app--exclusive-control { +.im-o-app--exclusive-control-search { .im-o-app__top .im-c-button-wrapper, .im-o-app__right .im-c-button-wrapper { opacity: 0; diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx index 9c11533c4..0fe8bf3cb 100755 --- a/src/App/layout/Layout.jsx +++ b/src/App/layout/Layout.jsx @@ -14,11 +14,22 @@ import { Hints } from '../components/Hints/Hints.jsx' import { hasOpenModalPanel } from '../renderer/slotHelpers.js' import { getMapThemeVars } from '../../config/mapTheme.js' +// im-o-app--exclusive-control-{pluginId}, or im-o-app--exclusive-control-{pluginId}--{name} when +// the plugin passed a name, for the most recent claim only. +const getExclusiveControlClass = (exclusiveControl = []) => { + const top = exclusiveControl.at(-1) + if (!top) { + return null + } + const suffix = top.name ? '--' + top.name : '' + return `im-o-app--exclusive-control-${top.pluginId}${suffix}` +} + // eslint-disable-next-line camelcase, react/jsx-pascal-case // sonarjs/disable-next-line function-name export const Layout = () => { const { id, mapLabel, mapHintText } = useConfig() - const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen, openPanels, panelConfig } = useApp() + const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, exclusiveControl, isFullscreen, openPanels, panelConfig } = useApp() const { mapStyle } = useMap() const showModalBackdrop = hasOpenModalPanel(openPanels ?? {}, panelConfig ?? {}, breakpoint) @@ -34,7 +45,7 @@ export const Layout = () => { `im-o-app--${interfaceType}`, `im-o-app--${isFullscreen ? 'fullscreen' : 'inline'}`, `im-o-app--${mapStyle?.appColorScheme || preferredColorScheme}-app`, - hasExclusiveControl && 'im-o-app--exclusive-control' + getExclusiveControlClass(exclusiveControl) ].filter(Boolean).join(' ')} style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }} ref={layoutRefs.appContainerRef} diff --git a/src/App/layout/Layout.test.jsx b/src/App/layout/Layout.test.jsx index b18fe9037..ed736b3f2 100755 --- a/src/App/layout/Layout.test.jsx +++ b/src/App/layout/Layout.test.jsx @@ -55,7 +55,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - hasExclusiveControl: true, + exclusiveControl: [{ pluginId: 'search', name: null }], isFullscreen: false }) useMap.mockReturnValue({ @@ -76,7 +76,8 @@ describe('Layout', () => { expect(root.className).toContain('im-o-app--map') expect(root.className).toContain('im-o-app--inline') expect(root.className).toContain('im-o-app--light-app') - expect(root.className).toContain('im-o-app--exclusive-control') + expect(root.className).toContain('im-o-app--exclusive-control-search') + expect(root.className).not.toContain('im-o-app--exclusive-control-search--') expect(root.style.backgroundColor).toBe('pink') expect(root.style.getPropertyValue('--map-overlay-halo-color')).toBe('#0b0c0c') expect(root.style.getPropertyValue('--map-overlay-selected-color')).toBe('#ffffff') @@ -101,6 +102,38 @@ describe('Layout', () => { expect(backdrop).not.toHaveClass('im-o-app__modal-backdrop--visible') }) + test('adds a single name-suffixed class when a name is set', () => { + useApp.mockReturnValueOnce({ + breakpoint: 'desktop', + interfaceType: 'map', + preferredColorScheme: 'dark', + layoutRefs: mockRefs, + isLayoutReady: true, + exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }], + isFullscreen: false + }) + render() + const root = document.getElementById('myApp-im-app') + expect(root).toHaveClass('im-o-app--exclusive-control-draw--edit-point') + expect(root).not.toHaveClass('im-o-app--exclusive-control-draw') + }) + + test('adds the class for the most recent claim only', () => { + useApp.mockReturnValueOnce({ + breakpoint: 'desktop', + interfaceType: 'map', + preferredColorScheme: 'dark', + layoutRefs: mockRefs, + isLayoutReady: true, + exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }], + isFullscreen: false + }) + render() + const root = document.getElementById('myApp-im-app') + expect(root).toHaveClass('im-o-app--exclusive-control-search') + expect(root.className).not.toContain('exclusive-control-draw') + }) + test('shows the modal backdrop only while a modal-configured panel is open', () => { useApp.mockReturnValue({ breakpoint: 'mobile', @@ -108,7 +141,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - hasExclusiveControl: false, + exclusiveControl: [], isFullscreen: true, openPanels: { settings: { props: {} } }, panelConfig: { settings: { mobile: { modal: true } } } @@ -125,7 +158,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: false, - hasExclusiveControl: false, + exclusiveControl: [], isFullscreen: true }) render() @@ -143,7 +176,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - hasExclusiveControl: false, + exclusiveControl: [], isFullscreen: false }) diff --git a/src/App/renderer/pluginWrapper.js b/src/App/renderer/pluginWrapper.js index 4a290fd79..b3fb94b53 100755 --- a/src/App/renderer/pluginWrapper.js +++ b/src/App/renderer/pluginWrapper.js @@ -1,5 +1,5 @@ // src/core/renderers/pluginWrapper.js -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useConfig } from '../store/configContext.js' import { useApp } from '../store/appContext.js' import { useMap } from '../store/mapContext.js' @@ -48,6 +48,12 @@ export function withPluginContexts (Component, { pluginId, pluginConfig }) { const mapState = useMap() const services = useService() const pluginState = usePlugin(pluginId) + // Bound to this plugin's id so authors never pass it: true claims control, a string claims it + // with a name (--{name} class suffix), anything falsy releases it. + const setExclusiveControl = useCallback((value) => appState.dispatch({ + type: 'SET_EXCLUSIVE_CONTROL', + payload: { pluginId, name: typeof value === 'string' ? value : null, active: !!value } + }), [appState.dispatch]) return ( Object.fromEntries( Object.entries(appState.buttonConfig).filter( ([_, btn]) => btn.pluginId === pluginId diff --git a/src/App/renderer/pluginWrapper.test.js b/src/App/renderer/pluginWrapper.test.js index 4554a15f4..c8c8cb969 100755 --- a/src/App/renderer/pluginWrapper.test.js +++ b/src/App/renderer/pluginWrapper.test.js @@ -37,6 +37,24 @@ describe('withPluginContexts', () => { })) }) + it.each([ + [true, { name: null, active: true }], + ['edit-point', { name: 'edit-point', active: true }], + [false, { name: null, active: false }], + [null, { name: null, active: false }], + [undefined, { name: null, active: false }] + ])('injects setExclusiveControl bound to the plugin id (%p)', (value, expected) => { + const dispatch = jest.fn() + require('../store/appContext.js').useApp.mockReturnValueOnce({ buttonConfig: {}, dispatch }) + const Inner = jest.fn(() =>
Inner
) + const Wrapped = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: {} }) + + render() + Inner.mock.calls[0][0].setExclusiveControl(value) + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_EXCLUSIVE_CONTROL', payload: { pluginId: 'plugin1', ...expected } }) + }) + it('returns the cached wrapper if called again with the same component', () => { const Inner = jest.fn(() =>
Inner
) const Wrapped1 = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: { foo: 'bar' } }) diff --git a/src/App/store/AppProvider.test.jsx b/src/App/store/AppProvider.test.jsx index a52755d9e..95682dcf1 100755 --- a/src/App/store/AppProvider.test.jsx +++ b/src/App/store/AppProvider.test.jsx @@ -129,7 +129,7 @@ describe('AppProvider', () => { previousOpenPanels: {}, interfaceType: 'default', isFullscreen: false, - hasExclusiveControl: false, + exclusiveControl: [], panelRegistry: { getPanelConfig: getPanelConfigMock } // <-- provide it here! })) diff --git a/src/App/store/appActionsMap.js b/src/App/store/appActionsMap.js index d806801c0..a0147bd54 100755 --- a/src/App/store/appActionsMap.js +++ b/src/App/store/appActionsMap.js @@ -145,11 +145,21 @@ const restorePreviousPanels = (state) => { } } -const toggleHasExclusiveControl = (state, payload) => { - return { - ...state, - hasExclusiveControl: payload +// A stack of plugins' claims on the interface, most recent last; Layout adds a single +// im-o-app--exclusive-control-{pluginId}[--{name}] class for the top claim. Claiming moves the +// plugin's claim to the top; releasing removes only its own, so an earlier claim underneath +// (e.g. draw's, while search was open) comes back on its own. +const setExclusiveControl = (state, { pluginId, name = null, active }) => { + const stack = state.exclusiveControl + const top = stack.at(-1) + if (active && top?.pluginId === pluginId && top.name === name) { + return state + } + const others = stack.filter(claim => claim.pluginId !== pluginId) + if (!active && others.length === stack.length) { + return state } + return { ...state, exclusiveControl: active ? [...others, { pluginId, name }] : others } } const toggleNudgeStep = (state) => { @@ -391,7 +401,7 @@ export const actionsMap = { CLOSE_ALL_PANELS: closeAllPanels, RESTORE_PREVIOUS_PANELS: restorePreviousPanels, TOGGLE_APP_VISIBLE: toggleAppVisible, - TOGGLE_HAS_EXCLUSIVE_CONTROL: toggleHasExclusiveControl, + SET_EXCLUSIVE_CONTROL: setExclusiveControl, TOGGLE_NUDGE_STEP: toggleNudgeStep, TOGGLE_BUTTON_DISABLED: toggleButtonDisabled, TOGGLE_BUTTON_HIDDEN: toggleButtonHidden, diff --git a/src/App/store/appActionsMap.test.js b/src/App/store/appActionsMap.test.js index a1f851fb7..88ec0bc9d 100755 --- a/src/App/store/appActionsMap.test.js +++ b/src/App/store/appActionsMap.test.js @@ -24,7 +24,7 @@ describe('actionsMap full coverage', () => { interfaceType: 'default', openPanels: { panel1: { props: {} } }, previousOpenPanels: {}, - hasExclusiveControl: false, + exclusiveControl: [], nudgeStepSize: 'small', safeZoneInset: { top: 0, bottom: 0 }, isLayoutReady: false, @@ -134,9 +134,45 @@ describe('actionsMap full coverage', () => { expect(result.previousOpenPanels).toBe(localState.openPanels) }) - test('TOGGLE_HAS_EXCLUSIVE_CONTROL sets flag', () => { - const result = actionsMap.TOGGLE_HAS_EXCLUSIVE_CONTROL(state, true) - expect(result.hasExclusiveControl).toBe(true) + test('SET_EXCLUSIVE_CONTROL adds a claim with a null name by default', () => { + const result = actionsMap.SET_EXCLUSIVE_CONTROL(state, { pluginId: 'search', active: true }) + expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }]) + }) + + test('SET_EXCLUSIVE_CONTROL stacks a new plugin\'s claim on top', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: true }) + expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }]) + }) + + test('SET_EXCLUSIVE_CONTROL reinstates the claim underneath when the top claim is released', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false }) + expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point' }]) + }) + + test('SET_EXCLUSIVE_CONTROL releasing a claim underneath leaves the top claim in place', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', active: false }) + expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }]) + }) + + test('SET_EXCLUSIVE_CONTROL re-claiming replaces the plugin\'s own claim and moves it to the top', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon' }, { pluginId: 'search', name: null }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', name: 'edit-vertex', active: true }) + expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }, { pluginId: 'draw', name: 'edit-vertex' }]) + }) + + test('SET_EXCLUSIVE_CONTROL re-claiming the top claim unchanged returns the same state', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-vertex' }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', name: 'edit-vertex', active: true }) + expect(result).toBe(localState) + }) + + test('SET_EXCLUSIVE_CONTROL releasing without a claim returns the same state', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon' }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false }) + expect(result).toBe(localState) }) test('TOGGLE_NUDGE_STEP flips small to large', () => { diff --git a/src/App/store/appReducer.js b/src/App/store/appReducer.js index a036ffeb8..901a4f545 100755 --- a/src/App/store/appReducer.js +++ b/src/App/store/appReducer.js @@ -44,7 +44,7 @@ export const initialState = (config) => { hiddenButtons: new Set(), pressedButtons: new Set(), expandedButtons: new Set(), - hasExclusiveControl: false, + exclusiveControl: [], nudgeStepSize: 'large', openPanels, previousOpenPanels: {}, From e542f8df38703bc07656567fcf9afd8049095deb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 25 Sep 2026 14:52:03 +0100 Subject: [PATCH 2/3] Test fix --- src/App/layout/Layout.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx index 0fe8bf3cb..2590a63e1 100755 --- a/src/App/layout/Layout.jsx +++ b/src/App/layout/Layout.jsx @@ -16,7 +16,7 @@ import { getMapThemeVars } from '../../config/mapTheme.js' // im-o-app--exclusive-control-{pluginId}, or im-o-app--exclusive-control-{pluginId}--{name} when // the plugin passed a name, for the most recent claim only. -const getExclusiveControlClass = (exclusiveControl = []) => { +const getExclusiveControlClass = (exclusiveControl) => { const top = exclusiveControl.at(-1) if (!top) { return null From bbefe42feb3b252b08f9a466d6f1c111606b07f3 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 28 Sep 2026 13:56:47 +0100 Subject: [PATCH 3/3] Test that releasing a lower exclusive control claim keeps the top one --- src/App/store/appActionsMap.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/App/store/appActionsMap.test.js b/src/App/store/appActionsMap.test.js index 88ec0bc9d..4010c6405 100755 --- a/src/App/store/appActionsMap.test.js +++ b/src/App/store/appActionsMap.test.js @@ -157,6 +157,13 @@ describe('actionsMap full coverage', () => { expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }]) }) + test('SET_EXCLUSIVE_CONTROL keeps draw\'s claim when search, claimed first, releases after draw claims', () => { + const searchClaimed = actionsMap.SET_EXCLUSIVE_CONTROL({ ...state, exclusiveControl: [] }, { pluginId: 'search', active: true }) + const drawClaimed = actionsMap.SET_EXCLUSIVE_CONTROL(searchClaimed, { pluginId: 'draw', active: true }) + const searchReleased = actionsMap.SET_EXCLUSIVE_CONTROL(drawClaimed, { pluginId: 'search', active: false }) + expect(searchReleased.exclusiveControl).toEqual([{ pluginId: 'draw', name: null }]) + }) + test('SET_EXCLUSIVE_CONTROL re-claiming replaces the plugin\'s own claim and moves it to the top', () => { const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon' }, { pluginId: 'search', name: null }] } const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', name: 'edit-vertex', active: true })