From 79732191db0437b8f9a26e3fd13ab5b2dd957ec1 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 28 Sep 2026 14:09:54 +0100 Subject: [PATCH 01/12] Draw takes exclusive control of the top row, replacing its actions menu In any draw or edit mode the draw plugin claims exclusive control, and draw.scss hides everything else in the top row except map styles and map controls (in whichever top-row slot they're in). The rule has zero specificity so hosts can bring items back with a simple override, and a new exclusiveControl: false option opts out entirely. The Draw/Edit actions menu is replaced by Undo (icon), Snap (labelled toggle, with a visible pressed state) and Delete point (icon) in the top-middle slot. Removes draw.scss's action bar width rules, which conflicted with core's action button sizing and made Cancel and Done overlap. The draw demos now link their own stylesheet: they were linking index.css, so none of draw.scss had been loading, which is why that conflict went unnoticed. --- demo/draw-ol.html | 2 +- demo/draw.html | 8 ++- demo/js/draw.js | 5 +- demo/js/esm.js | 6 +- docs/plugins/draw.md | 25 +++++++- plugins/draw/src/DrawInit.jsx | 15 ++++- plugins/draw/src/DrawInit.test.jsx | 33 +++++++++++ plugins/draw/src/draw.scss | 33 ++++++----- plugins/draw/src/manifest.js | 92 +++++++++++++----------------- plugins/draw/src/manifest.test.js | 40 ++++++------- 10 files changed, 154 insertions(+), 105 deletions(-) diff --git a/demo/draw-ol.html b/demo/draw-ol.html index e7cd812ea..175f3f97e 100644 --- a/demo/draw-ol.html +++ b/demo/draw-ol.html @@ -5,7 +5,7 @@ Draw tools demo (OpenLayers) - + diff --git a/demo/draw.html b/demo/draw.html index b0c679018..554e49093 100644 --- a/demo/draw.html +++ b/demo/draw.html @@ -5,7 +5,13 @@ React TypeScript Webpack App - + + diff --git a/demo/js/draw.js b/demo/js/draw.js index 096b472fa..865d8c6b0 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -67,10 +67,7 @@ const drawPlugin = createDrawPlugin({ onGeometryChange: (event) => ({ valid: isEastOfWalesBorder(event.feature.geometry), reason: 'Points must be placed east of the England/Wales border' - }), - manifest: { - buttons: [{ id: 'drawMenu', mobile: { slot: 'bottom-right' }}] - } + }) }) const datasetsPlugin = createDatasetsPlugin({ diff --git a/demo/js/esm.js b/demo/js/esm.js index 2e5d4cc0b..aad53b0ae 100644 --- a/demo/js/esm.js +++ b/demo/js/esm.js @@ -108,11 +108,7 @@ const interactPlugin = createInteractPlugin({ const framePlugin = createFramePlugin({ aspectRatio: 1.5 }) -const drawPlugin = createDrawPlugin({ - manifest: { - buttons: [{ id: 'drawMenu', mobile: { slot: 'bottom-right' } }] - } -}) +const drawPlugin = createDrawPlugin() const landCoversDataset = { id: 'land-covers', diff --git a/docs/plugins/draw.md b/docs/plugins/draw.md index 015c4a368..e073aadab 100644 --- a/docs/plugins/draw.md +++ b/docs/plugins/draw.md @@ -58,7 +58,7 @@ Vector tile source-layer names to snap new and edited vertices against. Can be o The layer names available depend entirely on your basemap style — there's no universal default, so check your style's vector tile source(s) for the source-layer names to use. The example below (`'OS/TopographicArea_1/Agricultural Land'`) is specific to an Ordnance Survey basemap style. -When set (globally or per call), a "Snap to feature" toggle appears in the draw menu, letting the user turn snapping on and off during a session. +When set (globally or per call), a "Snap" toggle button appears in the top row, letting the user turn snapping on and off during a session. ```js createDrawPlugin({ @@ -76,6 +76,27 @@ Plugin-level validation callback, called throughout the draw/edit lifecycle so y --- +### `exclusiveControl` + +**Type:** `boolean` +**Default:** `true` + +While drawing or editing, the plugin takes [exclusive control](./plugin-context.md#setexclusivecontrol) of the interface and hides everything else in the top row (buttons, button groups and controls) to make room for its own, keeping only the map styles and map controls buttons. + +Set to `false` when every button on the map is deliberate — for example a single-task map that goes straight into editing a shape — so nothing is hidden: + +```js +createDrawPlugin({ exclusiveControl: false }) +``` + +To keep a particular button while still letting draw take control, bring it back with your own CSS. The plugin's hiding rule has zero specificity, so a simple rule is enough: + +```css +.im-c-button-wrapper--datasets-layers { display: block; } +``` + +--- + ### `includeModes` **Type:** `string[]` @@ -396,7 +417,7 @@ interactiveMap.on('draw:merge', (e) => { ## Buttons and keyboard shortcuts -The plugin registers its own toolbar buttons automatically — Cancel, Add point (touch only), Done, and a Draw actions menu (Undo, Snap to feature, Delete point) — which show and enable themselves based on the current draw/edit state. You don't need to render these yourself; augment them with your own trigger buttons (e.g. "Draw polygon", "Draw line") the way the [Draw tools example](../examples/draw-tools.mdx) does. +The plugin registers its own toolbar buttons automatically — Cancel, Add point (touch only) and Done in the actions bar, plus Undo, Snap and Delete point in the middle of the top row — which show and enable themselves based on the current draw/edit state. You don't need to render these yourself; augment them with your own trigger buttons (e.g. "Draw polygon", "Draw line") the way the [Draw tools example](../examples/draw-tools.mdx) does. | Shortcut | Action | |----------|--------| diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index d71e82b89..69a9dfdc0 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef } from 'react' import { EVENTS } from '../../../src/config/events.js' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' @@ -40,7 +40,7 @@ function useLoadDrawAdapter ({ mapState, appState, pluginConfig, pluginState, ma }, [mapState.isMapReady, appState.mode]) } -export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginState, services, mapProvider, buttonConfig }) => { +export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginState, services, mapProvider, buttonConfig, setExclusiveControl }) => { const { eventBus, hints } = services const { crossHair } = mapState const isTouchOrKeyboard = ['touch', 'keyboard'].includes(appState.interfaceType) @@ -55,6 +55,17 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt useLoadDrawAdapter({ mapState, appState, pluginConfig, pluginState, mapProvider, eventBus }) + // Takes exclusive control of the interface in any draw/edit mode, so draw.scss can clear the top + // row for draw's own buttons. Hosts opt out with exclusiveControl: false (e.g. a single-task map + // whose other buttons are all deliberate). useLayoutEffect so the class lands in the same paint. + useLayoutEffect(() => { + if (pluginConfig.exclusiveControl === false) { + return undefined + } + setExclusiveControl(!!pluginState.mode) + return () => setExclusiveControl(false) + }, [pluginState.mode]) + // Suppresses the accessible spatial list for every draw/edit mode except edit_vertex, which // supplies its own list instead (useSpatialList.js above, claimed exclusively via the // registry) — every other mode still has nothing meaningful to show. diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index d72389ae8..b3dabe48e 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -26,6 +26,7 @@ const makeProps = (overrides = {}) => { services: { eventBus: { emit: jest.fn() } }, mapProvider: { draw: null }, buttonConfig: {}, + setExclusiveControl: jest.fn(), ...overrides } return { props, adapter } @@ -266,3 +267,35 @@ describe('event attachment', () => { expect(attachEvents).not.toHaveBeenCalled() }) }) + +describe('exclusive control', () => { + test('claims exclusive control while in a draw/edit mode', async () => { + const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } }) + await renderInit(props) + expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true) + }) + + test('releases exclusive control when the mode clears', async () => { + const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } }) + const { rerender } = await renderInit(props) + rerender() + expect(props.setExclusiveControl).toHaveBeenLastCalledWith(false) + }) + + test('releases exclusive control on unmount', async () => { + const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'draw_line' } }) + const { unmount } = await renderInit(props) + unmount() + expect(props.setExclusiveControl).toHaveBeenLastCalledWith(false) + }) + + test('never claims when the host opts out with exclusiveControl: false', async () => { + const { props } = makeProps({ + pluginConfig: { snapLayers: ['a'], exclusiveControl: false }, + pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } + }) + const { unmount } = await renderInit(props) + unmount() + expect(props.setExclusiveControl).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/draw.scss b/plugins/draw/src/draw.scss index 1b9dfe0e4..b6bdd19ce 100644 --- a/plugins/draw/src/draw.scss +++ b/plugins/draw/src/draw.scss @@ -25,19 +25,24 @@ outline-offset: 2px; } -// Ensure action buttons take up same width -.im-c-actions { - .im-c-button-wrapper--draw-done, - .im-c-button-wrapper--draw-menu, - .im-c-button-wrapper--draw-cancel { - width: 33.33%; - } +// Snap is a toggle in the top row, where core gives pressed buttons no visual state — reuse the +// inverted pressed treatment from the actions bar and map controls so its on state is visible. +.im-c-map-button--draw-snap[aria-pressed="true"] { + color: var(--pressed-button-foreground-color); + border-color: var(--pressed-button-border-color); + background-color: var(--pressed-button-background-color); } -.im-o-app--tablet .im-c-actions, -.im-o-app--desktop .im-c-actions { - .im-c-button-wrapper--draw-done, - .im-c-button-wrapper--draw-menu, - .im-c-button-wrapper--draw-cancel { - width: 100px; - } + +// While draw has exclusive control, clear the top row (buttons, button groups and controls) for +// draw's own buttons, keeping map styles and map controls in whichever top-row slot they're in. +// Wrapped in :where() for zero specificity, so a host can bring an item back with a simple rule, +// e.g. `.im-c-button-wrapper--datasets-layers { display: block; }`. +:where(.im-o-app--exclusive-control-draw .im-o-app__top-col > :not( + .im-c-button-wrapper--map-styles, + .im-c-button-wrapper--map-controls, + .im-c-button-wrapper--draw-undo, + .im-c-button-wrapper--draw-snap, + .im-c-button-wrapper--draw-delete-point +)) { + display: none; } diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index b80272be9..b46e10e23 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -11,9 +11,7 @@ import { split } from './api/split.js' import { merge } from './api/merge.js' import { isMac } from '../../../src/utils/isMac.js' -const DRAW_ACTIONS_SLOT = 'top-middle' - -// edit_point behaves like edit_vertex for Done/Menu/Undo, but stays out of drawDeletePoint and mergeShapes (neither applies to a single coordinate). +// edit_point behaves like edit_vertex for Done/Undo, but stays out of drawDeletePoint and mergeShapes (neither applies to a single coordinate). const EDIT_MODES = new Set(['edit_vertex', 'edit_point']) // .has(), not spread — [...aSet] can silently misbehave under a loose-mode Babel build (see OLDrawManager.js's emit()). const isEditMode = (mode) => EDIT_MODES.has(mode) @@ -24,10 +22,10 @@ const undoCommand = isMac() ? 'Command + Z' : 'CtrlOption' : 'Alt' -const createButtonSlots = (showLabel) => ({ - mobile: { slot: 'actions', showLabel }, - tablet: { slot: 'actions', showLabel }, - desktop: { slot: 'actions', showLabel } +const createButtonSlots = (showLabel, slot = 'actions') => ({ + mobile: { slot, showLabel }, + tablet: { slot, showLabel }, + desktop: { slot, showLabel } }) export const manifest = { @@ -74,49 +72,42 @@ export const manifest = { ...createButtonSlots(true) }, { - id: 'drawMenu', - label: ({ pluginState }) => isEditMode(pluginState.mode) ? 'Edit actions' : 'Draw actions', - iconId: 'menu', - exclusiveSlot: true, - // draw_point belongs here too — it has no Undo (nothing to undo before a single-click - // commit, gated separately below) and no delete-vertex, but it DOES support snapping, - // and the Snap toggle is a menuItem living inside this same button. - hiddenWhen: ({ pluginState }) => !(['draw_polygon', 'draw_line', 'draw_point'].includes(pluginState.mode) || isEditMode(pluginState.mode)), - menuItems: [ - { - id: 'drawUndo', - label: 'Undo', - iconId: 'undo', - hiddenWhen: ({ pluginState }) => !(['draw_polygon', 'draw_line'].includes(pluginState.mode) || isEditMode(pluginState.mode)), - enableWhen: ({ pluginState }) => { - if (['draw_polygon', 'draw_line'].includes(pluginState.mode)) { - return pluginState.numVertices > 0 - } - return pluginState.undoStackLength > 0 - } - }, - { - id: 'drawSnap', - label: 'Snap to feature', - iconId: 'magnet', - hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, - pressedWhen: ({ pluginState }) => !!pluginState.snap - }, - { - id: 'drawDeletePoint', - label: 'Delete point', - iconId: 'trash', - enableWhen: ({ pluginState }) => { - if (pluginState.selectedVertexIndex < 0) { return false } - const isPolygon = pluginState.feature?.geometry?.type === 'Polygon' - return isPolygon ? pluginState.numVertices > 3 : pluginState.numVertices > 2 // NOSONAR - }, - hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' + // Undo, Snap and Delete point sit together in the middle of the top row (in this order), which + // draw.scss clears of other buttons while draw has exclusive control. + id: 'drawUndo', + label: 'Undo', + iconId: 'undo', + // Hidden for draw_point — nothing to undo before a single-click commit. + hiddenWhen: ({ pluginState }) => !(['draw_polygon', 'draw_line'].includes(pluginState.mode) || isEditMode(pluginState.mode)), + enableWhen: ({ pluginState }) => { + if (['draw_polygon', 'draw_line'].includes(pluginState.mode)) { + return pluginState.numVertices > 0 } - ], - mobile: { slot: DRAW_ACTIONS_SLOT }, - tablet: { slot: DRAW_ACTIONS_SLOT }, - desktop: { slot: DRAW_ACTIONS_SLOT } + return pluginState.undoStackLength > 0 + }, + ...createButtonSlots(false, 'top-middle') + }, + { + id: 'drawSnap', + label: 'Snap', + iconId: 'magnet', + hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, + pressedWhen: ({ pluginState }) => !!pluginState.snap, + ...createButtonSlots(true, 'top-middle') + }, + { + id: 'drawDeletePoint', + label: 'Delete point', + iconId: 'trash', + enableWhen: ({ pluginState }) => { + if (pluginState.selectedVertexIndex < 0) { return false } + const isPolygon = pluginState.feature?.geometry?.type === 'Polygon' + return isPolygon ? pluginState.numVertices > 3 : pluginState.numVertices > 2 // NOSONAR + }, + // Deliberately excluded from edit_point — deleting a point's one coordinate is deleting + // the whole feature (deleteFeature's job), not a per-vertex action. + hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex', + ...createButtonSlots(false, 'top-middle') } ], @@ -158,9 +149,6 @@ export const manifest = { }], icons: [{ - id: 'menu', - svgContent: '' - }, { id: 'undo', svgContent: '' }, { diff --git a/plugins/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js index 0c69984fc..916be3a3c 100644 --- a/plugins/draw/src/manifest.test.js +++ b/plugins/draw/src/manifest.test.js @@ -1,7 +1,6 @@ import { manifest } from './manifest.js' const findButton = (id) => manifest.buttons.find((b) => b.id === id) -const findMenuItem = (menuId, itemId) => findButton(menuId).menuItems.find((m) => m.id === itemId) describe('manifest structure', () => { test('exposes the reducer, init component and api surface', () => { @@ -63,32 +62,21 @@ describe('drawDone', () => { }) }) -describe('drawMenu', () => { - test('is hidden outside draw/edit modes', () => { - expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: null } })).toBe(true) - expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(false) - }) - - // draw_point has no Undo and no delete-vertex, but it does support snapping, and Snap is - // a menuItem inside this same button — so the button itself must stay visible for it even - // though drawUndo/drawDeletePoint (below) correctly keep excluding it. - test('is visible during draw_point, for the Snap toggle, even though it has no undo/delete', () => { - expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: 'draw_point' } })).toBe(false) +describe('top row buttons', () => { + test('sit in the top-middle slot as Undo, Snap, Delete point, with only Snap showing its label', () => { + const topMiddle = manifest.buttons.filter(b => b.desktop?.slot === 'top-middle').map(b => b.id) + expect(topMiddle).toEqual(['drawUndo', 'drawSnap', 'drawDeletePoint']) + expect(findButton('drawUndo').desktop).toEqual({ slot: 'top-middle', showLabel: false }) + expect(findButton('drawSnap').desktop).toEqual({ slot: 'top-middle', showLabel: true }) + expect(findButton('drawDeletePoint').desktop).toEqual({ slot: 'top-middle', showLabel: false }) }) - test('labels "Edit actions" in edit mode, "Draw actions" otherwise', () => { - expect(findButton('drawMenu').label({ pluginState: { mode: 'edit_vertex' } })).toBe('Edit actions') - expect(findButton('drawMenu').label({ pluginState: { mode: 'edit_point' } })).toBe('Edit actions') - expect(findButton('drawMenu').label({ pluginState: { mode: 'draw_polygon' } })).toBe('Draw actions') - expect(findButton('drawMenu').label({ pluginState: { mode: 'draw_line' } })).toBe('Draw actions') - }) - - test('is visible during edit_point', () => { - expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: 'edit_point' } })).toBe(false) + test('replace the draw/edit actions menu', () => { + expect(findButton('drawMenu')).toBeUndefined() }) describe('drawUndo', () => { - const item = () => findMenuItem('drawMenu', 'drawUndo') + const item = () => findButton('drawUndo') test('is hidden outside draw/edit modes', () => { expect(item().hiddenWhen({ pluginState: { mode: null } })).toBe(true) @@ -114,7 +102,7 @@ describe('drawMenu', () => { }) describe('drawSnap', () => { - const item = () => findMenuItem('drawMenu', 'drawSnap') + const item = () => findButton('drawSnap') test('is hidden without a mode or snap layers', () => { expect(item().hiddenWhen({ pluginState: { mode: null, hasSnapLayers: true } })).toBe(true) @@ -122,6 +110,10 @@ describe('drawMenu', () => { expect(item().hiddenWhen({ pluginState: { mode: 'draw_line', hasSnapLayers: true } })).toBe(false) }) + test('is visible during draw_point, which supports snapping but has no undo/delete', () => { + expect(item().hiddenWhen({ pluginState: { mode: 'draw_point', hasSnapLayers: true } })).toBe(false) + }) + test('is pressed when snapping is enabled', () => { expect(item().pressedWhen({ pluginState: { snap: true } })).toBe(true) expect(item().pressedWhen({ pluginState: { snap: false } })).toBe(false) @@ -129,7 +121,7 @@ describe('drawMenu', () => { }) describe('drawDeletePoint', () => { - const item = () => findMenuItem('drawMenu', 'drawDeletePoint') + const item = () => findButton('drawDeletePoint') test('is hidden outside edit mode', () => { expect(item().hiddenWhen({ pluginState: { mode: 'draw_polygon' } })).toBe(true) From 8e8bcc405b402a7c54a14327570ff8fb15c87b0d Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 28 Sep 2026 15:14:18 +0100 Subject: [PATCH 02/12] Hide the rest of the interface while drawing, with a configurable keep list --- demo/index.html | 6 ++ docs/api.md | 2 +- docs/api/button-definition.md | 2 +- docs/plugins/draw.md | 39 ++++++++--- docs/plugins/plugin-context.md | 18 ++++- plugins/draw/src/DrawInit.jsx | 48 +++++++++++-- plugins/draw/src/DrawInit.test.jsx | 83 +++++++++++++++++++++-- plugins/draw/src/defaults.js | 6 ++ plugins/draw/src/draw.scss | 14 ---- src/App/components/Panel/Panel.jsx | 11 +-- src/App/components/Panel/Panel.test.jsx | 5 ++ src/App/layout/Layout.jsx | 2 +- src/App/renderer/HtmlElementHost.jsx | 16 +++-- src/App/renderer/HtmlElementHost.test.jsx | 25 +++++++ src/App/renderer/mapButtons.js | 20 +++++- src/App/renderer/mapButtons.test.js | 20 ++++++ src/App/renderer/mapControls.js | 12 +++- src/App/renderer/mapControls.test.js | 29 +++++++- src/App/renderer/mapPanels.js | 6 +- src/App/renderer/mapPanels.test.js | 9 +++ src/App/renderer/pluginWrapper.js | 7 +- src/App/renderer/pluginWrapper.test.js | 14 +++- src/App/renderer/slotHelpers.js | 15 ++++ src/App/renderer/slotHelpers.test.js | 35 +++++++++- src/App/store/appActionsMap.js | 20 +++--- src/App/store/appActionsMap.test.js | 41 +++++++---- src/types.js | 17 +++++ 27 files changed, 444 insertions(+), 78 deletions(-) diff --git a/demo/index.html b/demo/index.html index fb0a0de3e..09c5b10b9 100755 --- a/demo/index.html +++ b/demo/index.html @@ -6,6 +6,12 @@ React TypeScript Webpack App + diff --git a/docs/api.md b/docs/api.md index 30404323b..5346e369e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -785,7 +785,7 @@ Add a custom control to the UI at runtime. | Parameter | Type | Description | |-----------|------|-------------| -| `id` | `string` | Unique control identifier | +| `id` | `string` | Unique control identifier. Also the id to list in a plugin's exclusive-control keep list, e.g. the draw plugin's [`exclusiveControl.keep`](./plugins/draw.md#exclusivecontrol) | | `config` | `ControlDefinition` | Control configuration | See [ControlDefinition](./api/control-definition.md) for configuration options. diff --git a/docs/api/button-definition.md b/docs/api/button-definition.md index abd789bad..da210e97a 100644 --- a/docs/api/button-definition.md +++ b/docs/api/button-definition.md @@ -10,7 +10,7 @@ Defines a button that can be rendered in the UI at various breakpoints. **Type:** `string` **Required** -Unique button identifier. +Unique button identifier. It's also the id to list when a plugin that takes exclusive control of the interface lets you keep items visible, e.g. the draw plugin's [`exclusiveControl.keep`](../plugins/draw.md#exclusivecontrol). --- diff --git a/docs/plugins/draw.md b/docs/plugins/draw.md index e073aadab..e0e47e611 100644 --- a/docs/plugins/draw.md +++ b/docs/plugins/draw.md @@ -78,21 +78,44 @@ Plugin-level validation callback, called throughout the draw/edit lifecycle so y ### `exclusiveControl` -**Type:** `boolean` -**Default:** `true` +**Type:** `boolean | { keep: string[] | ((defaults: string[], context: { ids: string[] }) => string[]) }` +**Default:** `{ keep: ['mapStyles', 'mapControls', 'scaleBar'] }` -While drawing or editing, the plugin takes [exclusive control](./plugin-context.md#setexclusivecontrol) of the interface and hides everything else in the top row (buttons, button groups and controls) to make room for its own, keeping only the map styles and map controls buttons. +While drawing or editing, the plugin takes [exclusive control](./plugin-context.md#setexclusivecontrol) of the interface: every button, panel and control, in every slot, is hidden except the items on its keep list and draw's own buttons. Everything reappears as it was when the draw or edit mode ends. Hidden items stay mounted, so open panels keep their state and scroll position. Modal panels are never hidden. -Set to `false` when every button on the map is deliberate — for example a single-task map that goes straight into editing a shape — so nothing is hidden: +`keep` lists button, panel and control ids, and replaces the default list, so include any defaults you still want. For example, to also keep search and a button of your own: ```js -createDrawPlugin({ exclusiveControl: false }) +createDrawPlugin({ + exclusiveControl: { keep: ['mapStyles', 'mapControls', 'scaleBar', 'search', 'myButton'] } +}) +``` + +Or to hide map styles, leave it out: + +```js +createDrawPlugin({ exclusiveControl: { keep: ['mapControls', 'scaleBar'] } }) +``` + +`keep` can also be a function, so you can adjust the defaults without repeating them. It receives a copy of the default list and `ids`, every button, panel and control id currently on the map, and returns the list to keep. It's called each time a draw or edit mode starts, so logging `ids` is a handy way to find the id you need: + +```js +createDrawPlugin({ + exclusiveControl: { + keep: (defaults, { ids }) => { + console.log(ids) + return [...defaults.filter(id => id !== 'scaleBar'), 'search'] + } + } +}) ``` -To keep a particular button while still letting draw take control, bring it back with your own CSS. The plugin's hiding rule has zero specificity, so a simple rule is enough: +An id covers every item that shares it: `mapStyles` keeps both the map styles button and its panel, and `search` keeps both the search button and its form. If the user opens search while drawing, draw's hiding still applies to everything else. -```css -.im-c-button-wrapper--datasets-layers { display: block; } +Set to `false` when every button on the map is deliberate — for example a single-task map that goes straight into editing a shape — so nothing is hidden: + +```js +createDrawPlugin({ exclusiveControl: false }) ``` --- diff --git a/docs/plugins/plugin-context.md b/docs/plugins/plugin-context.md index 0335b42c7..59749b471 100644 --- a/docs/plugins/plugin-context.md +++ b/docs/plugins/plugin-context.md @@ -69,7 +69,7 @@ context.pluginState.dispatch({ type: 'setActive', payload: true }) --- ### `setExclusiveControl` -**Type:** `(value: boolean | string | null) => void` +**Type:** `(value: boolean | string | null, options?: { keep?: string[] }) => void` Available to plugin components (InitComponent, panel and control render components) as a prop. @@ -98,3 +98,19 @@ useLayoutEffect(() => { } } ``` + +#### Hiding everything else + +To take over the whole interface, pass a `keep` list of ids. While your claim holds, core hides every button, panel and control, in every slot, except those whose id is listed and your own plugin's items. You don't need any CSS: + +```js +setExclusiveControl(isActive, { keep: ['mapStyles', 'mapControls', 'scaleBar'] }) +``` + +- One id can cover several items, e.g. `mapStyles` is both the map styles button and its panel. +- Hidden items are hidden with `display: none`, not removed, so their state, scroll position and focus-return targets survive, and they reappear as they were when your claim is released. +- Modal panels are never hidden, so focus can't get trapped in a hidden one. +- If several claims with a `keep` list are active, an item only shows if every one of them allows it. +- Without `keep`, core hides nothing, and your CSS decides what to hide in response to your class (as in the examples above). + +Use `keep` for a mode the user stays in until they end it (like drawing), where hidden items shouldn't be reachable with Tab. Leave it out and hide things with your own CSS when hidden items must stay focusable, for example if your UI closes when focus leaves it, as search does, so Tab can still move on to the next item. diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index 69a9dfdc0..cda9f63e2 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -3,6 +3,38 @@ import { EVENTS } from '../../../src/config/events.js' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' import { useSpatialList } from './hooks/useSpatialList.js' +import { EXCLUSIVE_CONTROL_KEEP } from './defaults.js' + +/** + * Every button (not menu item), panel and control id currently registered, de-duplicated (one id + * can name a button and its panel). Passed to a keep function so hosts can discover ids. + * + * @param {{ buttonConfig?: Object, panelConfig?: Object, controlConfig?: Object }} appState + * @returns {string[]} + */ +export const getInterfaceItemIds = ({ buttonConfig = {}, panelConfig = {}, controlConfig = {} }) => [...new Set([ + ...Object.keys(buttonConfig).filter(id => !buttonConfig[id].isMenuItem), + ...Object.keys(panelConfig), + ...Object.keys(controlConfig) +])] + +/** + * Resolves the ids to keep visible while draw has exclusive control, from the exclusiveControl option. + * Draw's own buttons don't need listing: core never hides a claiming plugin's own items. + * + * @param {boolean | { keep?: string[] | ((defaults: string[], context: { ids: string[] }) => string[]) }} [option] + * false opts out; keep as an array replaces the default list, as a function receives a copy of the + * defaults and every current item id and returns the list to keep. + * @param {string[]} [ids=[]] - Every current button, panel and control id, for a keep function. + * @returns {string[] | null} The ids to keep, or null when opted out. + */ +export const getExclusiveControlKeep = (option, ids = []) => { + if (option === false) { + return null + } + const keep = option?.keep ?? EXCLUSIVE_CONTROL_KEEP + return typeof keep === 'function' ? keep([...EXCLUSIVE_CONTROL_KEEP], { ids }) : keep +} // Loads the draw adapter once the map is ready and this plugin instance is in scope for the // current app mode; tears it down (and releases MapControls' D-pad) on cleanup. @@ -55,14 +87,22 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt useLoadDrawAdapter({ mapState, appState, pluginConfig, pluginState, mapProvider, eventBus }) - // Takes exclusive control of the interface in any draw/edit mode, so draw.scss can clear the top - // row for draw's own buttons. Hosts opt out with exclusiveControl: false (e.g. a single-task map - // whose other buttons are all deliberate). useLayoutEffect so the class lands in the same paint. + // Takes exclusive control of the interface in any draw/edit mode: core hides every button, panel + // and control not on the keep list (other than draw's own) until the mode ends, keeping them + // mounted so their state survives. Hosts change the list with exclusiveControl: { keep }, + // or opt out with exclusiveControl: false (e.g. a single-task map whose other buttons are all + // deliberate). useLayoutEffect so the class lands in the same paint as the mode change. useLayoutEffect(() => { if (pluginConfig.exclusiveControl === false) { return undefined } - setExclusiveControl(!!pluginState.mode) + if (!pluginState.mode) { + setExclusiveControl(false) + return undefined + } + // Resolved on entering each mode (so a keep function runs once per mode, with current ids) + const keep = getExclusiveControlKeep(pluginConfig.exclusiveControl, getInterfaceItemIds(appState)) + setExclusiveControl(true, { keep }) return () => setExclusiveControl(false) }, [pluginState.mode]) diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index b3dabe48e..029e29073 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -1,6 +1,6 @@ import { render, act } from '@testing-library/react' import { EVENTS } from '../../../src/config/events.js' -import { DrawInit } from './DrawInit.jsx' +import { DrawInit, getExclusiveControlKeep, getInterfaceItemIds } from './DrawInit.jsx' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' import { useSpatialList } from './hooks/useSpatialList.js' @@ -269,17 +269,51 @@ describe('event attachment', () => { }) describe('exclusive control', () => { - test('claims exclusive control while in a draw/edit mode', async () => { + test('claims exclusive control while in a draw/edit mode, keeping map styles, map controls and scale bar', async () => { const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } }) await renderInit(props) - expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true) + expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true, { keep: ['mapStyles', 'mapControls', 'scaleBar'] }) + }) + + test('claims with the host\'s keep list in place of the default', async () => { + const { props } = makeProps({ + pluginConfig: { snapLayers: ['a'], exclusiveControl: { keep: ['search', 'myButton'] } }, + pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } + }) + await renderInit(props) + expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true, { keep: ['search', 'myButton'] }) + }) + + test('calls a keep function on entering a mode with the defaults and every current item id', async () => { + const keep = jest.fn((defaults) => defaults.filter(id => id !== 'scaleBar')) + const { props } = makeProps({ + pluginConfig: { snapLayers: ['a'], exclusiveControl: { keep } }, + pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } + }) + props.appState.buttonConfig = { search: {}, drawUndo: {} } + props.appState.panelConfig = { mapStyles: {} } + await renderInit(props) + expect(keep).toHaveBeenCalledWith(['mapStyles', 'mapControls', 'scaleBar'], { ids: ['search', 'drawUndo', 'mapStyles'] }) + expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true, { keep: ['mapStyles', 'mapControls'] }) + }) + + test('does not call a keep function when the mode clears', async () => { + const keep = jest.fn(() => []) + const { props } = makeProps({ + pluginConfig: { snapLayers: ['a'], exclusiveControl: { keep } }, + pluginState: { dispatch: jest.fn(), mode: 'draw_line' } + }) + const { rerender } = await renderInit(props) + keep.mockClear() + rerender() + expect(keep).not.toHaveBeenCalled() }) test('releases exclusive control when the mode clears', async () => { const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } }) const { rerender } = await renderInit(props) rerender() - expect(props.setExclusiveControl).toHaveBeenLastCalledWith(false) + expect(props.setExclusiveControl.mock.lastCall[0]).toBe(false) }) test('releases exclusive control on unmount', async () => { @@ -299,3 +333,44 @@ describe('exclusive control', () => { expect(props.setExclusiveControl).not.toHaveBeenCalled() }) }) + +describe('getExclusiveControlKeep', () => { + test('uses the default keep list when no option is given', () => { + expect(getExclusiveControlKeep(undefined)).toEqual(['mapStyles', 'mapControls', 'scaleBar']) + expect(getExclusiveControlKeep(true)).toEqual(['mapStyles', 'mapControls', 'scaleBar']) + }) + + test('replaces the default with the host\'s keep list', () => { + expect(getExclusiveControlKeep({ keep: ['mapControls'] })).toEqual(['mapControls']) + expect(getExclusiveControlKeep({ keep: [] })).toEqual([]) + }) + + test('passes a keep function a copy of the defaults and the item ids, using what it returns', () => { + const keep = jest.fn((defaults, { ids }) => [...defaults, ids[0]]) + expect(getExclusiveControlKeep({ keep }, ['search'])).toEqual(['mapStyles', 'mapControls', 'scaleBar', 'search']) + expect(keep).toHaveBeenCalledWith(['mapStyles', 'mapControls', 'scaleBar'], { ids: ['search'] }) + }) + + test('a keep function mutating its defaults argument doesn\'t change the shared default', () => { + getExclusiveControlKeep({ keep: (defaults) => { defaults.length = 0; return defaults } }) + expect(getExclusiveControlKeep(undefined)).toEqual(['mapStyles', 'mapControls', 'scaleBar']) + }) + + test('returns null when the host opts out', () => { + expect(getExclusiveControlKeep(false)).toBeNull() + }) +}) + +describe('getInterfaceItemIds', () => { + test('lists every button (not menu item), panel and control id once', () => { + expect(getInterfaceItemIds({ + buttonConfig: { mapStyles: {}, drawUndo: {}, drawSnapItem: { isMenuItem: true } }, + panelConfig: { mapStyles: {}, mapKey: {} }, + controlConfig: { scaleBar: {} } + })).toEqual(['mapStyles', 'drawUndo', 'mapKey', 'scaleBar']) + }) + + test('copes with missing config', () => { + expect(getInterfaceItemIds({})).toEqual([]) + }) +}) diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js index 47c484d52..e49151e45 100644 --- a/plugins/draw/src/defaults.js +++ b/plugins/draw/src/defaults.js @@ -50,3 +50,9 @@ export const MAP_SIZE_SCALES = { medium: 1.5, large: 2 } + +// Button, panel and control ids that stay visible while draw has exclusive control; everything else +// in the interface is hidden. Hosts replace this list with exclusiveControl: { keep: [...] }, or +// adjust it with a keep function that receives it. Draw's own buttons are always kept (core never +// hides a claiming plugin's own items). +export const EXCLUSIVE_CONTROL_KEEP = ['mapStyles', 'mapControls', 'scaleBar'] diff --git a/plugins/draw/src/draw.scss b/plugins/draw/src/draw.scss index b6bdd19ce..007d6ddb7 100644 --- a/plugins/draw/src/draw.scss +++ b/plugins/draw/src/draw.scss @@ -32,17 +32,3 @@ border-color: var(--pressed-button-border-color); background-color: var(--pressed-button-background-color); } - -// While draw has exclusive control, clear the top row (buttons, button groups and controls) for -// draw's own buttons, keeping map styles and map controls in whichever top-row slot they're in. -// Wrapped in :where() for zero specificity, so a host can bring an item back with a simple rule, -// e.g. `.im-c-button-wrapper--datasets-layers { display: block; }`. -:where(.im-o-app--exclusive-control-draw .im-o-app__top-col > :not( - .im-c-button-wrapper--map-styles, - .im-c-button-wrapper--map-controls, - .im-c-button-wrapper--draw-undo, - .im-c-button-wrapper--draw-snap, - .im-c-button-wrapper--draw-delete-point -)) { - display: none; -} diff --git a/src/App/components/Panel/Panel.jsx b/src/App/components/Panel/Panel.jsx index fa2a74d6d..f1665b796 100755 --- a/src/App/components/Panel/Panel.jsx +++ b/src/App/components/Panel/Panel.jsx @@ -27,7 +27,7 @@ const buildPanelBodyClassNames = (showLabel, isDismissible) => [ !showLabel && isDismissible && 'im-c-panel__body--offset' ].filter(Boolean).join(' ') -const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot, isOpen }) => ({ +const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot, isOpen, isHidden }) => ({ id: elementId, 'aria-labelledby': `${elementId}-label`, tabIndex: shouldFocus ? -1 : undefined, // nosonar @@ -36,8 +36,9 @@ const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isMo style: width ? { width } : undefined, className: panelClass, 'data-slot': slot, - // Panel is mounted permanently (see mapPanels.js); hidden is what actually opens/closes it. - hidden: !isOpen + // Panel is mounted permanently (see mapPanels.js); hidden is what actually opens/closes it. An + // open panel can also be hidden by another plugin's exclusive control, staying open underneath. + hidden: !isOpen || isHidden }) const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }) => ({ @@ -97,7 +98,7 @@ const PanelBody = ({ innerHtmlProp, tabs, items, WrappedChild, props, children, // eslint-disable-next-line camelcase, react/jsx-pascal-case // sonarjs/disable-next-line function-name -export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, items, tabs, label, html, children, isOpen = true, rootRef }) => { +export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, items, tabs, label, html, children, isOpen = true, isHidden = false, rootRef }) => { const { id } = useConfig() const { dispatch, breakpoint, layoutRefs, interfaceType } = useApp() @@ -148,7 +149,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissible) const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html]) - const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot, isOpen }) + const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot, isOpen, isHidden }) const bodyProps = buildBodyProps({ bodyRef, panelBodyClass, isBodyScrollable, elementId }) // DOM anchor for controls DOM-projected via the JS/consumer-HTML API — see HtmlElementHost.jsx. // Only present on the items-capable body below: dangerouslySetInnerHTML owns the static-html diff --git a/src/App/components/Panel/Panel.test.jsx b/src/App/components/Panel/Panel.test.jsx index d416bac34..f10e2c5b3 100755 --- a/src/App/components/Panel/Panel.test.jsx +++ b/src/App/components/Panel/Panel.test.jsx @@ -353,6 +353,11 @@ describe('Panel', () => { expect(panel).toHaveAttribute('hidden') }) + it('renders hidden while open when isHidden (e.g. by exclusive control), staying mounted', () => { + renderPanel({}, { isOpen: true, isHidden: true }) + expect(document.getElementById('app-panel-settings')).toHaveAttribute('hidden') + }) + it('does not focus a closed panel, even one that would otherwise auto-focus (modal)', () => { renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } }, { isOpen: false }) const panel = document.getElementById('app-panel-settings') diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx index 2590a63e1..e24612d94 100755 --- a/src/App/layout/Layout.jsx +++ b/src/App/layout/Layout.jsx @@ -17,7 +17,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 top = exclusiveControl.at(-1) + const top = exclusiveControl[exclusiveControl.length - 1] // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support if (!top) { return null } diff --git a/src/App/renderer/HtmlElementHost.jsx b/src/App/renderer/HtmlElementHost.jsx index 55b7fd186..27e62e076 100644 --- a/src/App/renderer/HtmlElementHost.jsx +++ b/src/App/renderer/HtmlElementHost.jsx @@ -2,7 +2,7 @@ import React, { useRef, useLayoutEffect, useMemo } from 'react' import { useApp } from '../store/appContext.js' import { Panel } from '../components/Panel/Panel.jsx' -import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js' +import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, isHiddenByExclusiveControl } from './slotHelpers.js' /** * Maps slot names to their corresponding layout refs. @@ -91,11 +91,11 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, // Same eligibility/modal-exclusivity rules as mapPanels.js (see slotHelpers.js), combined into // one boolean since isVisible alone decides whether useDomProjection shows it. - const isVisible = Boolean( - isOpen && bpConfig && targetSlot && - isPanelSlotEligible(config, { targetSlot, mode, isFullscreen }) && - (!bpConfig.modal || panelId === allowedModalPanelId) - ) + const isEligible = Boolean(bpConfig && targetSlot && isPanelSlotEligible(config, { targetSlot, mode, isFullscreen })) + const isAllowedModal = !bpConfig?.modal || panelId === allowedModalPanelId + // Modal panels are never hidden by exclusive control, so focus is never trapped in a hidden one + const isExclusiveHidden = !bpConfig?.modal && isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [panelId], pluginId: config.pluginId }) + const isVisible = isOpen && isEligible && isAllowedModal && !isExclusiveHidden useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint) @@ -122,7 +122,9 @@ const PersistentControl = ({ control, appState }) => { const { breakpoint, mode, isFullscreen, layoutRefs, openPanels } = appState const bpConfig = control[breakpoint] - const isVisible = isControlVisible(control, { breakpoint, mode, isFullscreen }) + // Exclusive control hides it by the same display toggle, so it stays mounted either way + const isVisible = isControlVisible(control, { breakpoint, mode, isFullscreen }) && + !isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [control.id], pluginId: control.pluginId }) const targetSlot = bpConfig?.slot || null // A control targeting a panel's body (`-panel`) needs its DOM anchor re-resolved diff --git a/src/App/renderer/HtmlElementHost.test.jsx b/src/App/renderer/HtmlElementHost.test.jsx index 815324650..e7315a71b 100644 --- a/src/App/renderer/HtmlElementHost.test.jsx +++ b/src/App/renderer/HtmlElementHost.test.jsx @@ -185,6 +185,31 @@ describe('HtmlElementHost', () => { expect(control.innerHTML).toBe('') }) + it('hides (keeps mounted) a consumer control that exclusive control doesn\'t keep', () => { + const controlConfig = { c1: { id: 'c1', html: '

Hi

', desktop: { slot: 'left-top' } } } + const hidden = renderWithSlots({ controlConfig, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['c2'] }] }) + expect(hidden.container.querySelector('.im-c-control').style.display).toBe('none') + const kept = renderWithSlots({ controlConfig, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['c1'] }] }) + expect(kept.container.querySelector('[data-slot="left-top"] .im-c-control').style.display).toBe('') + }) + + it('hides an open non-modal consumer panel that exclusive control doesn\'t keep, but never a modal one', () => { + const exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + const nonModal = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'left-top' } } }, + openPanels: { p1: { props: {} } }, + exclusiveControl + }) + expect(nonModal.getByTestId('panel-p1').style.display).toBe('none') + nonModal.unmount() + const modal = renderWithSlots({ + panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'side', modal: true } } }, + openPanels: { p1: { props: {} } }, + exclusiveControl + }) + expect(modal.getByTestId('panel-p1').style.display).toBe('') + }) + it('hides panel when slot is not allowed', () => { const { getByTestId } = renderWithSlots({ panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'invalid' } } }, diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index 80351c290..a96cb57b0 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -5,6 +5,7 @@ import { groupByKey } from './groupByKey.js' import { orderItems } from './orderItems.js' import { classifyPanel, getPanelRole } from '../../utils/getPanelRole.js' import { logger } from '../../services/logger.js' +import { isHiddenByExclusiveControl } from './slotHelpers.js' function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { const { breakpoint, mode } = appState @@ -95,6 +96,12 @@ function applySlotExclusivity (matching, appState) { return matching.filter(([_, config]) => config.pluginId === exclusivePluginId) } +// Hidden via toggleButtonState/hiddenWhen, or by another plugin's exclusive control. Either way the +// button stays mounted (display: none), so its refs, focus-return target and state survive. +const isButtonHidden = (buttonId, config, appState) => + appState.hiddenButtons.has(buttonId) || + isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [buttonId], pluginId: config.pluginId }) + /** * Builds the props for a . isHidden/variant are included here (not just derived * inside SlotButton) because Actions.jsx also reads them directly off the 'actions' slot's @@ -107,7 +114,7 @@ const slotButtonProps = ({ buttonId, config, appState, appConfig, evaluateProp } appState, appConfig, evaluateProp, - isHidden: appState.hiddenButtons.has(buttonId), + isHidden: isButtonHidden(buttonId, config, appState), variant: config.variant }) @@ -132,7 +139,7 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) { href={evaluateProp(config.href, config.pluginId)} showLabel={bpConfig.showLabel ?? true} isDisabled={appState.disabledButtons.has(buttonId)} - isHidden={appState.hiddenButtons.has(buttonId)} + isHidden={isButtonHidden(buttonId, config, appState)} isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined} isExpanded={(config.isExpanded !== undefined || config.expandedWhen) ? appState.expandedButtons.has(buttonId) : undefined} isPanelOpen={isPanelOpen} @@ -202,7 +209,14 @@ function buildGroupItem (key, members, ctx) { type: 'group', order, element: ( -
{/* NOSONAR - div with role="group" is correct for a button group */} + ) diff --git a/src/App/renderer/mapButtons.test.js b/src/App/renderer/mapButtons.test.js index b132c7e0d..da8ddb043 100755 --- a/src/App/renderer/mapButtons.test.js +++ b/src/App/renderer/mapButtons.test.js @@ -158,6 +158,14 @@ describe('mapButtons module', () => { }) }) + it('hides (keeps mounted) a button another plugin\'s exclusive control doesn\'t keep', () => { + const claim = (keep) => [{ pluginId: 'draw', name: null, keep }] + expect(render(baseBtn, { ...appState, exclusiveControl: claim(['other']) }).props.isHidden).toBe(true) + expect(render(baseBtn, { ...appState, exclusiveControl: claim(['id']) }).props.isHidden).toBe(false) + expect(render({ ...baseBtn, pluginId: 'draw' }, { ...appState, exclusiveControl: claim([]) }).props.isHidden).toBe(false) + expect(render(baseBtn, { ...appState, exclusiveControl: [{ pluginId: 'search', name: null, keep: null }] }).props.isHidden).toBe(false) + }) + it('renders correct state flags for disabled, hidden, pressed and expanded buttons', () => { const state = { ...appState, @@ -271,6 +279,18 @@ describe('mapButtons module', () => { expect(result[0].element.props['aria-label']).toBe('Group 1') }) + it('hides a group only once every member is hidden, e.g. by exclusive control', () => { + appState.buttonConfig = ({ + b1: { ...baseBtn, group: { label: 'Group 1' } }, + b2: { ...baseBtn, group: { label: 'Group 1' } } + }) + expect(map()[0].element.props.style).toBeUndefined() + appState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['b2'] }] + expect(map()[0].element.props.style).toBeUndefined() + appState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + expect(map()[0].element.props.style).toEqual({ display: 'none' }) + }) + it('merges group labels that differ only by case/whitespace into one group', () => { appState.buttonConfig = ({ b1: { ...baseBtn, group: { label: 'Zoom Controls' } }, diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js index cfe45483c..02a3cfa5f 100755 --- a/src/App/renderer/mapControls.js +++ b/src/App/renderer/mapControls.js @@ -2,7 +2,7 @@ import React from 'react' import { withPluginContexts } from './pluginWrapper.js' import { allowedSlots } from './slots.js' -import { isConsumerHtml } from './slotHelpers.js' +import { isConsumerHtml, isHiddenByExclusiveControl } from './slotHelpers.js' /** * Map controls for a given slot and app state. @@ -49,6 +49,7 @@ export function mapControls ({ slot, appState, evaluateProp }) { ) const pluginId = plugin?.id + const isHidden = isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [control.id], pluginId }) let element @@ -58,6 +59,7 @@ export function mapControls ({ slot, appState, evaluateProp }) {
) @@ -67,7 +69,13 @@ export function mapControls ({ slot, appState, evaluateProp }) { pluginId, pluginConfig: plugin?.config }) - element = + // Always wrapped, so hiding it (exclusive control) never remounts it: display: contents + // leaves layout untouched while shown, display: none hides it with its state intact. + element = ( +
+ +
+ ) } return { diff --git a/src/App/renderer/mapControls.test.js b/src/App/renderer/mapControls.test.js index 83cf4d26c..f2343ba1f 100755 --- a/src/App/renderer/mapControls.test.js +++ b/src/App/renderer/mapControls.test.js @@ -103,7 +103,9 @@ describe('mapControls', () => { const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].order).toBe(5) expect(result[0].id).toBe('ctrl1') - expect(typeof result[0].element.type).toBe('function') + // Rendered inside the core wrapper exclusive control hides it by + expect(result[0].element.type).toBe('div') + expect(typeof result[0].element.props.children.type).toBe('function') }) it('falls back to order 0 if order is missing', () => { @@ -122,6 +124,31 @@ describe('mapControls', () => { expect(result[0].element.props.dangerouslySetInnerHTML).toEqual({ __html: '

Hi

' }) }) + it('hides an HTML control (keeping it mounted) when exclusive control doesn\'t keep it', () => { + defaultAppState.controlConfig = ({ + ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + }) + const style = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element.props.style + expect(style()).toBeUndefined() + defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + expect(style()).toEqual({ display: 'none' }) + defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['ctrlHtml'] }] + expect(style()).toBeUndefined() + }) + + it('always wraps a plugin control, switching the wrapper to display: none when exclusive control hides it', () => { + defaultAppState.controlConfig = ({ + ctrl1: { id: 'ctrl1', desktop: { slot: 'header' }, render: () => null, includeModes: ['view'] } + }) + const wrapper = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element + expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper', style: { display: 'contents' } }) + defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] + expect(wrapper().props.style).toEqual({ display: 'none' }) + // The claiming plugin's own controls stay + defaultAppState.exclusiveControl = [{ pluginId: 'plugin1', name: null, keep: [] }] + expect(wrapper().props.style).toEqual({ display: 'contents' }) + }) + it('filters out consumer HTML controls (handled by HtmlElementHost)', () => { defaultAppState.controlConfig = ({ ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js index f44314a68..98cd561a1 100755 --- a/src/App/renderer/mapPanels.js +++ b/src/App/renderer/mapPanels.js @@ -2,7 +2,7 @@ import React from 'react' import { withPluginContexts } from './pluginWrapper.js' import { Panel } from '../components/Panel/Panel.jsx' -import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js' +import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, isHiddenByExclusiveControl } from './slotHelpers.js' import { mapControls } from './mapControls.js' import { orderItems } from './orderItems.js' import { groupIntoTabs } from './groupIntoTabs.js' @@ -57,6 +57,9 @@ export function mapPanels ({ slot, appState, evaluateProp }) { const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId) const pluginId = plugin?.id + // Modal panels are never hidden by exclusive control, so focus is never trapped in a hidden one + const isHidden = !bpConfig.modal && isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [panelId], pluginId }) + const html = pluginId ? evaluateProp(config.html, pluginId) : config.html const label = evaluateProp(config.label, pluginId) @@ -72,6 +75,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) { props={props} focusOnOpen={focusOnOpen} isOpen={isOpen} + isHidden={isHidden} {...(isOpen ? buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) : {})} label={label} html={html} diff --git a/src/App/renderer/mapPanels.test.js b/src/App/renderer/mapPanels.test.js index 450e78747..ad1d2dfb7 100755 --- a/src/App/renderer/mapPanels.test.js +++ b/src/App/renderer/mapPanels.test.js @@ -114,6 +114,15 @@ describe('mapPanels', () => { expect(result).toEqual([]) }) + it('hides a non-modal panel exclusive control doesn\'t keep, but never a modal one', () => { + const exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['other'] }] + expect(map({ ...defaultAppState, exclusiveControl })[0].element.props.isHidden).toBe(true) + expect(map({ ...defaultAppState, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['p1'] }] })[0].element.props.isHidden).toBe(false) + expect(map(defaultAppState)[0].element.props.isHidden).toBe(false) + defaultAppState.panelConfig = ({ p1: { desktop: { modal: true }, includeModes: ['view'] } }) + expect(map({ ...defaultAppState, exclusiveControl }, 'modal')[0].element.props.isHidden).toBe(false) + }) + it('renders both modal panels\' shells but only marks the last-opened one as open', () => { defaultAppState.panelConfig = ({ p1: { desktop: { modal: true }, includeModes: ['view'] }, diff --git a/src/App/renderer/pluginWrapper.js b/src/App/renderer/pluginWrapper.js index b3fb94b53..12211fc1c 100755 --- a/src/App/renderer/pluginWrapper.js +++ b/src/App/renderer/pluginWrapper.js @@ -49,10 +49,11 @@ export function withPluginContexts (Component, { pluginId, pluginConfig }) { 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({ + // with a name (--{name} class suffix), anything falsy releases it. options.keep, if given, has + // core hide every button, panel and control not listed (or not this plugin's own) meanwhile. + const setExclusiveControl = useCallback((value, { keep = null } = {}) => appState.dispatch({ type: 'SET_EXCLUSIVE_CONTROL', - payload: { pluginId, name: typeof value === 'string' ? value : null, active: !!value } + payload: { pluginId, name: typeof value === 'string' ? value : null, keep, active: !!value } }), [appState.dispatch]) return ( diff --git a/src/App/renderer/pluginWrapper.test.js b/src/App/renderer/pluginWrapper.test.js index c8c8cb969..e77274d8f 100755 --- a/src/App/renderer/pluginWrapper.test.js +++ b/src/App/renderer/pluginWrapper.test.js @@ -52,7 +52,19 @@ describe('withPluginContexts', () => { render() Inner.mock.calls[0][0].setExclusiveControl(value) - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_EXCLUSIVE_CONTROL', payload: { pluginId: 'plugin1', ...expected } }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_EXCLUSIVE_CONTROL', payload: { pluginId: 'plugin1', keep: null, ...expected } }) + }) + + it('passes setExclusiveControl\'s keep option through with the claim', () => { + 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(true, { keep: ['mapStyles'] }) + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_EXCLUSIVE_CONTROL', payload: { pluginId: 'plugin1', name: null, keep: ['mapStyles'], active: true } }) }) it('returns the cached wrapper if called again with the same component', () => { diff --git a/src/App/renderer/slotHelpers.js b/src/App/renderer/slotHelpers.js index f7fd15d91..0114cf6d8 100644 --- a/src/App/renderer/slotHelpers.js +++ b/src/App/renderer/slotHelpers.js @@ -16,6 +16,21 @@ export const resolveTargetSlot = (bpConfig, breakpoint) => { return bpConfig.slot } +/** + * Whether exclusive control hides an item: true when any claim in the stack that has a keep list + * neither lists one of the item's ids nor belongs to the item's own plugin (a claiming plugin's own + * items always stay). Claims without a keep list (e.g. search's) hide nothing here — that plugin's + * own CSS does. Items are hidden with CSS, never unmounted, so their state survives. + * + * @param {Array<{ pluginId: string, keep: string[] | null }>} [exclusiveControl] - The claim stack. + * @param {{ ids: string[], pluginId?: string }} item - The item's id(s) and owning plugin, if any. + * @returns {boolean} + */ +export const isHiddenByExclusiveControl = (exclusiveControl, { ids, pluginId }) => + (exclusiveControl ?? []).some(claim => + claim.keep && claim.pluginId !== pluginId && !ids.some(id => claim.keep.includes(id)) + ) + /** * Checks whether the current application mode permits an item to be shown, * based on its includeModes and excludModes configuration. diff --git a/src/App/renderer/slotHelpers.test.js b/src/App/renderer/slotHelpers.test.js index 9a87291ad..39c5329bc 100644 --- a/src/App/renderer/slotHelpers.test.js +++ b/src/App/renderer/slotHelpers.test.js @@ -1,4 +1,4 @@ -import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel } from './slotHelpers.js' +import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel, isHiddenByExclusiveControl } from './slotHelpers.js' jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'], panel: ['header', 'modal', 'left-top'] } })) @@ -162,3 +162,36 @@ describe('hasOpenModalPanel', () => { expect(hasOpenModalPanel({ p1: { props: {} } }, panelConfig, 'desktop')).toBe(true) }) }) + +describe('isHiddenByExclusiveControl', () => { + const draw = { pluginId: 'draw', name: null, keep: ['mapStyles', 'mapControls'] } + const search = { pluginId: 'search', name: null, keep: null } + + it('hides an item not on a keep-list claim\'s keep list', () => { + expect(isHiddenByExclusiveControl([draw], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(true) + }) + + it('keeps an item whose id (any of its ids) is on the keep list', () => { + expect(isHiddenByExclusiveControl([draw], { ids: ['mapStyles'], pluginId: 'mapStyles' })).toBe(false) + expect(isHiddenByExclusiveControl([draw], { ids: ['zoomIn', 'mapControls'] })).toBe(false) + }) + + it('never hides the claiming plugin\'s own items', () => { + expect(isHiddenByExclusiveControl([draw], { ids: ['drawUndo'], pluginId: 'draw' })).toBe(false) + }) + + it('ignores claims without a keep list', () => { + expect(isHiddenByExclusiveControl([search], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(false) + }) + + it('hides an item if any keep-list claim in the stack excludes it, whatever is on top', () => { + const searchKeepList = { pluginId: 'search', name: null, keep: ['mapStyles', 'datasetsLayers'] } + expect(isHiddenByExclusiveControl([draw, searchKeepList], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(true) + expect(isHiddenByExclusiveControl([draw, search], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(true) + }) + + it('hides nothing with no claims or no exclusiveControl state', () => { + expect(isHiddenByExclusiveControl([], { ids: ['x'] })).toBe(false) + expect(isHiddenByExclusiveControl(undefined, { ids: ['x'] })).toBe(false) + }) +}) diff --git a/src/App/store/appActionsMap.js b/src/App/store/appActionsMap.js index a0147bd54..ea0917e70 100755 --- a/src/App/store/appActionsMap.js +++ b/src/App/store/appActionsMap.js @@ -66,7 +66,7 @@ const setHybridFullscreen = (state, payload) => { const setBreakpoint = (state, payload) => { const { behaviour, hybridWidth, maxMobileWidth } = payload const panelIds = Object.keys(state.openPanels) - const lastPanelId = panelIds[panelIds.length - 1] + const lastPanelId = panelIds[panelIds.length - 1] // NOSONAR .length - 1 used instead of .at(-1) for wider browser support // For hybrid, isFullscreen is controlled by media query via SET_HYBRID_FULLSCREEN // For other behaviours, calculate it here @@ -145,21 +145,25 @@ const restorePreviousPanels = (state) => { } } +const isSameKeep = (a, b) => a === b || (!!a && !!b && a.length === b.length && a.every((id, i) => id === b[i])) + // 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 }) => { +// im-o-app--exclusive-control-{pluginId}[--{name}] class for the top claim. A claim with a keep list +// also has the slot renderers hide every item not on it (see isHiddenByExclusiveControl); a claim +// with keep: null hides nothing itself. 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, keep = null, active }) => { const stack = state.exclusiveControl - const top = stack.at(-1) - if (active && top?.pluginId === pluginId && top.name === name) { + const top = stack[stack.length - 1] // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support + if (active && top?.pluginId === pluginId && top.name === name && isSameKeep(top.keep, keep)) { 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 } + return { ...state, exclusiveControl: active ? [...others, { pluginId, name, keep }] : others } } const toggleNudgeStep = (state) => { diff --git a/src/App/store/appActionsMap.test.js b/src/App/store/appActionsMap.test.js index 4010c6405..ddf10abef 100755 --- a/src/App/store/appActionsMap.test.js +++ b/src/App/store/appActionsMap.test.js @@ -136,48 +136,65 @@ describe('actionsMap full coverage', () => { 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 }]) + expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null, keep: null }]) }) test('SET_EXCLUSIVE_CONTROL stacks a new plugin\'s claim on top', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }] } + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point', keep: null }] } const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: true }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }]) + expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point', keep: null }, { pluginId: 'search', name: null, keep: 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 localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point', keep: null }, { pluginId: 'search', name: null, keep: null }] } const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point' }]) + expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point', keep: null }]) }) 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 localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point', keep: null }, { pluginId: 'search', name: null, keep: null }] } const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', active: false }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null }]) + expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null, keep: 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 }]) + expect(searchReleased.exclusiveControl).toEqual([{ pluginId: 'draw', name: null, keep: 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 localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon', keep: null }, { pluginId: 'search', name: null, keep: 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' }]) + expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null, keep: null }, { pluginId: 'draw', name: 'edit-vertex', keep: null }]) + }) + + test('SET_EXCLUSIVE_CONTROL stores the claim\'s keep list', () => { + const result = actionsMap.SET_EXCLUSIVE_CONTROL(state, { pluginId: 'draw', keep: ['mapStyles'], active: true }) + expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: null, keep: ['mapStyles'] }]) + }) + + test('SET_EXCLUSIVE_CONTROL re-claiming with a different keep list replaces it', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', keep: ['mapStyles', 'search'], active: true }) + expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: null, keep: ['mapStyles', 'search'] }]) + }) + + test('SET_EXCLUSIVE_CONTROL re-claiming with an equal keep list returns the same state', () => { + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] } + const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', keep: ['mapStyles'], active: true }) + expect(result).toBe(localState) }) test('SET_EXCLUSIVE_CONTROL re-claiming the top claim unchanged returns the same state', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-vertex' }] } + const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-vertex', keep: null }] } 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 localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon', keep: null }] } const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false }) expect(result).toBe(localState) }) diff --git a/src/types.js b/src/types.js index 2fd87260a..1eb1ad980 100644 --- a/src/types.js +++ b/src/types.js @@ -113,6 +113,23 @@ * * @property {Object} [services] * Core services (announce, reverseGeocode, closeApp, etc.). + * + * @property {(value?: boolean | string | null, options?: ExclusiveControlOptions) => void} [setExclusiveControl] + * Plugin components only. Claims (true, or a string name) or releases (falsy) exclusive control of + * the interface for this plugin, adding im-o-app--exclusive-control-{pluginId}[--{name}] to the app + * root while its claim is the most recent. The plugin id is bound automatically. + */ + +/** + * Options for a plugin's exclusive-control claim. + * + * @typedef {Object} ExclusiveControlOptions + * + * @property {string[] | null} [keep=null] + * When given, core hides every button, panel and control, in every slot, whose id isn't listed and + * which doesn't belong to the claiming plugin, until the claim is released. Hidden items stay + * mounted (display: none), so their state survives. Modal panels are never hidden. Without keep, + * core hides nothing and the plugin's own CSS decides what to hide. */ /** From 23d2473415da59907c544ee65a7093c773fb2aeb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 28 Sep 2026 16:57:28 +0100 Subject: [PATCH 03/12] Hidden wrapper layourt fix --- plugins/search/src/search.scss | 2 +- src/App/layout/layout.module.scss | 3 ++- src/App/renderer/mapControls.js | 9 +++++++-- src/App/renderer/mapControls.test.js | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/plugins/search/src/search.scss b/plugins/search/src/search.scss index fbfd26124..59df47c08 100755 --- a/plugins/search/src/search.scss +++ b/plugins/search/src/search.scss @@ -26,7 +26,7 @@ } // Prevents a phantom trailing gap when the only header content is a collapsed (but still present) search form. -.im-o-app__header:has(> .im-c-search--collapsed:only-child) { +.im-o-app__header:has(> .im-c-control-wrapper:only-child > .im-c-search--collapsed) { margin-bottom: 0; } diff --git a/src/App/layout/layout.module.scss b/src/App/layout/layout.module.scss index 5b0c81aab..00bc4f2e7 100755 --- a/src/App/layout/layout.module.scss +++ b/src/App/layout/layout.module.scss @@ -110,7 +110,8 @@ width: 100%; } -.im-o-app__header:not(:empty) { +// Only when something in it is shown — a plugin control hidden by exclusive control doesn't count. +.im-o-app__header:has(> :not(.im-c-control-wrapper--hidden)) { margin-bottom: var(--divider-gap); } diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js index 02a3cfa5f..513050001 100755 --- a/src/App/renderer/mapControls.js +++ b/src/App/renderer/mapControls.js @@ -70,9 +70,14 @@ export function mapControls ({ slot, appState, evaluateProp }) { pluginConfig: plugin?.config }) // Always wrapped, so hiding it (exclusive control) never remounts it: display: contents - // leaves layout untouched while shown, display: none hides it with its state intact. + // leaves layout untouched while shown, display: none hides it with its state intact. The + // --hidden modifier lets slot CSS ignore it (e.g. the header's trailing gap). element = ( -
+
) diff --git a/src/App/renderer/mapControls.test.js b/src/App/renderer/mapControls.test.js index f2343ba1f..188853feb 100755 --- a/src/App/renderer/mapControls.test.js +++ b/src/App/renderer/mapControls.test.js @@ -143,7 +143,7 @@ describe('mapControls', () => { const wrapper = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper', style: { display: 'contents' } }) defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] - expect(wrapper().props.style).toEqual({ display: 'none' }) + expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper im-c-control-wrapper--hidden', style: { display: 'none' } }) // The claiming plugin's own controls stay defaultAppState.exclusiveControl = [{ pluginId: 'plugin1', name: null, keep: [] }] expect(wrapper().props.style).toEqual({ display: 'contents' }) From 8ecd8e9f3368aab5cec68eda4dd8290fc361a69c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 28 Sep 2026 17:08:48 +0100 Subject: [PATCH 04/12] Sonar cloud const fix --- plugins/draw/src/manifest.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index b46e10e23..3fc994a51 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -22,6 +22,9 @@ const undoCommand = isMac() ? 'Command + Z' : 'CtrlOption' : 'Alt' +// Undo, Snap and Delete point sit together in the middle of the top row +const TOOLBAR_SLOT = 'top-middle' + const createButtonSlots = (showLabel, slot = 'actions') => ({ mobile: { slot, showLabel }, tablet: { slot, showLabel }, @@ -72,8 +75,8 @@ export const manifest = { ...createButtonSlots(true) }, { - // Undo, Snap and Delete point sit together in the middle of the top row (in this order), which - // draw.scss clears of other buttons while draw has exclusive control. + // Undo, Snap and Delete point render in this order in TOOLBAR_SLOT. While draw has exclusive + // control, core hides everything around them that isn't on the keep list. id: 'drawUndo', label: 'Undo', iconId: 'undo', @@ -85,7 +88,7 @@ export const manifest = { } return pluginState.undoStackLength > 0 }, - ...createButtonSlots(false, 'top-middle') + ...createButtonSlots(false, TOOLBAR_SLOT) }, { id: 'drawSnap', @@ -93,7 +96,7 @@ export const manifest = { iconId: 'magnet', hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, pressedWhen: ({ pluginState }) => !!pluginState.snap, - ...createButtonSlots(true, 'top-middle') + ...createButtonSlots(true, TOOLBAR_SLOT) }, { id: 'drawDeletePoint', @@ -107,7 +110,7 @@ export const manifest = { // Deliberately excluded from edit_point — deleting a point's one coordinate is deleting // the whole feature (deleteFeature's job), not a per-vertex action. hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex', - ...createButtonSlots(false, 'top-middle') + ...createButtonSlots(false, TOOLBAR_SLOT) } ], From 61187d4eb1908da01ea8c101617e5f4edc350b01 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 28 Sep 2026 17:59:52 +0100 Subject: [PATCH 05/12] Give the control wrapper an id modifier class, like button wrappers Plugin control wrappers now carry im-c-control-wrapper--{id} (kebab-cased), matching im-c-button-wrapper--{id}, so it's clear in the DOM which control a wrapper belongs to. Documents the wrapper under ControlDefinition.render. --- docs/api/control-definition.md | 2 ++ src/App/renderer/mapControls.js | 15 +++++++++++---- src/App/renderer/mapControls.test.js | 12 ++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/api/control-definition.md b/docs/api/control-definition.md index 203433d00..655f7faf1 100644 --- a/docs/api/control-definition.md +++ b/docs/api/control-definition.md @@ -35,6 +35,8 @@ const MyControl = ({ context }) => { } ``` +Core renders the component inside a wrapper element, `
` (the id kebab-cased), as it does for buttons (`im-c-button-wrapper--{id}`). The wrapper is `display: contents`, so it doesn't affect layout, but your control isn't a direct child of its slot, so write CSS selectors with that in mind. + --- ### `mobile` diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js index 513050001..431bd2459 100755 --- a/src/App/renderer/mapControls.js +++ b/src/App/renderer/mapControls.js @@ -3,6 +3,7 @@ import React from 'react' import { withPluginContexts } from './pluginWrapper.js' import { allowedSlots } from './slots.js' import { isConsumerHtml, isHiddenByExclusiveControl } from './slotHelpers.js' +import { stringToKebab } from '../../utils/stringToKebab.js' /** * Map controls for a given slot and app state. @@ -69,13 +70,19 @@ export function mapControls ({ slot, appState, evaluateProp }) { pluginId, pluginConfig: plugin?.config }) - // Always wrapped, so hiding it (exclusive control) never remounts it: display: contents - // leaves layout untouched while shown, display: none hides it with its state intact. The - // --hidden modifier lets slot CSS ignore it (e.g. the header's trailing gap). + // Core's own element around the plugin's control, as MapButton's wrapper is for buttons and + // Panel's root for panels — so hiding it (exclusive control) never remounts it: display: + // contents leaves layout untouched while shown, display: none hides it with its state intact. + // --{id} identifies it (as im-c-button-wrapper--{id} does); --hidden lets slot CSS ignore it. + const wrapperClassName = [ + 'im-c-control-wrapper', + `im-c-control-wrapper--${stringToKebab(control.id)}`, + isHidden && 'im-c-control-wrapper--hidden' + ].filter(Boolean).join(' ') element = (
diff --git a/src/App/renderer/mapControls.test.js b/src/App/renderer/mapControls.test.js index 188853feb..c258a0d35 100755 --- a/src/App/renderer/mapControls.test.js +++ b/src/App/renderer/mapControls.test.js @@ -136,14 +136,22 @@ describe('mapControls', () => { expect(style()).toBeUndefined() }) + it('gives a plugin control\'s wrapper a kebab-cased id modifier, like button wrappers', () => { + defaultAppState.controlConfig = ({ + scaleBar: { id: 'scaleBar', desktop: { slot: 'header' }, render: () => null, includeModes: ['view'] } + }) + const [item] = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) + expect(item.element.props.className).toBe('im-c-control-wrapper im-c-control-wrapper--scale-bar') + }) + it('always wraps a plugin control, switching the wrapper to display: none when exclusive control hides it', () => { defaultAppState.controlConfig = ({ ctrl1: { id: 'ctrl1', desktop: { slot: 'header' }, render: () => null, includeModes: ['view'] } }) const wrapper = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element - expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper', style: { display: 'contents' } }) + expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper im-c-control-wrapper--ctrl1', style: { display: 'contents' } }) defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] - expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper im-c-control-wrapper--hidden', style: { display: 'none' } }) + expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper im-c-control-wrapper--ctrl1 im-c-control-wrapper--hidden', style: { display: 'none' } }) // The claiming plugin's own controls stay defaultAppState.exclusiveControl = [{ pluginId: 'plugin1', name: null, keep: [] }] expect(wrapper().props.style).toEqual({ display: 'contents' }) From ed24406af0c981df738e231ffce77b29d4680356 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 29 Sep 2026 16:41:46 +0100 Subject: [PATCH 06/12] Replace modes and exclusive control with application modes --- demo/js/farming.js | 4 +- docs/api.md | 97 ++++++++---- docs/api/button-definition.md | 2 +- docs/plugins/datasets.md | 16 -- docs/plugins/draw.md | 85 ++++------- docs/plugins/interact.md | 14 -- docs/plugins/map-key.md | 16 -- docs/plugins/map-styles.md | 14 -- docs/plugins/plugin-context.md | 61 ++++---- docs/plugins/scale-bar.md | 14 -- docs/plugins/search.md | 18 +-- plugins/beta/draw-es/src/DrawInit.jsx | 6 +- plugins/beta/draw-ml/src/DrawInit.jsx | 8 +- plugins/beta/draw-ol/src/DrawInit.jsx | 6 +- plugins/beta/frame/src/FrameInit.jsx | 8 +- .../datasets/src/initialise/DatasetsInit.jsx | 9 +- .../src/initialise/DatasetsInit.test.jsx | 29 +--- plugins/draw/src/DrawInit.jsx | 71 ++------- plugins/draw/src/DrawInit.test.jsx | 123 ++-------------- plugins/draw/src/defaults.js | 10 +- plugins/search/src/Search.jsx | 22 ++- plugins/search/src/Search.test.jsx | 23 ++- plugins/search/src/defaults.js | 3 + plugins/search/src/events/formHandlers.js | 14 +- plugins/search/src/search.scss | 12 +- src/App/components/Viewport/Viewport.jsx | 9 +- src/App/components/Viewport/Viewport.test.jsx | 16 -- src/App/hooks/useApplicationModeFocus.js | 21 +++ src/App/hooks/useApplicationModeFocus.test.js | 53 +++++++ src/App/hooks/useInterfaceAPI.js | 139 +++++++++--------- src/App/hooks/useInterfaceAPI.test.js | 15 ++ src/App/hooks/useLayoutMeasurements.js | 19 ++- src/App/hooks/useLayoutMeasurements.test.js | 22 +++ src/App/layout/Layout.jsx | 22 +-- src/App/layout/Layout.test.jsx | 34 +++-- src/App/registry/pluginRegistry.js | 4 +- src/App/registry/pluginRegistry.test.js | 16 +- src/App/renderer/HtmlElementHost.jsx | 32 ++-- src/App/renderer/HtmlElementHost.test.jsx | 25 +--- src/App/renderer/PluginInits.jsx | 12 +- src/App/renderer/PluginInits.test.jsx | 12 +- src/App/renderer/applicationModes.js | 65 ++++++++ src/App/renderer/applicationModes.test.js | 111 ++++++++++++++ src/App/renderer/mapButtons.js | 24 ++- src/App/renderer/mapButtons.test.js | 24 ++- src/App/renderer/mapControls.js | 19 ++- src/App/renderer/mapControls.test.js | 75 +++++----- src/App/renderer/mapPanels.js | 22 +-- src/App/renderer/mapPanels.test.js | 67 +++------ src/App/renderer/pluginWrapper.js | 26 ++-- src/App/renderer/pluginWrapper.test.js | 63 +++++--- src/App/renderer/slotHelpers.js | 43 +----- src/App/renderer/slotHelpers.test.js | 79 ++-------- src/App/store/AppProvider.jsx | 14 -- src/App/store/AppProvider.test.jsx | 41 ++---- src/App/store/appActionsMap.js | 61 +++----- src/App/store/appActionsMap.test.js | 119 ++++----------- src/App/store/appReducer.js | 7 +- src/App/store/appReducer.test.js | 8 +- src/InteractiveMap/InteractiveMap.js | 34 +++-- src/InteractiveMap/InteractiveMap.test.js | 8 +- src/config/events.js | 8 +- src/test-utils.js | 1 - src/types.js | 38 +++-- 64 files changed, 968 insertions(+), 1125 deletions(-) create mode 100644 src/App/hooks/useApplicationModeFocus.js create mode 100644 src/App/hooks/useApplicationModeFocus.test.js create mode 100644 src/App/renderer/applicationModes.js create mode 100644 src/App/renderer/applicationModes.test.js diff --git a/demo/js/farming.js b/demo/js/farming.js index 06fd40310..2621ab885 100755 --- a/demo/js/farming.js +++ b/demo/js/farming.js @@ -27,8 +27,7 @@ var interactPlugin = createInteractPlugin({ // idProperty: 'gid' }], interactionModes: ['selectMarker', 'selectFeature', 'placeMarker'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations - multiSelect: true, - // excludeModes: ['draw'] + multiSelect: true }) var datasetsPlugin = createDatasetsPlugin({ @@ -131,7 +130,6 @@ var interactiveMap = new InteractiveMap('map', { }) interactiveMap.on('map:ready', function (e) { - // interactiveMap.setMode('draw') // framePlugin.addFrame('test', { // aspectRatio: 1 // }) diff --git a/docs/api.md b/docs/api.md index 5346e369e..75c284cb2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,6 +58,40 @@ Uses a dark colour scheme. | --- +### `applicationModes` +**Type:** `Object` + +Adjusts [application modes](#setapplicationmodeid-options), keyed by mode id. Plugins document the modes they set — for example the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'` while drawing or editing. Your settings apply whenever that mode is active, whoever sets it, and have the final say. + +Each mode's value is either: + +- **an object**, with either or both of: + - `include` — adds items to the mode's visible items, and brings back any a plugin excluded. + - `exclude` — hides items, whoever included them. +- **`false`** — turns the mode off entirely, so it adds no class and hides nothing. + +Ids name buttons, panels and controls, and don't need to exist yet — an item you add later with [`addControl`](#addcontrolid-config) or similar is picked up when it appears. + +```js +new InteractiveMap('map', { + applicationModes: { + draw: { include: ['search', 'shapeDimensions'], exclude: ['scaleBar'] } + } +}) +``` + +Or, when every item on your map is deliberate (e.g. a single-task map that goes straight into editing a shape), turn a mode off so it hides nothing and adds no class: + +```js +new InteractiveMap('map', { + applicationModes: { + draw: false + } +}) +``` + +--- + ### `autoColorScheme` **Type:** `boolean` **Default:** `false` @@ -430,16 +464,6 @@ Passed directly to the underlying map engine. --- -### `mode` -**Type:** `string | null` -**Default:** `null` - -Initial application mode. Modes facilitate attaching behaviour to certain states, enabling short user journey steps within the map interface. Plugins can be configured to respect modes, only rendering content when the app is in a specific mode. - -See also: [`setMode()`](#setmodemode) method. - ---- - ### `nudgePanDelta` **Type:** `number` **Default:** `5` @@ -779,13 +803,50 @@ interactiveMap.hidePanel('info-panel') --- +### `setApplicationMode(id, options?)` + +Enters an application mode — for example for a step in a journey that needs a pared-down interface. Modes form a stack: the new mode goes on top, and setting a mode that's already on the stack replaces its lists and moves it to the top. While it's the most recently set active mode, the app root gets the class `im-o-app--mode-{id}`. Hidden items stay mounted, so their state is preserved, and modal panels are never hidden. + +Plugins set modes too (e.g. the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'`). An item only shows if every active mode allows it, and your [`applicationModes`](#applicationmodes) option always has the final say over a mode's lists. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `string` | Mode id, used as-is in the class, so keep it class-safe | +| `options.include` | `string[]` | Only these buttons, panels and controls stay visible | +| `options.exclude` | `string[]` | These buttons, panels and controls are hidden | + +Without either list, nothing is hidden and only the class is added. + +```js +// Only the map styles button and panel, and your own button, stay visible +interactiveMap.setApplicationMode('review', { include: ['mapStyles', 'myButton'] }) + +// Later, bring everything back +interactiveMap.clearApplicationMode('review') +``` + +> [!NOTE] +> Don't set or clear a plugin's mode id yourself (e.g. `'draw'`): it only changes the interface, so clearing it mid-draw would show everything again while drawing carries on. To adjust or disable a plugin's mode, use [`applicationModes`](#applicationmodes) instead. + +--- + +### `clearApplicationMode(id)` + +Leaves an application mode, removing it from the stack so the mode underneath (if any) takes over. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `string` | Mode id | + +--- + ### `addControl(id, config)` Add a custom control to the UI at runtime. | Parameter | Type | Description | |-----------|------|-------------| -| `id` | `string` | Unique control identifier. Also the id to list in a plugin's exclusive-control keep list, e.g. the draw plugin's [`exclusiveControl.keep`](./plugins/draw.md#exclusivecontrol) | +| `id` | `string` | Unique control identifier. Also the id to list in an application mode's `include`/`exclude`, e.g. in [`applicationModes`](#applicationmodes) | | `config` | `ControlDefinition` | Control configuration | See [ControlDefinition](./api/control-definition.md) for configuration options. @@ -849,20 +910,6 @@ interactiveMap.on('draw:unmerged', () => { --- -### `setMode(mode)` - -Programmatically set the application mode. See the [`mode`](#mode) option for more detail. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `mode` | `string` | Mode identifier | - -```js -interactiveMap.setMode('fullscreen') -``` - ---- - ### `toggleButtonState(id, prop, value)` Set or toggle a button state. Where applicable the corresponding ARIA attribute is updated — `aria-pressed`, `aria-disabled`, or `aria-expanded`. diff --git a/docs/api/button-definition.md b/docs/api/button-definition.md index da210e97a..49225a65d 100644 --- a/docs/api/button-definition.md +++ b/docs/api/button-definition.md @@ -10,7 +10,7 @@ Defines a button that can be rendered in the UI at various breakpoints. **Type:** `string` **Required** -Unique button identifier. It's also the id to list when a plugin that takes exclusive control of the interface lets you keep items visible, e.g. the draw plugin's [`exclusiveControl.keep`](../plugins/draw.md#exclusivecontrol). +Unique button identifier. It's also the id to list in an application mode's `include` or `exclude`, e.g. in the [`applicationModes`](../api.md#applicationmodes) option. --- diff --git a/docs/plugins/datasets.md b/docs/plugins/datasets.md index 213b17a11..064d4d968 100644 --- a/docs/plugins/datasets.md +++ b/docs/plugins/datasets.md @@ -89,22 +89,6 @@ Array of dataset configurations to render on the map. See [Dataset configuration --- -### `includeModes` - -**Type:** `string[]` - -When set, the plugin only initialises when the app is in one of the specified modes. - ---- - -### `excludeModes` - -**Type:** `string[]` - -When set, the plugin does not initialise when the app is in one of the specified modes. - ---- - ## Dataset configuration Each entry in the `datasets` array describes one data source and how it should be rendered. diff --git a/docs/plugins/draw.md b/docs/plugins/draw.md index e0e47e611..a10e5ebb8 100644 --- a/docs/plugins/draw.md +++ b/docs/plugins/draw.md @@ -76,66 +76,6 @@ Plugin-level validation callback, called throughout the draw/edit lifecycle so y --- -### `exclusiveControl` - -**Type:** `boolean | { keep: string[] | ((defaults: string[], context: { ids: string[] }) => string[]) }` -**Default:** `{ keep: ['mapStyles', 'mapControls', 'scaleBar'] }` - -While drawing or editing, the plugin takes [exclusive control](./plugin-context.md#setexclusivecontrol) of the interface: every button, panel and control, in every slot, is hidden except the items on its keep list and draw's own buttons. Everything reappears as it was when the draw or edit mode ends. Hidden items stay mounted, so open panels keep their state and scroll position. Modal panels are never hidden. - -`keep` lists button, panel and control ids, and replaces the default list, so include any defaults you still want. For example, to also keep search and a button of your own: - -```js -createDrawPlugin({ - exclusiveControl: { keep: ['mapStyles', 'mapControls', 'scaleBar', 'search', 'myButton'] } -}) -``` - -Or to hide map styles, leave it out: - -```js -createDrawPlugin({ exclusiveControl: { keep: ['mapControls', 'scaleBar'] } }) -``` - -`keep` can also be a function, so you can adjust the defaults without repeating them. It receives a copy of the default list and `ids`, every button, panel and control id currently on the map, and returns the list to keep. It's called each time a draw or edit mode starts, so logging `ids` is a handy way to find the id you need: - -```js -createDrawPlugin({ - exclusiveControl: { - keep: (defaults, { ids }) => { - console.log(ids) - return [...defaults.filter(id => id !== 'scaleBar'), 'search'] - } - } -}) -``` - -An id covers every item that shares it: `mapStyles` keeps both the map styles button and its panel, and `search` keeps both the search button and its form. If the user opens search while drawing, draw's hiding still applies to everything else. - -Set to `false` when every button on the map is deliberate — for example a single-task map that goes straight into editing a shape — so nothing is hidden: - -```js -createDrawPlugin({ exclusiveControl: false }) -``` - ---- - -### `includeModes` - -**Type:** `string[]` - -When set, the plugin only initialises when the app is in one of the specified modes. - ---- - -### `excludeModes` - -**Type:** `string[]` - -When set, the plugin does not initialise when the app is in one of the specified modes. - ---- - ### Colour and size overrides > [!NOTE] @@ -438,6 +378,31 @@ interactiveMap.on('draw:merge', (e) => { }) ``` +## Application mode + +While drawing or editing, the plugin sets the `'draw'` [application mode](../api.md#setapplicationmodeid-options), which gives the interface over to drawing: every button, panel and control, in every slot, is hidden except draw's own and these defaults: + +```js +['mapStyles', 'mapControls', 'scaleBar'] +``` + +Everything reappears as it was when the draw or edit mode ends. Hidden items stay mounted, so open panels keep their state and scroll position, and modal panels are never hidden. The app root also gets the class `im-o-app--mode-draw`. + +Adjust it with the [`applicationModes`](../api.md#applicationmodes) option, keyed by the mode id. For example, to also keep search and a control of your own, and hide the scale bar: + +```js +new InteractiveMap('map', { + applicationModes: { + draw: { include: ['search', 'myControl'], exclude: ['scaleBar'] } + } +}) +``` + +Or set `draw: false` when every button on the map is deliberate — for example a single-task map that goes straight into editing a shape — so nothing is hidden. + +> [!NOTE] +> The mode only changes the interface. Other plugins' own behaviour keeps running while their buttons are hidden, so disable any that shouldn't respond while drawing — for example, call `interactPlugin.disable()` on [`draw:started`](#drawstarted) and [`draw:editstart`](#draweditstart), and `interactPlugin.enable()` on [`draw:created`](#drawcreated), [`draw:edited`](#drawedited) and [`draw:cancelled`](#drawcancelled). + ## Buttons and keyboard shortcuts The plugin registers its own toolbar buttons automatically — Cancel, Add point (touch only) and Done in the actions bar, plus Undo, Snap and Delete point in the middle of the top row — which show and enable themselves based on the current draw/edit state. You don't need to render these yourself; augment them with your own trigger buttons (e.g. "Draw polygon", "Draw line") the way the [Draw tools example](../examples/draw-tools.mdx) does. diff --git a/docs/plugins/interact.md b/docs/plugins/interact.md index 352b48610..b7e4a7678 100644 --- a/docs/plugins/interact.md +++ b/docs/plugins/interact.md @@ -26,20 +26,6 @@ Options are passed to the factory function when creating the plugin. --- -### `includeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin only renders when the app is in one of these modes. - ---- - -### `excludeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin does not render when the app is in one of these modes. - ---- - ### `interactionModes` **Type:** `Array<'selectMarker' | 'selectFeature' | 'placeMarker'>` **Default:** `['selectMarker']` diff --git a/docs/plugins/map-key.md b/docs/plugins/map-key.md index 109e4e80f..76aab72f9 100644 --- a/docs/plugins/map-key.md +++ b/docs/plugins/map-key.md @@ -99,22 +99,6 @@ createMapKeyPlugin({ noKeyItemText: 'No layers to show' }) --- -### `includeModes` - -**Type:** `string[]` - -When set, the plugin only initialises when the app is in one of the specified modes. - ---- - -### `excludeModes` - -**Type:** `string[]` - -When set, the plugin does not initialise when the app is in one of the specified modes. - ---- - ## Key display properties These properties control how an entry looks in the key panel — they have no effect on how a feature renders on the map itself. Today the only way to set them is via a dataset's [`style`](./datasets.md#style) object, since Datasets is the only plugin feeding this key panel. diff --git a/docs/plugins/map-styles.md b/docs/plugins/map-styles.md index 68ff7b0c6..37028cd2f 100644 --- a/docs/plugins/map-styles.md +++ b/docs/plugins/map-styles.md @@ -70,20 +70,6 @@ createMapStylesPlugin({ --- -### `includeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin only renders when the app is in one of these modes. - ---- - -### `excludeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin does not render when the app is in one of these modes. - ---- - ## Map size When the active map provider supports map sizes (i.e. `mapProvider.capabilities.supportsMapSizes` is `true`), the panel also shows a map size control. This lets users choose between three size levels: diff --git a/docs/plugins/plugin-context.md b/docs/plugins/plugin-context.md index 59749b471..486cd4bac 100644 --- a/docs/plugins/plugin-context.md +++ b/docs/plugins/plugin-context.md @@ -68,49 +68,48 @@ context.pluginState.dispatch({ type: 'setActive', payload: true }) --- -### `setExclusiveControl` -**Type:** `(value: boolean | string | null, options?: { keep?: string[] }) => void` +### `setApplicationMode` +**Type:** `(id: string, options?: { include?: string[], exclude?: string[] }) => void` -Available to plugin components (InitComponent, panel and control render components) as a prop. +### `clearApplicationMode` +**Type:** `(id: string) => void` -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. +Available to plugin components (InitComponent, panel and control render components) as props. -- `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. +Application modes let your plugin change what the interface shows while it's in a particular state, e.g. draw while drawing or search while its form is open. Modes form a stack: `setApplicationMode(id, options)` puts a mode on top (or replaces its lists and moves it to the top if it's already set), and `clearApplicationMode(id)` removes it, so the mode underneath takes over. -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. +While a mode is the most recently set active mode, the app root gets `im-o-app--mode-{id}`. What's hidden depends on its lists: -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. +- **No lists** — nothing is hidden; your CSS can respond to the class instead (as search does). +- **`include`** — only the listed buttons, panels and controls stay visible. Use this to take over the interface, and list your own items too (e.g. draw adds its own button ids). +- **`exclude`** — the listed ones are hidden and everything else stays. ```js -// Claim while expanded; release when collapsed or unmounted +// Enter the mode for as long as your plugin is in that state, and leave it on unmount too useLayoutEffect(() => { - setExclusiveControl(isExpanded) - return () => setExclusiveControl(false) -}, [isExpanded]) + if (!isDrawing) { + return undefined + } + // buttonConfig (a prop) holds your plugin's own buttons, so listing its keys keeps them visible + setApplicationMode('my-plugin', { include: ['mapStyles', 'mapControls', ...Object.keys(buttonConfig)] }) + return () => clearApplicationMode('my-plugin') +}, [isDrawing]) ``` +- Use your plugin's id as the mode id, or as a prefix for several modes (e.g. `'draw-polygon'`). +- One id can name several items, e.g. `mapStyles` is both the map styles button and its panel. +- Hidden items are hidden with `display: none`, not removed, so their state, scroll position and focus-return targets survive, and they reappear as they were when the mode ends. If focus was on something that's hidden, it moves to the map. +- Modal panels are never hidden, so focus can't get trapped in a hidden one. +- If several modes are active, an item only shows if every one of them allows it. +- The host has the final say: its [`applicationModes`](../api.md#applicationmodes) option can add to, remove from or disable your mode. Document your mode's id and default lists so they can. +- Modes only change the interface. Your plugin's own behaviour, and other plugins', carries on as normal. + +Use `include` for a mode the user stays in until they end it (like drawing), where hidden items shouldn't be reachable with Tab. Use no lists, and hide things with your own CSS, when hidden items must stay focusable — for example if your UI closes when focus leaves it, as search does, so Tab can still move on to the next item: + ```scss -.im-o-app--exclusive-control-my-plugin { +.im-o-app--mode-my-plugin { .im-o-app__right .im-c-button-wrapper { - display: none; + opacity: 0; } } ``` - -#### Hiding everything else - -To take over the whole interface, pass a `keep` list of ids. While your claim holds, core hides every button, panel and control, in every slot, except those whose id is listed and your own plugin's items. You don't need any CSS: - -```js -setExclusiveControl(isActive, { keep: ['mapStyles', 'mapControls', 'scaleBar'] }) -``` - -- One id can cover several items, e.g. `mapStyles` is both the map styles button and its panel. -- Hidden items are hidden with `display: none`, not removed, so their state, scroll position and focus-return targets survive, and they reappear as they were when your claim is released. -- Modal panels are never hidden, so focus can't get trapped in a hidden one. -- If several claims with a `keep` list are active, an item only shows if every one of them allows it. -- Without `keep`, core hides nothing, and your CSS decides what to hide in response to your class (as in the examples above). - -Use `keep` for a mode the user stays in until they end it (like drawing), where hidden items shouldn't be reachable with Tab. Leave it out and hide things with your own CSS when hidden items must stay focusable, for example if your UI closes when focus leaves it, as search does, so Tab can still move on to the next item. diff --git a/docs/plugins/scale-bar.md b/docs/plugins/scale-bar.md index 0a7227e6f..5d2133a3e 100644 --- a/docs/plugins/scale-bar.md +++ b/docs/plugins/scale-bar.md @@ -39,20 +39,6 @@ createScaleBarPlugin({ units: 'imperial' }) --- -### `includeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin only renders when the app is in one of these modes. - ---- - -### `excludeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin does not render when the app is in one of these modes. - ---- - ## Methods This plugin does not expose any public methods. diff --git a/docs/plugins/search.md b/docs/plugins/search.md index f0ea5b46b..fc088c8c8 100644 --- a/docs/plugins/search.md +++ b/docs/plugins/search.md @@ -197,20 +197,6 @@ searchPlugin({ --- -### `includeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin only renders when the app is in one of these modes. - ---- - -### `excludeModes` -**Type:** `string[]` - -Array of mode identifiers. When set, the plugin does not render when the app is in one of these modes. - ---- - ## Custom datasets Custom datasets let you add your own search sources alongside or instead of OS Names. Pass them via the `customDatasets` option as an array of dataset configuration objects. @@ -316,6 +302,10 @@ When `true`, if this dataset returns results no other datasets will be queried. --- +## Application mode + +While the search form is open, the plugin sets the `'search'` [application mode](../api.md#setapplicationmodeid-options), adding `im-o-app--mode-search` to the app root. It doesn't hide anything through the mode itself: search's own styles fade out the rest of the top row and side panels, keeping them in the tab order so Tab can move on from the form. You can still use the [`applicationModes`](../api.md#applicationmodes) option to hide extra items while search is open, e.g. `{ search: { exclude: ['myControl'] } }`. + ## Methods This plugin does not expose any public methods. diff --git a/plugins/beta/draw-es/src/DrawInit.jsx b/plugins/beta/draw-es/src/DrawInit.jsx index ced29d093..ee1b46979 100755 --- a/plugins/beta/draw-es/src/DrawInit.jsx +++ b/plugins/beta/draw-es/src/DrawInit.jsx @@ -16,9 +16,7 @@ export const DrawInit = ({ const { mapColorScheme } = mapState.mapStyle || {} // Check if plugin should be active - const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true - const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false - const isActive = mapState.isMapReady && inModeWhitelist && !inExcludeModes + const isActive = mapState.isMapReady // Initialize sketch components once useEffect(() => { @@ -42,7 +40,7 @@ export const DrawInit = ({ mapProvider.sketchLayer = null mapProvider.emptySketchLayer = null } - }, [mapState.isMapReady, appState.mode]) + }, [mapState.isMapReady]) // Attach/detach events useEffect(() => { diff --git a/plugins/beta/draw-ml/src/DrawInit.jsx b/plugins/beta/draw-ml/src/DrawInit.jsx index dab21a2c1..22a4a129f 100755 --- a/plugins/beta/draw-ml/src/DrawInit.jsx +++ b/plugins/beta/draw-ml/src/DrawInit.jsx @@ -10,11 +10,7 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt // Create draw instance once useEffect(() => { - // Don't run init if the app is in non-specified mode - const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true - const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false - - if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) { + if (!mapState.isMapReady) { return } @@ -33,7 +29,7 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt eventBus.emit('draw:ready') return () => remove() - }, [mapState.isMapReady, appState.mode]) + }, [mapState.isMapReady]) // Keep draw instance aware of the crossHair API so draw modes can use show/hide // (rather than direct DOM manipulation which conflicts with React's controlled display style) diff --git a/plugins/beta/draw-ol/src/DrawInit.jsx b/plugins/beta/draw-ol/src/DrawInit.jsx index 5a4e2ec17..b591c7d83 100644 --- a/plugins/beta/draw-ol/src/DrawInit.jsx +++ b/plugins/beta/draw-ol/src/DrawInit.jsx @@ -10,9 +10,7 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt // Create the OLDrawManager once when the map is ready useEffect(() => { - const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true - const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false - if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) { + if (!mapState.isMapReady) { return undefined } @@ -23,7 +21,7 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt eventBus.emit('draw:ready') return () => remove() - }, [mapState.isMapReady, appState.mode]) + }, [mapState.isMapReady]) // Show crosshair when entering draw mode on touch/keyboard useEffect(() => { diff --git a/plugins/beta/frame/src/FrameInit.jsx b/plugins/beta/frame/src/FrameInit.jsx index d6aebb16d..6782342e3 100755 --- a/plugins/beta/frame/src/FrameInit.jsx +++ b/plugins/beta/frame/src/FrameInit.jsx @@ -10,15 +10,13 @@ export const FrameInit = ({ services, buttonConfig }) => { - const { mode, breakpoint, layoutRefs } = appState + const { breakpoint, layoutRefs } = appState const { eventBus } = services const { frameDone, frameCancel } = buttonConfig const { dispatch, frameRefs, frame } = pluginState // Check if plugin should be active - const inModeWhitelist = pluginConfig.includeModes?.includes(mode) ?? true - const inExcludeModes = pluginConfig.excludeModes?.includes(mode) ?? false - const isActive = mapState.isMapReady && inModeWhitelist && !inExcludeModes + const isActive = mapState.isMapReady // Attach events useEffect(() => { @@ -50,5 +48,5 @@ export const FrameInit = ({ frameDone.onClick = null frameCancel.onClick = null } - }, [mapState.isMapReady, mode, breakpoint, frame, frameRefs, layoutRefs, mapProvider, dispatch, eventBus, frameDone, frameCancel]) + }, [mapState.isMapReady, breakpoint, frame, frameRefs, layoutRefs, mapProvider, dispatch, eventBus, frameDone, frameCancel]) } diff --git a/plugins/datasets/src/initialise/DatasetsInit.jsx b/plugins/datasets/src/initialise/DatasetsInit.jsx index c5fd4d38e..320cab269 100755 --- a/plugins/datasets/src/initialise/DatasetsInit.jsx +++ b/plugins/datasets/src/initialise/DatasetsInit.jsx @@ -10,7 +10,7 @@ const pluginConfigHasMenuItems = (pluginConfig) => pluginConfig.datasets .some(dataset => dataset.showInMenu || dataset.sublayers?.some(sublayer => sublayer.showInMenu)) -export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, mapProvider, services }) { +export function DatasetsInit ({ pluginConfig, pluginState, mapState, mapProvider, services }) { const { dispatch } = pluginState const { eventBus, symbolRegistry, patternRegistry } = services const isBaseMapReady = Boolean(mapProvider?.isBaseMapReady()) @@ -32,10 +32,7 @@ export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, m const datasetsInstanceRef = useRef(null) useEffect(() => { - const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true - const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false - - if (!isBaseMapReady || !inModeWhitelist || inExcludeModes) { + if (!isBaseMapReady) { return } @@ -60,7 +57,7 @@ export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, m } initDatasets() - }, [isBaseMapReady, appState.mode]) + }, [isBaseMapReady]) useEffect(() => { datasetRegistry.attach(pluginState.mappedDatasets, pluginState.orderedDatasets) diff --git a/plugins/datasets/src/initialise/DatasetsInit.test.jsx b/plugins/datasets/src/initialise/DatasetsInit.test.jsx index 167f15f1a..6fd717a2a 100644 --- a/plugins/datasets/src/initialise/DatasetsInit.test.jsx +++ b/plugins/datasets/src/initialise/DatasetsInit.test.jsx @@ -47,7 +47,7 @@ const makeProps = (overrides = {}) => ({ datasets: [{ id: 'roads', label: 'Roads', showInMenu: true }] }, pluginState: makePluginState(), - appState: { mode: 'default' }, + appState: {}, mapState: { mapStyle: { id: 'outdoor' } }, mapProvider: { isBaseMapReady: jest.fn().mockReturnValue(true) }, services: { @@ -144,24 +144,6 @@ describe('DatasetsInit', () => { expect(loadLayerAdapter).not.toHaveBeenCalled() }) - it('does not initialise when mode is not in includeModes', async () => { - const props = makeProps({ - pluginConfig: { datasets: [], includeModes: ['edit'] }, - appState: { mode: 'default' } - }) - await render() - expect(loadLayerAdapter).not.toHaveBeenCalled() - }) - - it('does not initialise when mode is in excludeModes', async () => { - const props = makeProps({ - pluginConfig: { datasets: [], excludeModes: ['default'] }, - appState: { mode: 'default' } - }) - await render() - expect(loadLayerAdapter).not.toHaveBeenCalled() - }) - it('does not initialise twice when re-rendered', async () => { const props = makeProps() const { rerender } = render() @@ -171,13 +153,16 @@ describe('DatasetsInit', () => { expect(loadLayerAdapter).toHaveBeenCalledTimes(1) }) - it('skips init when datasetsInstanceRef is already populated (mode change)', async () => { + it('skips init when datasetsInstanceRef is already populated (base map reloads)', async () => { const props = makeProps() const { rerender } = render() await act(async () => {}) - // Change appState.mode — that's in the effect deps, so the effect re-runs + // Toggle base map readiness — that's the effect's dependency, so the effect re-runs + await act(async () => { + rerender( false }} />) + }) await act(async () => { - rerender() + rerender( true }} />) }) // loadLayerAdapter should only be called once despite the re-run expect(loadLayerAdapter).toHaveBeenCalledTimes(1) diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index cda9f63e2..313fb9466 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -3,47 +3,13 @@ import { EVENTS } from '../../../src/config/events.js' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' import { useSpatialList } from './hooks/useSpatialList.js' -import { EXCLUSIVE_CONTROL_KEEP } from './defaults.js' +import { APPLICATION_MODE_ID, APPLICATION_MODE_INCLUDE } from './defaults.js' -/** - * Every button (not menu item), panel and control id currently registered, de-duplicated (one id - * can name a button and its panel). Passed to a keep function so hosts can discover ids. - * - * @param {{ buttonConfig?: Object, panelConfig?: Object, controlConfig?: Object }} appState - * @returns {string[]} - */ -export const getInterfaceItemIds = ({ buttonConfig = {}, panelConfig = {}, controlConfig = {} }) => [...new Set([ - ...Object.keys(buttonConfig).filter(id => !buttonConfig[id].isMenuItem), - ...Object.keys(panelConfig), - ...Object.keys(controlConfig) -])] - -/** - * Resolves the ids to keep visible while draw has exclusive control, from the exclusiveControl option. - * Draw's own buttons don't need listing: core never hides a claiming plugin's own items. - * - * @param {boolean | { keep?: string[] | ((defaults: string[], context: { ids: string[] }) => string[]) }} [option] - * false opts out; keep as an array replaces the default list, as a function receives a copy of the - * defaults and every current item id and returns the list to keep. - * @param {string[]} [ids=[]] - Every current button, panel and control id, for a keep function. - * @returns {string[] | null} The ids to keep, or null when opted out. - */ -export const getExclusiveControlKeep = (option, ids = []) => { - if (option === false) { - return null - } - const keep = option?.keep ?? EXCLUSIVE_CONTROL_KEEP - return typeof keep === 'function' ? keep([...EXCLUSIVE_CONTROL_KEEP], { ids }) : keep -} - -// Loads the draw adapter once the map is ready and this plugin instance is in scope for the -// current app mode; tears it down (and releases MapControls' D-pad) on cleanup. -function useLoadDrawAdapter ({ mapState, appState, pluginConfig, pluginState, mapProvider, eventBus }) { +// Loads the draw adapter once the map is ready; tears it down (and releases MapControls' D-pad) +// on cleanup. +function useLoadDrawAdapter ({ mapState, pluginConfig, pluginState, mapProvider, eventBus }) { useEffect(() => { - const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true - const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false - - if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) { + if (!mapState.isMapReady) { return undefined } @@ -69,10 +35,10 @@ function useLoadDrawAdapter ({ mapState, appState, pluginConfig, pluginState, ma // Release MapControls' D-pad if this plugin instance still held it. mapProvider.activeMoveTarget = null } - }, [mapState.isMapReady, appState.mode]) + }, [mapState.isMapReady]) } -export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginState, services, mapProvider, buttonConfig, setExclusiveControl }) => { +export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginState, services, mapProvider, buttonConfig, setApplicationMode, clearApplicationMode }) => { const { eventBus, hints } = services const { crossHair } = mapState const isTouchOrKeyboard = ['touch', 'keyboard'].includes(appState.interfaceType) @@ -85,25 +51,20 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt shouldShowCrosshairRef.current = ['draw_polygon', 'draw_line', 'draw_point'].includes(pluginState.mode) && (isTouchOrKeyboard || appState.expandedButtons?.has('mapControls')) - useLoadDrawAdapter({ mapState, appState, pluginConfig, pluginState, mapProvider, eventBus }) + useLoadDrawAdapter({ mapState, pluginConfig, pluginState, mapProvider, eventBus }) - // Takes exclusive control of the interface in any draw/edit mode: core hides every button, panel - // and control not on the keep list (other than draw's own) until the mode ends, keeping them - // mounted so their state survives. Hosts change the list with exclusiveControl: { keep }, - // or opt out with exclusiveControl: false (e.g. a single-task map whose other buttons are all - // deliberate). useLayoutEffect so the class lands in the same paint as the mode change. + // Enters the 'draw' application mode in any draw/edit mode: core hides every button, panel and + // control except APPLICATION_MODE_INCLUDE and draw's own buttons (buttonConfig holds only draw's) + // until the draw/edit mode ends, keeping them mounted so their state survives. The host adjusts or + // disables it via its applicationModes config. useLayoutEffect so the class lands in the same paint + // as the draw/edit mode change. useLayoutEffect(() => { - if (pluginConfig.exclusiveControl === false) { - return undefined - } if (!pluginState.mode) { - setExclusiveControl(false) + clearApplicationMode(APPLICATION_MODE_ID) return undefined } - // Resolved on entering each mode (so a keep function runs once per mode, with current ids) - const keep = getExclusiveControlKeep(pluginConfig.exclusiveControl, getInterfaceItemIds(appState)) - setExclusiveControl(true, { keep }) - return () => setExclusiveControl(false) + setApplicationMode(APPLICATION_MODE_ID, { include: [...APPLICATION_MODE_INCLUDE, ...Object.keys(buttonConfig)] }) + return () => clearApplicationMode(APPLICATION_MODE_ID) }, [pluginState.mode]) // Suppresses the accessible spatial list for every draw/edit mode except edit_vertex, which diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index 029e29073..73c0a8665 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -1,6 +1,6 @@ import { render, act } from '@testing-library/react' import { EVENTS } from '../../../src/config/events.js' -import { DrawInit, getExclusiveControlKeep, getInterfaceItemIds } from './DrawInit.jsx' +import { DrawInit } from './DrawInit.jsx' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' import { useSpatialList } from './hooks/useSpatialList.js' @@ -26,7 +26,8 @@ const makeProps = (overrides = {}) => { services: { eventBus: { emit: jest.fn() } }, mapProvider: { draw: null }, buttonConfig: {}, - setExclusiveControl: jest.fn(), + setApplicationMode: jest.fn(), + clearApplicationMode: jest.fn(), ...overrides } return { props, adapter } @@ -61,24 +62,6 @@ describe('adapter lifecycle', () => { expect(loadDrawAdapter).not.toHaveBeenCalled() }) - test('does not load when the app mode is excluded', async () => { - const { props } = makeProps({ - appState: { interfaceType: 'mouse', mode: 'measure', layoutRefs: { viewportRef: { current: null } } }, - pluginConfig: { snapLayers: [], excludeModes: ['measure'] } - }) - await renderInit(props) - expect(loadDrawAdapter).not.toHaveBeenCalled() - }) - - test('does not load when the app mode is outside the include list', async () => { - const { props } = makeProps({ - appState: { interfaceType: 'mouse', mode: 'other', layoutRefs: { viewportRef: { current: null } } }, - pluginConfig: { snapLayers: [], includeModes: ['draw'] } - }) - await renderInit(props) - expect(loadDrawAdapter).not.toHaveBeenCalled() - }) - test('removes the adapter, clears the reference, and releases activeMoveTarget on unmount', async () => { const { props, adapter } = makeProps() const result = await renderInit(props) @@ -268,109 +251,25 @@ describe('event attachment', () => { }) }) -describe('exclusive control', () => { - test('claims exclusive control while in a draw/edit mode, keeping map styles, map controls and scale bar', async () => { +describe('application mode', () => { + test('enters the draw application mode in a draw/edit mode, including its default items', async () => { const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } }) await renderInit(props) - expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true, { keep: ['mapStyles', 'mapControls', 'scaleBar'] }) + expect(props.setApplicationMode).toHaveBeenLastCalledWith('draw', { include: ['mapStyles', 'mapControls', 'scaleBar'] }) }) - test('claims with the host\'s keep list in place of the default', async () => { - const { props } = makeProps({ - pluginConfig: { snapLayers: ['a'], exclusiveControl: { keep: ['search', 'myButton'] } }, - pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } - }) - await renderInit(props) - expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true, { keep: ['search', 'myButton'] }) - }) - - test('calls a keep function on entering a mode with the defaults and every current item id', async () => { - const keep = jest.fn((defaults) => defaults.filter(id => id !== 'scaleBar')) - const { props } = makeProps({ - pluginConfig: { snapLayers: ['a'], exclusiveControl: { keep } }, - pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } - }) - props.appState.buttonConfig = { search: {}, drawUndo: {} } - props.appState.panelConfig = { mapStyles: {} } - await renderInit(props) - expect(keep).toHaveBeenCalledWith(['mapStyles', 'mapControls', 'scaleBar'], { ids: ['search', 'drawUndo', 'mapStyles'] }) - expect(props.setExclusiveControl).toHaveBeenLastCalledWith(true, { keep: ['mapStyles', 'mapControls'] }) - }) - - test('does not call a keep function when the mode clears', async () => { - const keep = jest.fn(() => []) - const { props } = makeProps({ - pluginConfig: { snapLayers: ['a'], exclusiveControl: { keep } }, - pluginState: { dispatch: jest.fn(), mode: 'draw_line' } - }) - const { rerender } = await renderInit(props) - keep.mockClear() - rerender() - expect(keep).not.toHaveBeenCalled() - }) - - test('releases exclusive control when the mode clears', async () => { + test('leaves the draw application mode when the draw/edit mode ends', async () => { const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } }) const { rerender } = await renderInit(props) rerender() - expect(props.setExclusiveControl.mock.lastCall[0]).toBe(false) + expect(props.clearApplicationMode).toHaveBeenLastCalledWith('draw') }) - test('releases exclusive control on unmount', async () => { + test('leaves the draw application mode on unmount', async () => { const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'draw_line' } }) const { unmount } = await renderInit(props) + props.clearApplicationMode.mockClear() unmount() - expect(props.setExclusiveControl).toHaveBeenLastCalledWith(false) - }) - - test('never claims when the host opts out with exclusiveControl: false', async () => { - const { props } = makeProps({ - pluginConfig: { snapLayers: ['a'], exclusiveControl: false }, - pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } - }) - const { unmount } = await renderInit(props) - unmount() - expect(props.setExclusiveControl).not.toHaveBeenCalled() - }) -}) - -describe('getExclusiveControlKeep', () => { - test('uses the default keep list when no option is given', () => { - expect(getExclusiveControlKeep(undefined)).toEqual(['mapStyles', 'mapControls', 'scaleBar']) - expect(getExclusiveControlKeep(true)).toEqual(['mapStyles', 'mapControls', 'scaleBar']) - }) - - test('replaces the default with the host\'s keep list', () => { - expect(getExclusiveControlKeep({ keep: ['mapControls'] })).toEqual(['mapControls']) - expect(getExclusiveControlKeep({ keep: [] })).toEqual([]) - }) - - test('passes a keep function a copy of the defaults and the item ids, using what it returns', () => { - const keep = jest.fn((defaults, { ids }) => [...defaults, ids[0]]) - expect(getExclusiveControlKeep({ keep }, ['search'])).toEqual(['mapStyles', 'mapControls', 'scaleBar', 'search']) - expect(keep).toHaveBeenCalledWith(['mapStyles', 'mapControls', 'scaleBar'], { ids: ['search'] }) - }) - - test('a keep function mutating its defaults argument doesn\'t change the shared default', () => { - getExclusiveControlKeep({ keep: (defaults) => { defaults.length = 0; return defaults } }) - expect(getExclusiveControlKeep(undefined)).toEqual(['mapStyles', 'mapControls', 'scaleBar']) - }) - - test('returns null when the host opts out', () => { - expect(getExclusiveControlKeep(false)).toBeNull() - }) -}) - -describe('getInterfaceItemIds', () => { - test('lists every button (not menu item), panel and control id once', () => { - expect(getInterfaceItemIds({ - buttonConfig: { mapStyles: {}, drawUndo: {}, drawSnapItem: { isMenuItem: true } }, - panelConfig: { mapStyles: {}, mapKey: {} }, - controlConfig: { scaleBar: {} } - })).toEqual(['mapStyles', 'drawUndo', 'mapKey', 'scaleBar']) - }) - - test('copes with missing config', () => { - expect(getInterfaceItemIds({})).toEqual([]) + expect(props.clearApplicationMode).toHaveBeenCalledWith('draw') }) }) diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js index e49151e45..da4cf240e 100644 --- a/plugins/draw/src/defaults.js +++ b/plugins/draw/src/defaults.js @@ -51,8 +51,8 @@ export const MAP_SIZE_SCALES = { large: 2 } -// Button, panel and control ids that stay visible while draw has exclusive control; everything else -// in the interface is hidden. Hosts replace this list with exclusiveControl: { keep: [...] }, or -// adjust it with a keep function that receives it. Draw's own buttons are always kept (core never -// hides a claiming plugin's own items). -export const EXCLUSIVE_CONTROL_KEEP = ['mapStyles', 'mapControls', 'scaleBar'] +// The application mode draw enters while drawing or editing, and the button, panel and control ids +// it keeps visible alongside its own buttons; everything else in the interface is hidden meanwhile. +// Hosts adjust or disable it via the applicationModes config, keyed by this id. +export const APPLICATION_MODE_ID = 'draw' +export const APPLICATION_MODE_INCLUDE = ['mapStyles', 'mapControls', 'scaleBar'] diff --git a/plugins/search/src/Search.jsx b/plugins/search/src/Search.jsx index 427a3160c..3dcd8d01e 100755 --- a/plugins/search/src/Search.jsx +++ b/plugins/search/src/Search.jsx @@ -5,8 +5,9 @@ import { CloseButton } from './components/CloseButton/CloseButton' import { SubmitButton } from './components/SubmitButton/SubmitButton' import { createDatasets } from './datasets.js' import { attachEvents } from './events/index.js' +import { APPLICATION_MODE_ID } from './defaults.js' -export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, appState, mapState, services, mapProvider, setExclusiveControl }) { +export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, appState, mapState, services, mapProvider, setApplicationMode, clearApplicationMode }) { const { id } = appConfig const { interfaceType } = appState const { expanded: defaultExpanded, customDatasets, osNamesURL, regions, maxSuggestions } = pluginConfig @@ -54,13 +55,20 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap inputRef.current?.focus() }, [isExpanded]) - // Manage focus outside the search control - // 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. + // Enters the 'search' application mode while expanded, so search.scss can hide the rest of the + // interface (with opacity, keeping it focusable for Tab-out), and leaves it on collapse or unmount. + // useLayoutEffect (not useEffect) so the class lands in the same paint as the form expanding - + // otherwise the browser paints once with the other buttons still visible, producing a flicker. useLayoutEffect(() => { - setExclusiveControl(isExpanded) + if (!isExpanded) { + return undefined + } + setApplicationMode(APPLICATION_MODE_ID) + return () => clearApplicationMode(APPLICATION_MODE_ID) + }, [isExpanded]) + // Manage focus outside the search control + useLayoutEffect(() => { if (!searchOpen) { return undefined } @@ -87,7 +95,7 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap
events.handleTabOut(e, appState.buttonRefs)} + onKeyDown={(event) => events.handleTabOut(event, appState.buttonRefs)} >
{ mapState: { markers: {} }, services: {}, mapProvider: { crs: 'EPSG:3857' }, - setExclusiveControl: jest.fn() + setApplicationMode: jest.fn(), + clearApplicationMode: jest.fn() } }) @@ -104,15 +105,27 @@ describe('Search component', () => { expect(container.querySelector('.im-c-search')).not.toHaveClass('im-c-search--collapsed') }) - it('claims exclusive control as search while expanded', () => { + it('enters the search application mode while expanded', () => { props.pluginState.isExpanded = true render() - expect(props.setExclusiveControl).toHaveBeenCalledWith(true) + expect(props.setApplicationMode).toHaveBeenCalledWith('search') }) - it('releases exclusive control when collapsed', () => { + it('does not enter the search application mode while collapsed', () => { render() - expect(props.setExclusiveControl).toHaveBeenCalledWith(false) + expect(props.setApplicationMode).not.toHaveBeenCalled() + }) + + it('leaves the search application mode on collapse and on unmount', () => { + props.pluginState.isExpanded = true + const { rerender, unmount } = render() + rerender() + expect(props.clearApplicationMode).toHaveBeenCalledWith('search') + + props.clearApplicationMode.mockClear() + rerender() + unmount() + expect(props.clearApplicationMode).toHaveBeenCalledWith('search') }) it('does not collapse the wrapper in default-expanded mode', () => { diff --git a/plugins/search/src/defaults.js b/plugins/search/src/defaults.js index d711cc636..214fd34a2 100644 --- a/plugins/search/src/defaults.js +++ b/plugins/search/src/defaults.js @@ -7,3 +7,6 @@ export const DEFAULTS = { noResultsMessage: 'No results available', searchErrorMessage: 'Sorry, there was a problem with search' } + +// The application mode search enters while its form is expanded (see search.scss's .im-o-app--mode-search) +export const APPLICATION_MODE_ID = 'search' diff --git a/plugins/search/src/events/formHandlers.js b/plugins/search/src/events/formHandlers.js index 44eaa0d39..680688bf9 100755 --- a/plugins/search/src/events/formHandlers.js +++ b/plugins/search/src/events/formHandlers.js @@ -41,22 +41,22 @@ const getTabStops = () => Array.from(document.querySelectorAll(TAB_STOP_SELECTOR // Tab order would leave the form for whatever happens to follow it in the DOM. Continue from the // trigger's position instead: Tab past the last element goes to whatever follows the trigger, // Shift+Tab past the first returns to the trigger. handleOutside then closes search as usual. -const tabOutFromTrigger = (e, buttonRefs, searchContainerRef) => { +const tabOutFromTrigger = (event, buttonRefs, searchContainerRef) => { const trigger = getTriggerButton(buttonRefs) - if (e.key !== 'Tab' || !trigger) { + if (event.key !== 'Tab' || !trigger) { return } const stops = getTabStops() - const adjacent = stops[stops.indexOf(e.target) + (e.shiftKey ? -1 : 1)] + const adjacent = stops[stops.indexOf(event.target) + (event.shiftKey ? -1 : 1)] if (searchContainerRef.current?.contains(adjacent)) { return } // Nothing after the trigger means it's the page's last tab stop: let focus leave the page natively. - const target = e.shiftKey ? trigger : stops[stops.indexOf(trigger) + 1] + const target = event.shiftKey ? trigger : stops[stops.indexOf(trigger) + 1] if (!target) { return } - e.preventDefault() + event.preventDefault() target.focus() } @@ -75,8 +75,8 @@ export const createFormHandlers = ({ let lastFetchedValue = '' return { - handleTabOut (e, buttonRefs) { - tabOutFromTrigger(e, buttonRefs, searchContainerRef) + handleTabOut (event, buttonRefs) { + tabOutFromTrigger(event, buttonRefs, searchContainerRef) }, handleCloseClick (_e, appState) { diff --git a/plugins/search/src/search.scss b/plugins/search/src/search.scss index 59df47c08..0b2ffeb00 100755 --- a/plugins/search/src/search.scss +++ b/plugins/search/src/search.scss @@ -112,12 +112,14 @@ } // 3. Modifiers -.im-o-app--exclusive-control-search { +.im-o-app--mode-search { // Everything in the top row except the form itself: buttons (the trigger included), button - // groups and other controls, whatever slot search is in. Taken out of flow so nothing offsets the - // form within its column, but kept focusable: Tab from the form still reaches the next item, and - // focus can return to the trigger on close. - .im-o-app__top-col > :not(.im-c-search) { + // groups and other controls, whatever slot search is in. Plugin controls sit inside a display: + // contents wrapper, so it's their content that's targeted (search's own wrapper excepted). Taken + // out of flow so nothing offsets the form within its column, but kept focusable: Tab from the form + // still reaches the next item, and focus can return to the trigger on close. + .im-o-app__top-col > :not(.im-c-control-wrapper), + .im-o-app__top-col > .im-c-control-wrapper:not(.im-c-control-wrapper--search) > * { position: absolute; opacity: 0; z-index: -1; diff --git a/src/App/components/Viewport/Viewport.jsx b/src/App/components/Viewport/Viewport.jsx index bc7320ad6..a502a3408 100755 --- a/src/App/components/Viewport/Viewport.jsx +++ b/src/App/components/Viewport/Viewport.jsx @@ -20,7 +20,7 @@ import { Markers } from '../Markers/Markers' // sonarjs/disable-next-line function-name export const Viewport = () => { const { id, mapProvider, mapLabel, keyboardHintText, focusOnMount } = useConfig() - const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp() + const { layoutRefs, safeZoneInset, dispatch } = useApp() const { mapSize, isMapReady } = useMap() const { eventBus, hints } = useService() @@ -65,13 +65,6 @@ export const Viewport = () => { } }) - // Set focus on viewport on mode change - useEffect(() => { - if (mode && previousMode && mode !== previousMode) { - layoutRefs.viewportRef?.current.focus() - } - }, [mode]) - // Focus the viewport on mount when opened by a genuine launcher click (see InteractiveMap.js's _handleButtonClick), never on an auto-load or resize. useEffect(() => { if (focusOnMount) { diff --git a/src/App/components/Viewport/Viewport.test.jsx b/src/App/components/Viewport/Viewport.test.jsx index cd747a681..467f225ed 100755 --- a/src/App/components/Viewport/Viewport.test.jsx +++ b/src/App/components/Viewport/Viewport.test.jsx @@ -38,8 +38,6 @@ function setupHookMocks (mainEl, viewportEl) { }) useApp.mockReturnValue({ interfaceType: 'desktop', - mode: 'default', - previousMode: 'default', layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewportEl }, safeZoneRef: { current: null } }, safeZoneInset: {}, dispatch: jest.fn() @@ -197,18 +195,4 @@ describe('Viewport interactions', () => { fireEvent.blur(container.querySelector('[role="listbox"]')) expect(hints.dismiss).toHaveBeenCalled() }) - - it('focuses viewport when mode changes', () => { - const { viewport, rerender } = renderViewport() - const focusMock = jest.spyOn(viewport, 'focus') - useApp.mockReturnValueOnce({ - interfaceType: 'desktop', - mode: 'edit', - previousMode: 'default', - layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewport }, safeZoneRef: { current: null } }, - safeZoneInset: {} - }) - rerender() - expect(focusMock).toHaveBeenCalled() - }) }) diff --git a/src/App/hooks/useApplicationModeFocus.js b/src/App/hooks/useApplicationModeFocus.js new file mode 100644 index 000000000..5733c60a9 --- /dev/null +++ b/src/App/hooks/useApplicationModeFocus.js @@ -0,0 +1,21 @@ +import { useEffect } from 'react' +import { useApp } from '../store/appContext.js' + +/** + * Keeps focus somewhere visible when application modes change: if focus was inside the app on an + * element a mode has just hidden (display: none leaves it with no client rects), it moves to the + * map viewport. A plain useEffect so it runs after every item's own hiding has been applied, + * including consumer HTML items, which are hidden in their own effects. + */ +export function useApplicationModeFocus () { + const { applicationModeEntries, layoutRefs } = useApp() + + useEffect(() => { + const appElement = layoutRefs.appContainerRef?.current + const focusedElement = document.activeElement + if (!appElement || !focusedElement || !appElement.contains(focusedElement) || focusedElement.getClientRects().length > 0) { + return + } + layoutRefs.viewportRef?.current?.focus({ preventScroll: true }) + }, [applicationModeEntries]) +} diff --git a/src/App/hooks/useApplicationModeFocus.test.js b/src/App/hooks/useApplicationModeFocus.test.js new file mode 100644 index 000000000..0da689cc4 --- /dev/null +++ b/src/App/hooks/useApplicationModeFocus.test.js @@ -0,0 +1,53 @@ +import { renderHook } from '@testing-library/react' +import { useApplicationModeFocus } from './useApplicationModeFocus.js' +import { useApp } from '../store/appContext.js' + +jest.mock('../store/appContext.js', () => ({ useApp: jest.fn() })) + +describe('useApplicationModeFocus', () => { + let app, viewport, button + + // jsdom has no layout, so stand in for rendered (has client rects) vs hidden (none) + const setRendered = (el, rendered) => { el.getClientRects = () => (rendered ? [{}] : []) } + + beforeEach(() => { + app = document.createElement('div') + viewport = document.createElement('div') + viewport.tabIndex = 0 + button = document.createElement('button') + app.append(viewport, button) + document.body.append(app) + setRendered(viewport, true) + viewport.focus = jest.fn() + }) + + afterEach(() => { document.body.innerHTML = '' }) + + const render = (applicationModeEntries) => { + useApp.mockReturnValue({ applicationModeEntries, layoutRefs: { appContainerRef: { current: app }, viewportRef: { current: viewport } } }) + return renderHook(() => useApplicationModeFocus()) + } + + it('moves focus to the viewport when the focused element has been hidden', () => { + button.focus() + setRendered(button, false) + render([{ id: 'draw', include: [], exclude: null }]) + expect(viewport.focus).toHaveBeenCalledWith({ preventScroll: true }) + }) + + it('leaves focus alone when the focused element is still shown', () => { + button.focus() + setRendered(button, true) + render([{ id: 'draw', include: [], exclude: null }]) + expect(viewport.focus).not.toHaveBeenCalled() + }) + + it('leaves focus alone when it is outside the app', () => { + const outside = document.createElement('button') + document.body.append(outside) + outside.focus() + setRendered(outside, false) + render([{ id: 'draw', include: [], exclude: null }]) + expect(viewport.focus).not.toHaveBeenCalled() + }) +}) diff --git a/src/App/hooks/useInterfaceAPI.js b/src/App/hooks/useInterfaceAPI.js index 8606cc97d..b7fdf2e32 100644 --- a/src/App/hooks/useInterfaceAPI.js +++ b/src/App/hooks/useInterfaceAPI.js @@ -4,6 +4,76 @@ import { EVENTS as events } from '../../config/events.js' import { useApp } from '../store/appContext.js' import { useService } from '../store/serviceContext.js' +// Handlers for the public interface API events (addButton, showPanel, setApplicationMode, ...), as +// [event, handler] pairs. They read dispatch and state through refs, so they're created once and +// always act on the latest values. +const createInterfaceSubscriptions = (dispatchRef, stateRef) => { + const handleAddButton = ({ id, config }) => { + // Add the button + dispatchRef.current({ type: 'ADD_BUTTON', payload: { id, config } }) + // Add all optional menu items as individual buttons (isMenuItem prevents slot rendering) + if (Array.isArray(config.menuItems)) { + config.menuItems.forEach(item => { + dispatchRef.current({ type: 'ADD_BUTTON', payload: { id: item.id, config: { ...item, isMenuItem: true } } }) + }) + } + } + + const handleToggleButtonState = ({ id, prop, value }) => { + const { hiddenButtons: hidden, disabledButtons: disabled, pressedButtons: pressed, expandedButtons: expanded } = stateRef.current + switch (prop) { + case 'hidden': { + const isHidden = typeof value === 'boolean' ? value : !hidden.has(id) + dispatchRef.current({ type: 'TOGGLE_BUTTON_HIDDEN', payload: { id, isHidden } }) + break + } + case 'disabled': { + const isDisabled = typeof value === 'boolean' ? value : !disabled.has(id) + dispatchRef.current({ type: 'TOGGLE_BUTTON_DISABLED', payload: { id, isDisabled } }) + break + } + case 'pressed': { + const isPressed = typeof value === 'boolean' ? value : !pressed.has(id) + dispatchRef.current({ type: 'TOGGLE_BUTTON_PRESSED', payload: { id, isPressed } }) + break + } + case 'expanded': { + const isExpanded = typeof value === 'boolean' ? value : !expanded.has(id) + dispatchRef.current({ type: 'TOGGLE_BUTTON_EXPANDED', payload: { id, isExpanded } }) + break + } + default: + break + } + } + + const handleAppOpened = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true }) + const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false }) + const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } }) + const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id }) + const handleShowPanel = ({ id, focus = true, triggeringElement }) => + dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus, props: triggeringElement ? { triggeringElement } : undefined } }) + const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id }) + const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } }) + const handleSetApplicationMode = ({ id, include, exclude }) => + dispatchRef.current({ type: 'SET_APPLICATION_MODE', payload: { id, include, exclude } }) + const handleClearApplicationMode = (id) => dispatchRef.current({ type: 'CLEAR_APPLICATION_MODE', payload: id }) + + return [ + [events.APP_OPENED, handleAppOpened], + [events.APP_CLOSED, handleAppClosed], + [events.APP_ADD_BUTTON, handleAddButton], + [events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState], + [events.APP_ADD_PANEL, handleAddPanel], + [events.APP_REMOVE_PANEL, handleRemovePanel], + [events.APP_SHOW_PANEL, handleShowPanel], + [events.APP_HIDE_PANEL, handleHidePanel], + [events.APP_ADD_CONTROL, handleAddControl], + [events.APP_SET_APPLICATION_MODE, handleSetApplicationMode], + [events.APP_CLEAR_APPLICATION_MODE, handleClearApplicationMode] + ] +} + export const useInterfaceAPI = () => { const { dispatch, hiddenButtons, disabledButtons, pressedButtons, expandedButtons } = useApp() const { eventBus } = useService() @@ -15,74 +85,11 @@ export const useInterfaceAPI = () => { stateRef.current = { hiddenButtons, disabledButtons, pressedButtons, expandedButtons } useEffect(() => { - const handleAddButton = ({ id, config }) => { - // Add the button - dispatchRef.current({ type: 'ADD_BUTTON', payload: { id, config } }) - // Add all optional menu items as individual buttons (isMenuItem prevents slot rendering) - if (Array.isArray(config.menuItems)) { - config.menuItems.forEach(item => { - dispatchRef.current({ type: 'ADD_BUTTON', payload: { id: item.id, config: { ...item, isMenuItem: true } } }) - }) - } - } - - const handleToggleButtonState = ({ id, prop, value }) => { - const { hiddenButtons: hidden, disabledButtons: disabled, pressedButtons: pressed, expandedButtons: expanded } = stateRef.current - switch (prop) { - case 'hidden': { - const isHidden = typeof value === 'boolean' ? value : !hidden.has(id) - dispatchRef.current({ type: 'TOGGLE_BUTTON_HIDDEN', payload: { id, isHidden } }) - break - } - case 'disabled': { - const isDisabled = typeof value === 'boolean' ? value : !disabled.has(id) - dispatchRef.current({ type: 'TOGGLE_BUTTON_DISABLED', payload: { id, isDisabled } }) - break - } - case 'pressed': { - const isPressed = typeof value === 'boolean' ? value : !pressed.has(id) - dispatchRef.current({ type: 'TOGGLE_BUTTON_PRESSED', payload: { id, isPressed } }) - break - } - case 'expanded': { - const isExpanded = typeof value === 'boolean' ? value : !expanded.has(id) - dispatchRef.current({ type: 'TOGGLE_BUTTON_EXPANDED', payload: { id, isExpanded } }) - break - } - default: - break - } - } - - const handleAppOpened = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true }) - const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false }) - const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } }) - const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id }) - const handleShowPanel = ({ id, focus = true, triggeringElement }) => - dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus, props: triggeringElement ? { triggeringElement } : undefined } }) - const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id }) - const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } }) - - eventBus.on(events.APP_OPENED, handleAppOpened) - eventBus.on(events.APP_CLOSED, handleAppClosed) - eventBus.on(events.APP_ADD_BUTTON, handleAddButton) - eventBus.on(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState) - eventBus.on(events.APP_ADD_PANEL, handleAddPanel) - eventBus.on(events.APP_REMOVE_PANEL, handleRemovePanel) - eventBus.on(events.APP_SHOW_PANEL, handleShowPanel) - eventBus.on(events.APP_HIDE_PANEL, handleHidePanel) - eventBus.on(events.APP_ADD_CONTROL, handleAddControl) + const subscriptions = createInterfaceSubscriptions(dispatchRef, stateRef) + subscriptions.forEach(([eventName, handler]) => eventBus.on(eventName, handler)) return () => { - eventBus.off(events.APP_OPENED, handleAppOpened) - eventBus.off(events.APP_CLOSED, handleAppClosed) - eventBus.off(events.APP_ADD_BUTTON, handleAddButton) - eventBus.off(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState) - eventBus.off(events.APP_ADD_PANEL, handleAddPanel) - eventBus.off(events.APP_REMOVE_PANEL, handleRemovePanel) - eventBus.off(events.APP_SHOW_PANEL, handleShowPanel) - eventBus.off(events.APP_HIDE_PANEL, handleHidePanel) - eventBus.off(events.APP_ADD_CONTROL, handleAddControl) + subscriptions.forEach(([eventName, handler]) => eventBus.off(eventName, handler)) } }, [eventBus]) } diff --git a/src/App/hooks/useInterfaceAPI.test.js b/src/App/hooks/useInterfaceAPI.test.js index b77a29278..51c2e843d 100644 --- a/src/App/hooks/useInterfaceAPI.test.js +++ b/src/App/hooks/useInterfaceAPI.test.js @@ -33,6 +33,21 @@ describe('useInterfaceAPI', () => { useService.mockReturnValue({ eventBus: mockEventBus }) }) + it('sets a mode on app:setapplicationmode', () => { + renderHook(() => useInterfaceAPI()) + act(() => mockEventBus.emit('app:setapplicationmode', { id: 'review', include: ['mapStyles'], exclude: null })) + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_APPLICATION_MODE', + payload: { id: 'review', include: ['mapStyles'], exclude: null } + }) + }) + + it('clears a mode on app:clearapplicationmode', () => { + renderHook(() => useInterfaceAPI()) + act(() => mockEventBus.emit('app:clearapplicationmode', 'review')) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'CLEAR_APPLICATION_MODE', payload: 'review' }) + }) + it('dispatches ADD_BUTTON on app:addbutton', () => { renderHook(() => useInterfaceAPI()) act(() => mockEventBus.emit('app:addbutton', { id: 'btn1', config: { label: 'Test' } })) diff --git a/src/App/hooks/useLayoutMeasurements.js b/src/App/hooks/useLayoutMeasurements.js index 1daab0484..66e9a7d51 100755 --- a/src/App/hooks/useLayoutMeasurements.js +++ b/src/App/hooks/useLayoutMeasurements.js @@ -11,12 +11,23 @@ const ATTRIBUTIONS_STACKED_CLASS = 'im-o-app__attributions--stacked' const buttonHeight = (ref) => ref?.current?.offsetHeight ?? 0 const buttonWidth = (ref) => ref?.current?.offsetWidth ?? 0 -// A top column whose children are all display:none (hiddenWhen buttons, or items hidden by a -// plugin's exclusive-control CSS) isn't :empty, so it keeps its trailing padding-bottom — which -// would push the side column below it down by a gap. Treat it as empty instead. +// Whether an element renders anything. A display: contents element (e.g. the control wrapper core puts +// around each plugin control) has no box of its own, so it counts as rendered if any child is. +const isRendered = (element) => { + if (element.getClientRects().length > 0) { + return true + } + // Core's control wrapper sets display inline, so check that before the (costlier) computed style + const display = element.style.display || getComputedStyle(element).display + return display === 'contents' && Array.from(element.children).some(isRendered) +} + +// A top column whose children are all display:none (hiddenWhen buttons, or items an application mode +// hides) isn't :empty, so it keeps its trailing padding-bottom — which would push the side column +// below it down by a gap. Treat it as empty instead. const topColHeight = (col) => { const children = Array.from(col.children) - const isAllHidden = children.length > 0 && children.every(child => child.getClientRects().length === 0) + const isAllHidden = children.length > 0 && !children.some(isRendered) return isAllHidden ? 0 : col.offsetHeight } diff --git a/src/App/hooks/useLayoutMeasurements.test.js b/src/App/hooks/useLayoutMeasurements.test.js index d4d54ec01..6d462d131 100644 --- a/src/App/hooks/useLayoutMeasurements.test.js +++ b/src/App/hooks/useLayoutMeasurements.test.js @@ -85,6 +85,28 @@ describe('useLayoutMeasurements', () => { expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-offset-top', '15px') }) + test('counts a display: contents wrapper as rendered when anything inside it is', () => { + const { layoutRefs } = setup({ refs: { topRightCol: { offsetHeight: 50 }, top: { offsetTop: 15 } } }) + // A plain element, since el()'s mocked style.setProperty would swallow the display change + const wrapper = document.createElement('div') + wrapper.style.display = 'contents' + layoutRefs.topRightColRef.current.appendChild(wrapper) + appendChild(wrapper, { getClientRects: () => [{}] }) + renderHook(() => useLayoutMeasurements()) + expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-offset-top', '65px') + }) + + test('treats a display: contents wrapper with nothing rendered inside as hidden', () => { + const { layoutRefs } = setup({ refs: { topRightCol: { offsetHeight: 10 }, top: { offsetTop: 15 } } }) + // A plain element, since el()'s mocked style.setProperty would swallow the display change + const wrapper = document.createElement('div') + wrapper.style.display = 'contents' + layoutRefs.topRightColRef.current.appendChild(wrapper) + appendChild(wrapper) + renderHook(() => useLayoutMeasurements()) + expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-offset-top', '15px') + }) + test('measures a top column normally when any child is rendered', () => { const { layoutRefs } = setup({ refs: { topRightCol: { offsetHeight: 50 }, top: { offsetTop: 15 } } }) appendChild(layoutRefs.topRightColRef.current) diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx index e24612d94..2a3bd57e3 100755 --- a/src/App/layout/Layout.jsx +++ b/src/App/layout/Layout.jsx @@ -5,6 +5,7 @@ import { useApp } from '../store/appContext' import { useMap } from '../store/mapContext' import { useLayoutMeasurements } from '../hooks/useLayoutMeasurements' import { useFocusVisible } from '../hooks/useFocusVisible' +import { useApplicationModeFocus } from '../hooks/useApplicationModeFocus.js' import { Logo } from '../components/Logo/Logo' import { Attributions } from '../components/Attributions/Attributions' import { layoutSlots } from '../renderer/slots' @@ -13,28 +14,21 @@ import { HtmlElementHost } from '../renderer/HtmlElementHost' 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[exclusiveControl.length - 1] // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support - if (!top) { - return null - } - const suffix = top.name ? '--' + top.name : '' - return `im-o-app--exclusive-control-${top.pluginId}${suffix}` -} +import { getApplicationModeClass, selectApplicationModes } from '../renderer/applicationModes.js' // 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, exclusiveControl, isFullscreen, openPanels, panelConfig } = useApp() + const appConfig = useConfig() + const { id, mapLabel, mapHintText } = appConfig + const appState = useApp() + const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, isFullscreen, openPanels, panelConfig } = appState const { mapStyle } = useMap() const showModalBackdrop = hasOpenModalPanel(openPanels ?? {}, panelConfig ?? {}, breakpoint) useLayoutMeasurements() useFocusVisible() + useApplicationModeFocus() return (
{ `im-o-app--${interfaceType}`, `im-o-app--${isFullscreen ? 'fullscreen' : 'inline'}`, `im-o-app--${mapStyle?.appColorScheme || preferredColorScheme}-app`, - getExclusiveControlClass(exclusiveControl) + getApplicationModeClass(selectApplicationModes(appState, appConfig)) ].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 ed736b3f2..383f63d91 100755 --- a/src/App/layout/Layout.test.jsx +++ b/src/App/layout/Layout.test.jsx @@ -31,6 +31,7 @@ jest.mock('../store/appContext', () => ({ useApp: jest.fn() })) jest.mock('../store/mapContext', () => ({ useMap: jest.fn() })) jest.mock('../hooks/useLayoutMeasurements', () => ({ useLayoutMeasurements: jest.fn() })) jest.mock('../hooks/useFocusVisible', () => ({ useFocusVisible: jest.fn() })) +jest.mock('../hooks/useApplicationModeFocus.js', () => ({ useApplicationModeFocus: jest.fn() })) describe('Layout', () => { const mockRefs = { @@ -55,7 +56,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - exclusiveControl: [{ pluginId: 'search', name: null }], + applicationModeEntries: [{ id: 'search', include: null, exclude: null }], isFullscreen: false }) useMap.mockReturnValue({ @@ -76,8 +77,7 @@ 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-search') - expect(root.className).not.toContain('im-o-app--exclusive-control-search--') + expect(root.className).toContain('im-o-app--mode-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') @@ -102,36 +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', () => { + test('adds the class for the most recently set active mode only', () => { useApp.mockReturnValueOnce({ breakpoint: 'desktop', interfaceType: 'map', preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }], + applicationModeEntries: [ + { id: 'draw', include: [], exclude: null }, + { id: 'search', include: null, exclude: null } + ], 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') + expect(root).toHaveClass('im-o-app--mode-search') + expect(root).not.toHaveClass('im-o-app--mode-draw') }) - test('adds the class for the most recent claim only', () => { + test('adds no class for a mode the consumer\'s config disables', () => { useApp.mockReturnValueOnce({ breakpoint: 'desktop', interfaceType: 'map', preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - exclusiveControl: [{ pluginId: 'draw', name: 'edit-point' }, { pluginId: 'search', name: null }], + applicationModeEntries: [{ id: 'draw', include: [], exclude: null }], isFullscreen: false }) + useConfig.mockReturnValueOnce({ id: 'myApp', applicationModes: { draw: 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') + expect(document.getElementById('myApp-im-app').className).not.toContain('im-o-app--mode-') }) test('shows the modal backdrop only while a modal-configured panel is open', () => { @@ -141,7 +143,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - exclusiveControl: [], + applicationModeEntries: [], isFullscreen: true, openPanels: { settings: { props: {} } }, panelConfig: { settings: { mobile: { modal: true } } } @@ -158,7 +160,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: false, - exclusiveControl: [], + applicationModeEntries: [], isFullscreen: true }) render() @@ -176,7 +178,7 @@ describe('Layout', () => { preferredColorScheme: 'dark', layoutRefs: mockRefs, isLayoutReady: true, - exclusiveControl: [], + applicationModeEntries: [], isFullscreen: false }) @@ -186,7 +188,7 @@ describe('Layout', () => { expect(root.className).toContain('im-o-app--dark-app') expect(root.className).not.toContain('im-o-app--light-map') expect(root.style.backgroundColor).toBe('') - expect(root.className).not.toContain('exclusive-control') + expect(root.className).not.toContain('im-o-app--mode-') }) test('calls layout measurement and focus visible hooks', () => { diff --git a/src/App/registry/pluginRegistry.js b/src/App/registry/pluginRegistry.js index 2844cc55a..6aa39a670 100755 --- a/src/App/registry/pluginRegistry.js +++ b/src/App/registry/pluginRegistry.js @@ -24,9 +24,7 @@ export function createPluginRegistry ({ registerButton, registerPanel, registerC const { manifest } = plugin const pluginConfig = { - pluginId: plugin.id, - includeModes: plugin.config?.includeModes, - excludeModes: plugin.config?.excludeModes + pluginId: plugin.id } if (manifest.buttons) { diff --git a/src/App/registry/pluginRegistry.test.js b/src/App/registry/pluginRegistry.test.js index 5098e05f0..dfeb0df79 100755 --- a/src/App/registry/pluginRegistry.test.js +++ b/src/App/registry/pluginRegistry.test.js @@ -27,7 +27,7 @@ describe('pluginRegistry', () => { it('registers plugin and pushes to registeredPlugins', () => { const plugin = { id: 'plugin1', - config: { includeModes: ['view'], excludeModes: ['edit'] }, + config: {}, manifest: {} } pluginRegistry.registerPlugin(plugin) @@ -37,7 +37,7 @@ describe('pluginRegistry', () => { it('registers buttons, panels, controls, icons, and shortcuts with pluginConfig info', () => { const plugin = { id: 'plugin2', - config: { includeModes: ['view'], excludeModes: ['edit'] }, + config: {}, manifest: { buttons: { id: 'btn1' }, panels: [{ id: 'panel1' }], @@ -50,9 +50,7 @@ describe('pluginRegistry', () => { pluginRegistry.registerPlugin(plugin) const expectedPluginConfig = { - pluginId: 'plugin2', - includeModes: ['view'], - excludeModes: ['edit'] + pluginId: 'plugin2' } expect(registerButton).toHaveBeenCalledWith({ @@ -74,7 +72,7 @@ describe('pluginRegistry', () => { it('registers nested menuItems for buttons', () => { const plugin = { id: 'plugin-menu', - config: { includeModes: ['mode1'], excludeModes: [] }, + config: {}, manifest: { buttons: [ { @@ -91,9 +89,7 @@ describe('pluginRegistry', () => { pluginRegistry.registerPlugin(plugin) const expectedPluginConfig = { - pluginId: 'plugin-menu', - includeModes: ['mode1'], - excludeModes: [] + pluginId: 'plugin-menu' } // Parent button @@ -113,7 +109,7 @@ describe('pluginRegistry', () => { it('handles single vs array manifests correctly', () => { const plugin = { id: 'plugin3', - config: { includeModes: ['a'], excludeModes: ['b'] }, + config: {}, manifest: { buttons: [{ id: 'b1' }, { id: 'b2' }], panels: { id: 'p1' }, diff --git a/src/App/renderer/HtmlElementHost.jsx b/src/App/renderer/HtmlElementHost.jsx index 27e62e076..9fcc8c4c7 100644 --- a/src/App/renderer/HtmlElementHost.jsx +++ b/src/App/renderer/HtmlElementHost.jsx @@ -1,8 +1,11 @@ // src/App/renderer/HtmlElementHost.jsx import React, { useRef, useLayoutEffect, useMemo } from 'react' import { useApp } from '../store/appContext.js' +import { stringToKebab } from '../../utils/stringToKebab.js' import { Panel } from '../components/Panel/Panel.jsx' -import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, isHiddenByExclusiveControl } from './slotHelpers.js' +import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js' +import { isHiddenByApplicationMode, selectApplicationModes } from './applicationModes.js' +import { useConfig } from '../store/configContext.js' /** * Maps slot names to their corresponding layout refs. @@ -82,20 +85,20 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, * The Panel component stays mounted for the lifetime of the registration. * DOM projection moves it between slots; CSS hides it when closed. */ -const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, allowedModalPanelId, appState }) => { +const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, allowedModalPanelId, appState, appConfig }) => { const panelRootRef = useRef(null) - const { breakpoint, mode, isFullscreen, layoutRefs } = appState + const { breakpoint, isFullscreen, layoutRefs } = appState const bpConfig = config[breakpoint] const targetSlot = bpConfig ? resolveTargetSlot(bpConfig, breakpoint) : null // Same eligibility/modal-exclusivity rules as mapPanels.js (see slotHelpers.js), combined into // one boolean since isVisible alone decides whether useDomProjection shows it. - const isEligible = Boolean(bpConfig && targetSlot && isPanelSlotEligible(config, { targetSlot, mode, isFullscreen })) + const isEligible = Boolean(bpConfig && targetSlot && isPanelSlotEligible(config, { targetSlot, isFullscreen })) const isAllowedModal = !bpConfig?.modal || panelId === allowedModalPanelId - // Modal panels are never hidden by exclusive control, so focus is never trapped in a hidden one - const isExclusiveHidden = !bpConfig?.modal && isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [panelId], pluginId: config.pluginId }) - const isVisible = isOpen && isEligible && isAllowedModal && !isExclusiveHidden + // Modal panels are never hidden by an application mode, so focus is never trapped in a hidden one + const isModeHidden = !bpConfig?.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [panelId]) + const isVisible = isOpen && isEligible && isAllowedModal && !isModeHidden useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint) @@ -117,14 +120,14 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, * Persistent wrapper for a consumer HTML control. * The control stays mounted for the lifetime of the registration. */ -const PersistentControl = ({ control, appState }) => { +const PersistentControl = ({ control, appState, appConfig }) => { const wrapperRef = useRef(null) - const { breakpoint, mode, isFullscreen, layoutRefs, openPanels } = appState + const { breakpoint, isFullscreen, layoutRefs, openPanels } = appState const bpConfig = control[breakpoint] - // Exclusive control hides it by the same display toggle, so it stays mounted either way - const isVisible = isControlVisible(control, { breakpoint, mode, isFullscreen }) && - !isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [control.id], pluginId: control.pluginId }) + // An application mode hides it by the same display toggle, so it stays mounted either way + const isVisible = isControlVisible(control, { breakpoint, isFullscreen }) && + !isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [control.id]) const targetSlot = bpConfig?.slot || null // A control targeting a panel's body (`-panel`) needs its DOM anchor re-resolved @@ -138,7 +141,7 @@ const PersistentControl = ({ control, appState }) => { return (
@@ -152,6 +155,7 @@ const PersistentControl = ({ control, appState }) => { */ export const HtmlElementHost = () => { const appState = useApp() + const appConfig = useConfig() const { panelConfig = {}, controlConfig = {}, openPanels = {}, breakpoint } = appState // Find consumer HTML panels @@ -188,6 +192,7 @@ export const HtmlElementHost = () => { focusOnOpen={openPanels[panelId]?.focusOnOpen} allowedModalPanelId={allowedModalPanelId} appState={appState} + appConfig={appConfig} /> ))} {htmlControls.map(control => ( @@ -195,6 +200,7 @@ export const HtmlElementHost = () => { key={control.id} control={control} appState={appState} + appConfig={appConfig} /> ))} diff --git a/src/App/renderer/HtmlElementHost.test.jsx b/src/App/renderer/HtmlElementHost.test.jsx index e7315a71b..df39d66d1 100644 --- a/src/App/renderer/HtmlElementHost.test.jsx +++ b/src/App/renderer/HtmlElementHost.test.jsx @@ -58,7 +58,6 @@ describe('HtmlElementHost', () => { const mockApp = (overrides = {}) => { const state = { breakpoint: 'desktop', - mode: 'view', isFullscreen: false, panelConfig: {}, controlConfig: {}, @@ -120,14 +119,6 @@ describe('HtmlElementHost', () => { expect(getByTestId('panel-p1').dataset.open).toBe('false') }) - it('hides panel when mode is not allowed', () => { - const { getByTestId } = renderWithSlots({ - panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'inset' }, includeModes: ['edit'] } }, - openPanels: { p1: { props: {} } } - }) - expect(getByTestId('panel-p1').style.display).toBe('none') - }) - it('hides panel with inline:false when not fullscreen', () => { const { getByTestId } = renderWithSlots({ panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'left-top' }, inline: false } }, @@ -182,30 +173,31 @@ describe('HtmlElementHost', () => { }) const control = container.querySelector('[data-slot="left-top"] .im-c-control') expect(control).toBeTruthy() + expect(control).toHaveClass('im-c-control--c1') expect(control.innerHTML).toBe('') }) - it('hides (keeps mounted) a consumer control that exclusive control doesn\'t keep', () => { + it('hides (keeps mounted) a consumer control that an application mode doesn\'t show', () => { const controlConfig = { c1: { id: 'c1', html: '

Hi

', desktop: { slot: 'left-top' } } } - const hidden = renderWithSlots({ controlConfig, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['c2'] }] }) + const hidden = renderWithSlots({ controlConfig, applicationModeEntries: [{ id: 'draw', include: ['c2'], exclude: null }] }) expect(hidden.container.querySelector('.im-c-control').style.display).toBe('none') - const kept = renderWithSlots({ controlConfig, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['c1'] }] }) + const kept = renderWithSlots({ controlConfig, applicationModeEntries: [{ id: 'draw', include: ['c1'], exclude: null }] }) expect(kept.container.querySelector('[data-slot="left-top"] .im-c-control').style.display).toBe('') }) - it('hides an open non-modal consumer panel that exclusive control doesn\'t keep, but never a modal one', () => { - const exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + it('hides an open non-modal consumer panel that an application mode doesn\'t show, but never a modal one', () => { + const applicationModeEntries = [{ id: 'draw', include: [], exclude: null }] const nonModal = renderWithSlots({ panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'left-top' } } }, openPanels: { p1: { props: {} } }, - exclusiveControl + applicationModeEntries }) expect(nonModal.getByTestId('panel-p1').style.display).toBe('none') nonModal.unmount() const modal = renderWithSlots({ panelConfig: { p1: { html: '

Hi

', label: 'Test', desktop: { slot: 'side', modal: true } } }, openPanels: { p1: { props: {} } }, - exclusiveControl + applicationModeEntries }) expect(modal.getByTestId('panel-p1').style.display).toBe('') }) @@ -298,7 +290,6 @@ describe('HtmlElementHost', () => { // Close the panel useApp.mockReturnValue({ breakpoint: 'desktop', - mode: 'view', isFullscreen: false, panelConfig, controlConfig: {}, diff --git a/src/App/renderer/PluginInits.jsx b/src/App/renderer/PluginInits.jsx index 356e5ac55..3a203aaff 100755 --- a/src/App/renderer/PluginInits.jsx +++ b/src/App/renderer/PluginInits.jsx @@ -4,14 +4,13 @@ import { withPluginContexts } from './pluginWrapper.js' import { withPluginApiContexts, usePluginApiState } from './pluginApiWrapper.js' import { useInterfaceAPI } from '../hooks/useInterfaceAPI.js' import { useHintsAPI } from '../hooks/useHintsAPI.js' -import { useApp } from '../store/appContext.js' import { useConfig } from '../store/configContext.js' import { useEvaluateProp } from '../hooks/useEvaluateProp.js' import { useButtonStateEvaluator } from '../hooks/useButtonStateEvaluator.js' import { useContinueEnabledEvaluator } from '../hooks/useContinueEnabledEvaluator.js' // Create a component for each plugin to handle its hooks properly -const PluginInit = ({ plugin, mode }) => { +const PluginInit = ({ plugin }) => { const stateRef = usePluginApiState(plugin.id) // Wrap all API functions @@ -30,12 +29,7 @@ const PluginInit = ({ plugin, mode }) => { const { InitComponent } = plugin const { api, ...pluginConfig } = plugin?.config || {} - // Check if valid mode for plugin - const { includeModes, excludeModes } = plugin.config || {} - const inModeWhitelist = includeModes?.includes(mode) ?? true - const inExcludeModes = excludeModes?.includes(mode) ?? false - - if (!inModeWhitelist || inExcludeModes || !InitComponent) { + if (!InitComponent) { return null } @@ -48,7 +42,6 @@ const PluginInit = ({ plugin, mode }) => { } export const PluginInits = () => { - const { mode } = useApp() const { pluginRegistry } = useConfig() // Add button, panel and control API methods (Needs to be top-level) @@ -68,7 +61,6 @@ export const PluginInits = () => { ))} diff --git a/src/App/renderer/PluginInits.test.jsx b/src/App/renderer/PluginInits.test.jsx index c60dfca8e..52208c609 100755 --- a/src/App/renderer/PluginInits.test.jsx +++ b/src/App/renderer/PluginInits.test.jsx @@ -144,17 +144,17 @@ describe('PluginInits', () => { expect(container.textContent).toBe('') }) - it('respects includeModes and excludeModes for InitComponent rendering', () => { - const InitComp = () =>
+ it('always renders a plugin\'s InitComponent (application modes hide UI, they never stop plugins initialising)', () => { + const InitComp = () =>
const plugin = { - id: 'pluginMode', + id: 'pluginAlways', _originalPlugin: {}, - config: { includeModes: ['edit'], excludeModes: ['view'], api: {} }, + config: { api: {} }, InitComponent: InitComp } pluginRegistryMock.registeredPlugins.push(plugin) - const { container } = render() - expect(container.textContent).toBe('') + const { getByTestId } = render() + expect(getByTestId('initAlways')).toBeInTheDocument() }) }) diff --git a/src/App/renderer/applicationModes.js b/src/App/renderer/applicationModes.js new file mode 100644 index 000000000..38483861c --- /dev/null +++ b/src/App/renderer/applicationModes.js @@ -0,0 +1,65 @@ +// src/App/renderer/applicationModes.js + +/** + * The two halves of the application mode data the helpers below need: the stack of modes set at + * runtime (app state) and the consumer's fixed applicationModes option (app config). + * + * @param {{ applicationModeEntries?: Array }} appState + * @param {{ applicationModes?: Object }} [appConfig] + * @returns {{ entries: Array<{ id: string, include: string[] | null, exclude: string[] | null }>, config: Object }} + */ +export const selectApplicationModes = (appState, appConfig) => ({ + entries: appState?.applicationModeEntries ?? [], + config: appConfig?.applicationModes ?? {} +}) + +/** + * The active application modes, least recently set first: every mode on the stack, unless the + * consumer's config disables it (false). + * + * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. + * @returns {Array<{ id: string, include: string[] | null, exclude: string[] | null }>} + */ +export const getActiveApplicationModes = ({ entries, config }) => + entries.filter(mode => config[mode.id] !== false) + +/** + * The root class for the most recently set active mode, or null when no mode is active. + * + * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. + * @returns {string | null} + */ +export const getApplicationModeClass = (modes) => { + const activeModes = getActiveApplicationModes(modes) + const topMode = activeModes[activeModes.length - 1] // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support + return topMode ? `im-o-app--mode-${topMode.id}` : null +} + +const includesAny = (list, ids) => !!list && ids.some(id => list.includes(id)) + +/** + * Whether one active mode shows an item. The consumer's config for the mode adjusts what the mode + * was set with, so it's checked first and wins for the items it names: exclude removes, include + * appends. Every other item follows the mode's own lists: with include, only listed items show; + * with exclude, listed items are hidden. + */ +const isVisibleInMode = (mode, config, ids) => { + if (includesAny(config?.exclude, ids)) { + return false + } + if (includesAny(config?.include, ids)) { + return true + } + return (!mode.include || includesAny(mode.include, ids)) && !includesAny(mode.exclude, ids) +} + +/** + * Whether the active application modes hide an item: true when any active mode doesn't show it. + * Items are hidden with CSS, never unmounted, so their state survives. + * + * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. + * @param {string[]} ids - The item's id(s); one id can name a button and its panel. + * @returns {boolean} + */ +export const isHiddenByApplicationMode = (modes, ids) => + getActiveApplicationModes(modes).some(mode => !isVisibleInMode(mode, modes.config[mode.id], ids)) diff --git a/src/App/renderer/applicationModes.test.js b/src/App/renderer/applicationModes.test.js new file mode 100644 index 000000000..7e35de79c --- /dev/null +++ b/src/App/renderer/applicationModes.test.js @@ -0,0 +1,111 @@ +import { + selectApplicationModes, + getActiveApplicationModes, + getApplicationModeClass, + isHiddenByApplicationMode +} from './applicationModes.js' + +const mode = (id, lists = {}) => ({ id, include: null, exclude: null, ...lists }) +// The stack of modes set at runtime (app state) and the consumer's applicationModes option (app config) +const modesWith = (entries, config = {}) => ({ entries, config }) + +describe('selectApplicationModes', () => { + it('pairs the stack from app state with the applicationModes option from app config', () => { + const entries = [mode('draw')] + expect(selectApplicationModes({ applicationModeEntries: entries }, { applicationModes: { draw: false } })) + .toEqual({ entries, config: { draw: false } }) + }) + + it('defaults both halves when missing', () => { + expect(selectApplicationModes({}, undefined)).toEqual({ entries: [], config: {} }) + }) +}) + +describe('getActiveApplicationModes', () => { + it('lists the modes on the stack, least recently set first', () => { + expect(getActiveApplicationModes(modesWith([mode('draw'), mode('search')])).map(active => active.id)).toEqual(['draw', 'search']) + }) + + it('leaves out a mode the consumer\'s config disables', () => { + expect(getActiveApplicationModes(modesWith([mode('draw'), mode('search')], { draw: false })).map(active => active.id)).toEqual(['search']) + }) +}) + +describe('getApplicationModeClass', () => { + it('uses the most recently set active mode', () => { + expect(getApplicationModeClass(modesWith([mode('draw'), mode('search')]))).toBe('im-o-app--mode-search') + }) + + it('is null with no active mode', () => { + expect(getApplicationModeClass(modesWith([]))).toBeNull() + expect(getApplicationModeClass(modesWith([mode('draw')], { draw: false }))).toBeNull() + }) +}) + +describe('isHiddenByApplicationMode', () => { + it('hides nothing with no active mode, or a mode without lists', () => { + expect(isHiddenByApplicationMode(modesWith([]), ['mapKey'])).toBe(false) + expect(isHiddenByApplicationMode(modesWith([mode('search')]), ['mapKey'])).toBe(false) + }) + + it('shows only included items when a mode has an include list', () => { + const modes = modesWith([mode('draw', { include: ['mapStyles', 'drawUndo'] })]) + expect(isHiddenByApplicationMode(modes, ['mapStyles'])).toBe(false) + expect(isHiddenByApplicationMode(modes, ['drawUndo'])).toBe(false) + expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(true) + }) + + it('shows an item if any of its ids is included', () => { + const modes = modesWith([mode('draw', { include: ['mapStyles'] })]) + expect(isHiddenByApplicationMode(modes, ['zoomIn', 'mapStyles'])).toBe(false) + }) + + it('hides excluded items and nothing else when a mode has only an exclude list', () => { + const modes = modesWith([mode('focus', { exclude: ['search'] })]) + expect(isHiddenByApplicationMode(modes, ['search'])).toBe(true) + expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(false) + }) + + it('lets a mode\'s exclude beat its include', () => { + const modes = modesWith([mode('review', { include: ['layers'], exclude: ['layers'] })]) + expect(isHiddenByApplicationMode(modes, ['layers'])).toBe(true) + }) + + describe('the consumer\'s config adjusts the mode', () => { + const draw = mode('draw', { include: ['mapStyles', 'scaleBar'] }) + + it('appends items with include', () => { + expect(isHiddenByApplicationMode(modesWith([draw], { draw: { include: ['search'] } }), ['search'])).toBe(false) + }) + + it('removes items with exclude', () => { + expect(isHiddenByApplicationMode(modesWith([draw], { draw: { exclude: ['scaleBar'] } }), ['scaleBar'])).toBe(true) + }) + + it('brings back an item the mode excluded', () => { + const focus = mode('focus', { exclude: ['search'] }) + expect(isHiddenByApplicationMode(modesWith([focus], { focus: { include: ['search'] } }), ['search'])).toBe(false) + }) + + it('leaves items it doesn\'t name to the mode', () => { + const modes = modesWith([draw], { draw: { include: ['search'] } }) + expect(isHiddenByApplicationMode(modes, ['mapStyles'])).toBe(false) + expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(true) + }) + + it('only applies to its own mode', () => { + expect(isHiddenByApplicationMode(modesWith([draw], { review: { include: ['search'] } }), ['search'])).toBe(true) + }) + + it('ignores a mode it disables', () => { + expect(isHiddenByApplicationMode(modesWith([draw], { draw: false }), ['mapKey'])).toBe(false) + }) + }) + + it('hides an item when any active mode hides it', () => { + const modes = modesWith([mode('draw', { include: ['mapStyles', 'search'] }), mode('focus', { exclude: ['mapStyles'] })]) + expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(true) + expect(isHiddenByApplicationMode(modes, ['mapStyles'])).toBe(true) + expect(isHiddenByApplicationMode(modes, ['search'])).toBe(false) + }) +}) diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index a96cb57b0..74f37d703 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -5,10 +5,10 @@ import { groupByKey } from './groupByKey.js' import { orderItems } from './orderItems.js' import { classifyPanel, getPanelRole } from '../../utils/getPanelRole.js' import { logger } from '../../services/logger.js' -import { isHiddenByExclusiveControl } from './slotHelpers.js' +import { isHiddenByApplicationMode, selectApplicationModes } from './applicationModes.js' function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { - const { breakpoint, mode } = appState + const { breakpoint } = appState if (!buttonConfig) { return [] } @@ -25,12 +25,6 @@ function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { if (typeof config.excludeWhen === 'function' && evaluateProp(config.excludeWhen, config.pluginId)) { return false } - if (config.includeModes && !config.includeModes?.includes(mode)) { - return false - } - if (config.excludeModes?.includes(mode)) { - return false - } // Skip buttons marked as inline:false when not in fullscreen mode if (config.inline === false && !appState.isFullscreen) { @@ -96,11 +90,11 @@ function applySlotExclusivity (matching, appState) { return matching.filter(([_, config]) => config.pluginId === exclusivePluginId) } -// Hidden via toggleButtonState/hiddenWhen, or by another plugin's exclusive control. Either way the -// button stays mounted (display: none), so its refs, focus-return target and state survive. -const isButtonHidden = (buttonId, config, appState) => +// Hidden via toggleButtonState/hiddenWhen, or by an application mode. Either way the button stays +// mounted (display: none), so its refs, focus-return target and state survive. +const isButtonHidden = (buttonId, appState, appConfig) => appState.hiddenButtons.has(buttonId) || - isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [buttonId], pluginId: config.pluginId }) + isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [buttonId]) /** * Builds the props for a . isHidden/variant are included here (not just derived @@ -114,7 +108,7 @@ const slotButtonProps = ({ buttonId, config, appState, appConfig, evaluateProp } appState, appConfig, evaluateProp, - isHidden: isButtonHidden(buttonId, config, appState), + isHidden: isButtonHidden(buttonId, appState, appConfig), variant: config.variant }) @@ -139,7 +133,7 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) { href={evaluateProp(config.href, config.pluginId)} showLabel={bpConfig.showLabel ?? true} isDisabled={appState.disabledButtons.has(buttonId)} - isHidden={isButtonHidden(buttonId, config, appState)} + isHidden={isButtonHidden(buttonId, appState, appConfig)} isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined} isExpanded={(config.isExpanded !== undefined || config.expandedWhen) ? appState.expandedButtons.has(buttonId) : undefined} isPanelOpen={isPanelOpen} @@ -215,7 +209,7 @@ function buildGroupItem (key, members, ctx) { aria-label={firstConfig.group.label} className='im-c-button-group' // Hidden too once every member is, so an empty group doesn't hold a gap in its slot - style={members.every(([buttonId, config]) => isButtonHidden(buttonId, config, ctx.appState)) ? { display: 'none' } : undefined} + style={members.every(([buttonId]) => isButtonHidden(buttonId, ctx.appState, ctx.appConfig)) ? { display: 'none' } : undefined} > {sorted.map(({ buttonId, config }) => )} diff --git a/src/App/renderer/mapButtons.test.js b/src/App/renderer/mapButtons.test.js index da8ddb043..c40b7e478 100755 --- a/src/App/renderer/mapButtons.test.js +++ b/src/App/renderer/mapButtons.test.js @@ -17,8 +17,7 @@ describe('mapButtons module', () => { const baseBtn = { iconId: 'i1', label: 'Btn', - desktop: { slot: 'header', order: 1, showLabel: true }, - includeModes: ['view'] + desktop: { slot: 'header', order: 1, showLabel: true } } let appState @@ -31,7 +30,6 @@ describe('mapButtons module', () => { jest.clearAllMocks() appState = { breakpoint: 'desktop', - mode: 'view', isFullscreen: true, openPanels: {}, dispatch: jest.fn(), @@ -57,8 +55,6 @@ describe('mapButtons module', () => { it('returns empty array when buttonConfig is null', () => testFilter(null, 0)) it('filters out buttons not in the correct slot', () => testFilter({ b1: { ...baseBtn, desktop: { slot: 'sidebar' } } }, 0)) - it('filters out buttons excluded by includeModes', () => testFilter({ b1: { ...baseBtn, includeModes: ['edit'] } }, 0)) - it('filters out buttons excluded by excludeModes', () => testFilter({ b1: { ...baseBtn, excludeModes: ['view'] } }, 0)) it('returns all valid matching buttons', () => testFilter({ b1: baseBtn, b2: baseBtn }, 2)) it('excludes buttons dynamically via excludeWhen', () => { @@ -158,12 +154,12 @@ describe('mapButtons module', () => { }) }) - it('hides (keeps mounted) a button another plugin\'s exclusive control doesn\'t keep', () => { - const claim = (keep) => [{ pluginId: 'draw', name: null, keep }] - expect(render(baseBtn, { ...appState, exclusiveControl: claim(['other']) }).props.isHidden).toBe(true) - expect(render(baseBtn, { ...appState, exclusiveControl: claim(['id']) }).props.isHidden).toBe(false) - expect(render({ ...baseBtn, pluginId: 'draw' }, { ...appState, exclusiveControl: claim([]) }).props.isHidden).toBe(false) - expect(render(baseBtn, { ...appState, exclusiveControl: [{ pluginId: 'search', name: null, keep: null }] }).props.isHidden).toBe(false) + it('hides (keeps mounted) a button an application mode doesn\'t show', () => { + const withMode = (include) => ({ ...appState, applicationModeEntries: [{ id: 'draw', include, exclude: null }] }) + expect(render(baseBtn, withMode(['other'])).props.isHidden).toBe(true) + expect(render(baseBtn, withMode(['id'])).props.isHidden).toBe(false) + const searchMode = { ...appState, applicationModeEntries: [{ id: 'search', include: null, exclude: null }] } + expect(render(baseBtn, searchMode).props.isHidden).toBe(false) }) it('renders correct state flags for disabled, hidden, pressed and expanded buttons', () => { @@ -279,15 +275,15 @@ describe('mapButtons module', () => { expect(result[0].element.props['aria-label']).toBe('Group 1') }) - it('hides a group only once every member is hidden, e.g. by exclusive control', () => { + it('hides a group only once every member is hidden, e.g. by an application mode', () => { appState.buttonConfig = ({ b1: { ...baseBtn, group: { label: 'Group 1' } }, b2: { ...baseBtn, group: { label: 'Group 1' } } }) expect(map()[0].element.props.style).toBeUndefined() - appState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['b2'] }] + appState.applicationModeEntries = [{ id: 'draw', include: ['b2'], exclude: null }] expect(map()[0].element.props.style).toBeUndefined() - appState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + appState.applicationModeEntries = [{ id: 'draw', include: [], exclude: null }] expect(map()[0].element.props.style).toEqual({ display: 'none' }) }) diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js index 431bd2459..5bb7e5ce2 100755 --- a/src/App/renderer/mapControls.js +++ b/src/App/renderer/mapControls.js @@ -2,15 +2,16 @@ import React from 'react' import { withPluginContexts } from './pluginWrapper.js' import { allowedSlots } from './slots.js' -import { isConsumerHtml, isHiddenByExclusiveControl } from './slotHelpers.js' +import { isConsumerHtml } from './slotHelpers.js' +import { isHiddenByApplicationMode, selectApplicationModes } from './applicationModes.js' import { stringToKebab } from '../../utils/stringToKebab.js' /** * Map controls for a given slot and app state. * Returns an array of control descriptors. */ -export function mapControls ({ slot, appState, evaluateProp }) { - const { breakpoint, mode, pluginRegistry, controlConfig } = appState +export function mapControls ({ slot, appState, appConfig, evaluateProp }) { + const { breakpoint, pluginRegistry, controlConfig } = appState return Object.values(controlConfig) .filter(control => { // NOSONAR, extracting to a helper wouldn't necessarily improve readability @@ -32,16 +33,14 @@ export function mapControls ({ slot, appState, evaluateProp }) { // A control may also target a panel's body directly via the `-panel` // slot convention (mirrors the `-button` convention panels already use). const slotAllowed = allowedSlots.control.includes(bpConfig.slot) || bpConfig.slot?.endsWith('-panel') - const inModeWhitelist = control.includeModes?.includes(mode) ?? true - const inExcludeModes = control.excludeModes?.includes(mode) ?? false // Skip controls marked as inline:false when not in fullscreen mode if (control.inline === false && !appState.isFullscreen) { return false } - // Only include controls allowed in slot and current mode - return inModeWhitelist && !inExcludeModes && bpConfig.slot === slot && slotAllowed + // Only include controls allowed in this slot + return bpConfig.slot === slot && slotAllowed }) .map(control => { // Detect plugin owning this control @@ -50,7 +49,7 @@ export function mapControls ({ slot, appState, evaluateProp }) { ) const pluginId = plugin?.id - const isHidden = isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [control.id], pluginId }) + const isHidden = isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [control.id]) let element @@ -58,7 +57,7 @@ export function mapControls ({ slot, appState, evaluateProp }) { if (control.html) { element = (
{ jest.clearAllMocks() defaultAppState = { breakpoint: 'desktop', - mode: 'view', isFullscreen: true, controlConfig: {}, pluginRegistry: { @@ -40,8 +39,8 @@ describe('mapControls', () => { it('filters controls by slot and allowedSlots', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['view'] }, - ctrl2: { id: 'ctrl2', desktop: { slot: 'footer', order: 2 }, includeModes: ['view'] } // filtered out + ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 } }, + ctrl2: { id: 'ctrl2', desktop: { slot: 'footer', order: 2 } } // filtered out }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result.map(c => c.id)).toEqual(['ctrl1']) @@ -49,15 +48,7 @@ describe('mapControls', () => { it('filters out controls missing breakpoint config', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', mobile: { slot: 'header', order: 1 }, includeModes: ['view'] } - }) - const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) - expect(result).toEqual([]) - }) - - it('filters by includeModes whitelist', () => { - defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['edit'] } + ctrl1: { id: 'ctrl1', mobile: { slot: 'header', order: 1 } } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result).toEqual([]) @@ -87,18 +78,10 @@ describe('mapControls', () => { expect(result.map(c => c.id)).toEqual(['ctrl1']) }) - it('filters by excludeModes', () => { - defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, excludeModes: ['view'] } - }) - const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) - expect(result).toEqual([]) - }) - it('maps plugin controls to wrapped component with correct order', () => { const renderFn = () =>
Control
defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 5 }, render: renderFn, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 5 }, render: renderFn } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].order).toBe(5) @@ -110,7 +93,7 @@ describe('mapControls', () => { it('falls back to order 0 if order is missing', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header' }, render: () =>
, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'header' }, render: () =>
} }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].order).toBe(0) @@ -118,48 +101,56 @@ describe('mapControls', () => { it('renders plugin HTML controls with dangerouslySetInnerHTML', () => { defaultAppState.controlConfig = ({ - ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '

Hi

' } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].element.props.dangerouslySetInnerHTML).toEqual({ __html: '

Hi

' }) }) - it('hides an HTML control (keeping it mounted) when exclusive control doesn\'t keep it', () => { + it('gives an HTML control a kebab-cased id modifier, like button and control wrappers', () => { + defaultAppState.controlConfig = ({ + myHtmlCtrl: { id: 'myHtmlCtrl', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '

Hi

' } + }) + const [item] = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) + expect(item.element.props.className).toBe('im-c-control im-c-control--my-html-ctrl') + }) + + it('hides an HTML control (keeping it mounted) when an application mode doesn\'t show it', () => { defaultAppState.controlConfig = ({ - ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '

Hi

' } }) const style = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element.props.style expect(style()).toBeUndefined() - defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + defaultAppState.applicationModeEntries = [{ id: 'draw', include: [], exclude: null }] expect(style()).toEqual({ display: 'none' }) - defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['ctrlHtml'] }] + defaultAppState.applicationModeEntries = [{ id: 'draw', include: ['ctrlHtml'], exclude: null }] expect(style()).toBeUndefined() }) it('gives a plugin control\'s wrapper a kebab-cased id modifier, like button wrappers', () => { defaultAppState.controlConfig = ({ - scaleBar: { id: 'scaleBar', desktop: { slot: 'header' }, render: () => null, includeModes: ['view'] } + scaleBar: { id: 'scaleBar', desktop: { slot: 'header' }, render: () => null } }) const [item] = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(item.element.props.className).toBe('im-c-control-wrapper im-c-control-wrapper--scale-bar') }) - it('always wraps a plugin control, switching the wrapper to display: none when exclusive control hides it', () => { + it('always wraps a plugin control, switching the wrapper to display: none when an application mode hides it', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header' }, render: () => null, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'header' }, render: () => null } }) const wrapper = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper im-c-control-wrapper--ctrl1', style: { display: 'contents' } }) - defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] + defaultAppState.applicationModeEntries = [{ id: 'draw', include: ['mapStyles'], exclude: null }] expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper im-c-control-wrapper--ctrl1 im-c-control-wrapper--hidden', style: { display: 'none' } }) - // The claiming plugin's own controls stay - defaultAppState.exclusiveControl = [{ pluginId: 'plugin1', name: null, keep: [] }] + // Listed controls stay + defaultAppState.applicationModeEntries = [{ id: 'plugin1-mode', include: ['ctrl1'], exclude: null }] expect(wrapper().props.style).toEqual({ display: 'contents' }) }) it('filters out consumer HTML controls (handled by HtmlElementHost)', () => { defaultAppState.controlConfig = ({ - ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: '

Hi

' } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result).toEqual([]) @@ -167,7 +158,7 @@ describe('mapControls', () => { it('handles plugin-less controls gracefully', () => { defaultAppState.controlConfig = ({ - ctrl2: { id: 'ctrl2', desktop: { slot: 'header' }, render: () =>
, includeModes: ['view'] } + ctrl2: { id: 'ctrl2', desktop: { slot: 'header' }, render: () =>
} }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].element).toBeDefined() @@ -176,7 +167,7 @@ describe('mapControls', () => { it('filters out controls with inline:false when not in fullscreen', () => { defaultAppState.isFullscreen = false defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['view'], inline: false } + ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, inline: false } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result).toEqual([]) @@ -185,7 +176,7 @@ describe('mapControls', () => { it('includes controls with inline:false when in fullscreen', () => { defaultAppState.isFullscreen = true defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['view'], inline: false } + ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, inline: false } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result.map(c => c.id)).toEqual(['ctrl1']) @@ -194,7 +185,7 @@ describe('mapControls', () => { it('includes controls without inline property regardless of fullscreen state', () => { defaultAppState.isFullscreen = false defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 } } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result.map(c => c.id)).toEqual(['ctrl1']) @@ -202,7 +193,7 @@ describe('mapControls', () => { it('matches a control targeting a panel-body slot via the -panel convention', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', order: 1 }, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', order: 1 } } }) const result = mapControls({ slot: 'map-styles-panel', appState: defaultAppState, evaluateProp: (p) => p }) expect(result.map(c => c.id)).toEqual(['ctrl1']) @@ -210,7 +201,7 @@ describe('mapControls', () => { it('does not match a panel-body-targeting control against an unrelated slot', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', order: 1 }, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', order: 1 } } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result).toEqual([]) @@ -218,7 +209,7 @@ describe('mapControls', () => { it('passes through the tab field from the breakpoint config', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', tab: 'Styles' }, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', tab: 'Styles' } } }) const result = mapControls({ slot: 'map-styles-panel', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].tab).toBe('Styles') @@ -226,7 +217,7 @@ describe('mapControls', () => { it('leaves tab undefined when not set on the breakpoint config', () => { defaultAppState.controlConfig = ({ - ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['view'] } + ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 } } }) const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p }) expect(result[0].tab).toBeUndefined() diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js index 98cd561a1..8b34c4579 100755 --- a/src/App/renderer/mapPanels.js +++ b/src/App/renderer/mapPanels.js @@ -2,7 +2,8 @@ import React from 'react' import { withPluginContexts } from './pluginWrapper.js' import { Panel } from '../components/Panel/Panel.jsx' -import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, isHiddenByExclusiveControl } from './slotHelpers.js' +import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js' +import { isHiddenByApplicationMode, selectApplicationModes } from './applicationModes.js' import { mapControls } from './mapControls.js' import { orderItems } from './orderItems.js' import { groupIntoTabs } from './groupIntoTabs.js' @@ -17,7 +18,7 @@ import { logger } from '../../services/logger.js' */ // Consumer HTML panels are managed by HtmlElementHost; the rest need a breakpoint config and to // be eligible for this slot. Returns null (skip) or the pieces the caller needs. -const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, mode, isFullscreen }) => { +const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, isFullscreen }) => { const config = panelConfig[panelId] if (!config || isConsumerHtml(config)) { return null @@ -27,7 +28,7 @@ const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, mode, return null } const targetSlot = resolveTargetSlot(bpConfig, breakpoint) - if (!isPanelSlotEligible(config, { targetSlot, slot, mode, isFullscreen })) { + if (!isPanelSlotEligible(config, { targetSlot, slot, isFullscreen })) { return null } return { config, bpConfig } @@ -37,14 +38,14 @@ const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, mode, const resolveIsOpen = (openEntry, bpConfig, panelId, allowedModalPanelId) => Boolean(openEntry) && (!bpConfig.modal || panelId === allowedModalPanelId) -export function mapPanels ({ slot, appState, evaluateProp }) { - const { breakpoint, pluginRegistry, panelConfig, mode, openPanels } = appState +export function mapPanels ({ slot, appState, appConfig, evaluateProp }) { + const { breakpoint, pluginRegistry, panelConfig, openPanels } = appState // Only the most-recently-opened modal panel is ever actually shown — see isOpen below. const allowedModalPanelId = getAllowedModalPanelId(openPanels, panelConfig, breakpoint) return Object.keys(panelConfig).map((panelId) => { - const eligible = getEligiblePanelConfig(panelId, panelConfig, breakpoint, { slot, mode, isFullscreen: appState.isFullscreen }) + const eligible = getEligiblePanelConfig(panelId, panelConfig, breakpoint, { slot, isFullscreen: appState.isFullscreen }) if (!eligible) { return null } @@ -57,8 +58,8 @@ export function mapPanels ({ slot, appState, evaluateProp }) { const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId) const pluginId = plugin?.id - // Modal panels are never hidden by exclusive control, so focus is never trapped in a hidden one - const isHidden = !bpConfig.modal && isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [panelId], pluginId }) + // Modal panels are never hidden by an application mode, so focus is never trapped in a hidden one + const isHidden = !bpConfig.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [panelId]) const html = pluginId ? evaluateProp(config.html, pluginId) : config.html const label = evaluateProp(config.label, pluginId) @@ -76,7 +77,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) { focusOnOpen={focusOnOpen} isOpen={isOpen} isHidden={isHidden} - {...(isOpen ? buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) : {})} + {...(isOpen ? buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, appConfig, evaluateProp }) : {})} label={label} html={html} /> @@ -96,10 +97,11 @@ export function mapPanels ({ slot, appState, evaluateProp }) { * @returns {{ items?: object[], tabs?: object[] }} spread directly onto `` — exactly one * of `items`/`tabs` is set (or neither, for a static-html panel). */ -function buildPanelBody ({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) { +function buildPanelBody ({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, appConfig, evaluateProp }) { const injectedItems = mapControls({ slot: `${stringToKebab(panelId)}-panel`, appState, + appConfig, evaluateProp }) diff --git a/src/App/renderer/mapPanels.test.js b/src/App/renderer/mapPanels.test.js index ad1d2dfb7..5663d69c8 100755 --- a/src/App/renderer/mapPanels.test.js +++ b/src/App/renderer/mapPanels.test.js @@ -16,7 +16,6 @@ jest.mock('./slots.js', () => ({ allowedSlots: { panel: ['header', 'modal', 'lef describe('mapPanels', () => { const baseConfig = { desktop: { slot: 'header', order: 1 }, - includeModes: ['view'], pluginId: 'plug1' } @@ -31,7 +30,6 @@ describe('mapPanels', () => { registeredPlugins.length = 0 defaultAppState = { breakpoint: 'desktop', - mode: 'view', isFullscreen: true, openPanels: { p1: { props: { foo: 'bar' } } }, panelConfig: { p1: baseConfig }, @@ -77,56 +75,32 @@ describe('mapPanels', () => { }) it('skips panel if slot does not match requested slot', () => { - defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } }) + defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' } } }) const state = { ...defaultAppState, openPanels: { p1: { props: {} } } } expect(map(state, 'sidebar')).toEqual([]) }) - it('skips panel if mode does not match includeModes/excludeModes or slot invalid', () => { + it('skips panel if its slot is invalid or it belongs to another slot', () => { defaultAppState.panelConfig = ({ - p1: { desktop: { slot: 'invalid' }, includeModes: ['view'] }, - p2: { desktop: { slot: 'header' }, includeModes: ['edit'] }, - p3: { desktop: { slot: 'header' }, excludeModes: ['view'] }, - p4: { desktop: { modal: true }, includeModes: ['view'] } + p1: { desktop: { slot: 'invalid' } }, + p4: { desktop: { modal: true } } }) expect(map()).toEqual([]) }) - it('skips panel if mode is not allowed (isModeAllowed returns false)', () => { - // 1. Define config that only allows 'edit' mode - const panelConfig = { - p1: { - desktop: { slot: 'header' }, - includeModes: ['edit'] - } - } - - // 2. Mock appState with 'view' mode - const state = { - ...defaultAppState, - mode: 'view', - panelConfig, - openPanels: { p1: { props: {} } } - } - - // 3. Verify it's filtered out even though it's the right slot - const result = map(state, 'header') - expect(result).toEqual([]) - }) - - it('hides a non-modal panel exclusive control doesn\'t keep, but never a modal one', () => { - const exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['other'] }] - expect(map({ ...defaultAppState, exclusiveControl })[0].element.props.isHidden).toBe(true) - expect(map({ ...defaultAppState, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['p1'] }] })[0].element.props.isHidden).toBe(false) + it('hides a non-modal panel an application mode doesn\'t show, but never a modal one', () => { + const applicationModeEntries = [{ id: 'draw', include: ['other'], exclude: null }] + expect(map({ ...defaultAppState, applicationModeEntries })[0].element.props.isHidden).toBe(true) + expect(map({ ...defaultAppState, applicationModeEntries: [{ id: 'draw', include: ['p1'], exclude: null }] })[0].element.props.isHidden).toBe(false) expect(map(defaultAppState)[0].element.props.isHidden).toBe(false) - defaultAppState.panelConfig = ({ p1: { desktop: { modal: true }, includeModes: ['view'] } }) - expect(map({ ...defaultAppState, exclusiveControl }, 'modal')[0].element.props.isHidden).toBe(false) + defaultAppState.panelConfig = ({ p1: { desktop: { modal: true } } }) + expect(map({ ...defaultAppState, applicationModeEntries }, 'modal')[0].element.props.isHidden).toBe(false) }) it('renders both modal panels\' shells but only marks the last-opened one as open', () => { defaultAppState.panelConfig = ({ - p1: { desktop: { modal: true }, includeModes: ['view'] }, - p2: { desktop: { modal: true }, includeModes: ['view'] } + p1: { desktop: { modal: true } }, + p2: { desktop: { modal: true } } }) const state = { ...defaultAppState, @@ -185,13 +159,13 @@ describe('mapPanels', () => { it('returns just the injected controls when the panel has neither render nor html', () => { mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: injected }]) - defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } }) + defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' } } }) const result = map() expect(result[0].element.props.items.map(i => i.id)).toEqual(['injected1']) }) it('does not build an items list for a static-html panel (dangerouslySetInnerHTML can\'t host injected controls)', () => { - defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'], pluginId: 'plug1', html: '

Hi

' } }) + defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, pluginId: 'plug1', html: '

Hi

' } }) const result = map() expect(result[0].element.props.items).toBeUndefined() expect(result[0].element.props.html).toBe('

Hi

') @@ -199,7 +173,7 @@ describe('mapPanels', () => { it('warns in dev when controls target a static-html panel', () => { mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: injected }]) - defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'], pluginId: 'plug1', html: '

Hi

' } }) + defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, pluginId: 'plug1', html: '

Hi

' } }) map() expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('p1')) }) @@ -209,7 +183,7 @@ describe('mapPanels', () => { { id: 'first', order: 2, element: first }, { id: 'second', order: 1, element: second } ]) - defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } }) + defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' } } }) const result = map() expect(result[0].element.props.items.map(i => i.id)).toEqual(['second', 'first']) }) @@ -249,7 +223,7 @@ describe('mapPanels', () => { }) it('returns correct structure and defaults', () => { - defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } }) + defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' } } }) const result = map() expect(result[0]).toMatchObject({ id: 'p1', type: 'panel', order: 0 }) expect(result[0].element.props).toMatchObject({ panelId: 'p1', props: { foo: 'bar' } }) @@ -258,7 +232,7 @@ describe('mapPanels', () => { it('allows panel next to a button slot', () => { const panelId = 'p-1' defaultAppState.panelConfig = ({ - [panelId]: { desktop: { slot: 'p-1-button' }, includeModes: ['view'] } + [panelId]: { desktop: { slot: 'p-1-button' } } }) const state = { ...defaultAppState, openPanels: { [panelId]: { props: {} } } } expect(map(state, 'p-1-button')).toHaveLength(1) @@ -275,8 +249,7 @@ describe('mapPanels', () => { it('replaces drawer slot with left-top on non-mobile breakpoints', () => { defaultAppState.panelConfig = ({ p1: { - desktop: { slot: 'drawer' }, - includeModes: ['view'] + desktop: { slot: 'drawer' } } }) @@ -311,7 +284,7 @@ describe('mapPanels', () => { it('filters out consumer HTML panels (handled by HtmlElementHost)', () => { defaultAppState.panelConfig = ({ - p1: { desktop: { slot: 'header' }, html: '

Hi

', includeModes: ['view'] } + p1: { desktop: { slot: 'header' }, html: '

Hi

' } }) expect(map()).toEqual([]) }) diff --git a/src/App/renderer/pluginWrapper.js b/src/App/renderer/pluginWrapper.js index 12211fc1c..469046816 100755 --- a/src/App/renderer/pluginWrapper.js +++ b/src/App/renderer/pluginWrapper.js @@ -1,5 +1,5 @@ // src/core/renderers/pluginWrapper.js -import { useCallback, useMemo } from 'react' +import { useMemo, useRef } from 'react' import { useConfig } from '../store/configContext.js' import { useApp } from '../store/appContext.js' import { useMap } from '../store/mapContext.js' @@ -48,13 +48,20 @@ 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. options.keep, if given, has - // core hide every button, panel and control not listed (or not this plugin's own) meanwhile. - const setExclusiveControl = useCallback((value, { keep = null } = {}) => appState.dispatch({ - type: 'SET_EXCLUSIVE_CONTROL', - payload: { pluginId, name: typeof value === 'string' ? value : null, keep, active: !!value } - }), [appState.dispatch]) + // Created once per mount, reading dispatch through a ref, so effects can depend on them without + // re-running. + const dispatchRef = useRef(appState.dispatch) + dispatchRef.current = appState.dispatch + const applicationModeApiRef = useRef(null) + if (!applicationModeApiRef.current) { + applicationModeApiRef.current = { + setApplicationMode: (id, { include = null, exclude = null } = {}) => + dispatchRef.current({ type: 'SET_APPLICATION_MODE', payload: { id, include, exclude } }), + clearApplicationMode: (id) => + dispatchRef.current({ type: 'CLEAR_APPLICATION_MODE', payload: id }) + } + } + const { setApplicationMode, clearApplicationMode } = applicationModeApiRef.current 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 e77274d8f..efc942503 100755 --- a/src/App/renderer/pluginWrapper.test.js +++ b/src/App/renderer/pluginWrapper.test.js @@ -37,34 +37,51 @@ 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: {} }) + describe('application mode helpers', () => { + const setup = () => { + const dispatch = jest.fn() + require('../store/appContext.js').useApp.mockReturnValue({ buttonConfig: {}, dispatch }) + const Inner = jest.fn(() =>
Inner
) + const Wrapped = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: {} }) + const { rerender } = render() + return { dispatch, Inner, Wrapped, rerender } + } - render() - Inner.mock.calls[0][0].setExclusiveControl(value) + afterEach(() => { + require('../store/appContext.js').useApp.mockReturnValue({ user: 'testUser', buttonConfig: {} }) + }) - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_EXCLUSIVE_CONTROL', payload: { pluginId: 'plugin1', keep: null, ...expected } }) - }) + it('sets a mode with its lists', () => { + const { dispatch, Inner } = setup() + Inner.mock.calls[0][0].setApplicationMode('draw', { include: ['mapStyles'] }) + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_APPLICATION_MODE', + payload: { id: 'draw', include: ['mapStyles'], exclude: null } + }) + }) - it('passes setExclusiveControl\'s keep option through with the claim', () => { - const dispatch = jest.fn() - require('../store/appContext.js').useApp.mockReturnValueOnce({ buttonConfig: {}, dispatch }) - const Inner = jest.fn(() =>
Inner
) - const Wrapped = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: {} }) + it('defaults both lists to null', () => { + const { dispatch, Inner } = setup() + Inner.mock.calls[0][0].setApplicationMode('search') + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_APPLICATION_MODE', + payload: { id: 'search', include: null, exclude: null } + }) + }) - render() - Inner.mock.calls[0][0].setExclusiveControl(true, { keep: ['mapStyles'] }) + it('clears a mode', () => { + const { dispatch, Inner } = setup() + Inner.mock.calls[0][0].clearApplicationMode('draw') + expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_APPLICATION_MODE', payload: 'draw' }) + }) - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_EXCLUSIVE_CONTROL', payload: { pluginId: 'plugin1', name: null, keep: ['mapStyles'], active: true } }) + it('passes the same functions on every render, so effects can depend on them', () => { + const { Inner, Wrapped, rerender } = setup() + rerender() + const [firstProps, secondProps] = Inner.mock.calls.map(([props]) => props) + expect(secondProps.setApplicationMode).toBe(firstProps.setApplicationMode) + expect(secondProps.clearApplicationMode).toBe(firstProps.clearApplicationMode) + }) }) it('returns the cached wrapper if called again with the same component', () => { diff --git a/src/App/renderer/slotHelpers.js b/src/App/renderer/slotHelpers.js index 0114cf6d8..dc9c2a6e2 100644 --- a/src/App/renderer/slotHelpers.js +++ b/src/App/renderer/slotHelpers.js @@ -16,40 +16,11 @@ export const resolveTargetSlot = (bpConfig, breakpoint) => { return bpConfig.slot } -/** - * Whether exclusive control hides an item: true when any claim in the stack that has a keep list - * neither lists one of the item's ids nor belongs to the item's own plugin (a claiming plugin's own - * items always stay). Claims without a keep list (e.g. search's) hide nothing here — that plugin's - * own CSS does. Items are hidden with CSS, never unmounted, so their state survives. - * - * @param {Array<{ pluginId: string, keep: string[] | null }>} [exclusiveControl] - The claim stack. - * @param {{ ids: string[], pluginId?: string }} item - The item's id(s) and owning plugin, if any. - * @returns {boolean} - */ -export const isHiddenByExclusiveControl = (exclusiveControl, { ids, pluginId }) => - (exclusiveControl ?? []).some(claim => - claim.keep && claim.pluginId !== pluginId && !ids.some(id => claim.keep.includes(id)) - ) - -/** - * Checks whether the current application mode permits an item to be shown, - * based on its includeModes and excludModes configuration. - */ -export const isModeAllowed = (config, mode) => { - if (config.includeModes && !config.includeModes.includes(mode)) { - return false - } - if (config.excludeModes?.includes(mode)) { - return false - } - return true -} - /** * Checks whether a control should be visible based on breakpoint, - * mode, fullscreen, and slot constraints. + * fullscreen, and slot constraints. */ -export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) => { +export const isControlVisible = (control, { breakpoint, isFullscreen }) => { const bpConfig = control[breakpoint] if (!bpConfig) { return false @@ -59,9 +30,6 @@ export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) => if (!allowedSlots.control.includes(bpConfig.slot) && !bpConfig.slot?.endsWith('-panel')) { return false } - if (!isModeAllowed(control, mode)) { - return false - } if (control.inline === false && !isFullscreen) { return false } @@ -77,18 +45,15 @@ export const isConsumerHtml = (config) => { } /** - * Whether a panel is eligible for a target slot (slot type, mode, inline/fullscreen) — + * Whether a panel is eligible for a target slot (slot type, inline/fullscreen) — * independent of open state and modal exclusivity (see getAllowedModalPanelId). `slot`, if * given, also requires an exact match to the panel's resolved targetSlot. */ -export const isPanelSlotEligible = (config, { targetSlot, slot, mode, isFullscreen }) => { +export const isPanelSlotEligible = (config, { targetSlot, slot, isFullscreen }) => { const isNextToButton = targetSlot.endsWith('-button') if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) { return false } - if (!isModeAllowed(config, mode)) { - return false - } if (config.inline === false && !isFullscreen) { return false } diff --git a/src/App/renderer/slotHelpers.test.js b/src/App/renderer/slotHelpers.test.js index 39c5329bc..b0d9ce7b6 100644 --- a/src/App/renderer/slotHelpers.test.js +++ b/src/App/renderer/slotHelpers.test.js @@ -1,4 +1,4 @@ -import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel, isHiddenByExclusiveControl } from './slotHelpers.js' +import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel } from './slotHelpers.js' jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'], panel: ['header', 'modal', 'left-top'] } })) @@ -21,54 +21,32 @@ describe('resolveTargetSlot', () => { }) }) -describe('isModeAllowed', () => { - it('returns true when no mode restrictions', () => { - expect(isModeAllowed({}, 'view')).toBe(true) - }) - - it('rejects when mode not in includeModes', () => { - expect(isModeAllowed({ includeModes: ['edit'] }, 'view')).toBe(false) - }) - - it('rejects when mode in excludeModes', () => { - expect(isModeAllowed({ excludeModes: ['view'] }, 'view')).toBe(false) - }) - - it('allows when mode matches includeModes', () => { - expect(isModeAllowed({ includeModes: ['view'] }, 'view')).toBe(true) - }) -}) - describe('isControlVisible', () => { const base = { desktop: { slot: 'inset' } } it('returns true for valid control', () => { - expect(isControlVisible(base, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(true) + expect(isControlVisible(base, { breakpoint: 'desktop', isFullscreen: false })).toBe(true) }) it('returns false when breakpoint config missing', () => { - expect(isControlVisible(base, { breakpoint: 'mobile', mode: 'view', isFullscreen: false })).toBe(false) + expect(isControlVisible(base, { breakpoint: 'mobile', isFullscreen: false })).toBe(false) }) it('returns false when slot not allowed', () => { - expect(isControlVisible({ desktop: { slot: 'invalid' } }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false) - }) - - it('returns false when mode not allowed', () => { - expect(isControlVisible({ ...base, includeModes: ['edit'] }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false) + expect(isControlVisible({ desktop: { slot: 'invalid' } }, { breakpoint: 'desktop', isFullscreen: false })).toBe(false) }) it('returns false when inline:false and not fullscreen', () => { - expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false) + expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', isFullscreen: false })).toBe(false) }) it('returns true when inline:false and fullscreen', () => { - expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: true })).toBe(true) + expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', isFullscreen: true })).toBe(true) }) it('returns true for a control targeting a panel-body slot via the -panel convention', () => { const panelTargeting = { desktop: { slot: 'map-styles-panel' } } - expect(isControlVisible(panelTargeting, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(true) + expect(isControlVisible(panelTargeting, { breakpoint: 'desktop', isFullscreen: false })).toBe(true) }) }) @@ -87,8 +65,8 @@ describe('isConsumerHtml', () => { }) describe('isPanelSlotEligible', () => { - const base = { includeModes: ['view'] } - const ctx = { targetSlot: 'header', mode: 'view', isFullscreen: false } + const base = {} + const ctx = { targetSlot: 'header', isFullscreen: false } it('returns true for a panel in an allowed slot', () => { expect(isPanelSlotEligible(base, ctx)).toBe(true) @@ -102,10 +80,6 @@ describe('isPanelSlotEligible', () => { expect(isPanelSlotEligible(base, { ...ctx, targetSlot: 'my-button-button' })).toBe(true) }) - it('returns false when mode is not allowed', () => { - expect(isPanelSlotEligible({ includeModes: ['edit'] }, ctx)).toBe(false) - }) - it('returns false when inline:false and not fullscreen', () => { expect(isPanelSlotEligible({ ...base, inline: false }, ctx)).toBe(false) }) @@ -123,7 +97,7 @@ describe('isPanelSlotEligible', () => { }) it('skips the requested-slot check entirely when slot is omitted (HtmlElementHost usage)', () => { - expect(isPanelSlotEligible(base, { targetSlot: 'header', mode: 'view', isFullscreen: false })).toBe(true) + expect(isPanelSlotEligible(base, { targetSlot: 'header', isFullscreen: false })).toBe(true) }) }) @@ -162,36 +136,3 @@ describe('hasOpenModalPanel', () => { expect(hasOpenModalPanel({ p1: { props: {} } }, panelConfig, 'desktop')).toBe(true) }) }) - -describe('isHiddenByExclusiveControl', () => { - const draw = { pluginId: 'draw', name: null, keep: ['mapStyles', 'mapControls'] } - const search = { pluginId: 'search', name: null, keep: null } - - it('hides an item not on a keep-list claim\'s keep list', () => { - expect(isHiddenByExclusiveControl([draw], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(true) - }) - - it('keeps an item whose id (any of its ids) is on the keep list', () => { - expect(isHiddenByExclusiveControl([draw], { ids: ['mapStyles'], pluginId: 'mapStyles' })).toBe(false) - expect(isHiddenByExclusiveControl([draw], { ids: ['zoomIn', 'mapControls'] })).toBe(false) - }) - - it('never hides the claiming plugin\'s own items', () => { - expect(isHiddenByExclusiveControl([draw], { ids: ['drawUndo'], pluginId: 'draw' })).toBe(false) - }) - - it('ignores claims without a keep list', () => { - expect(isHiddenByExclusiveControl([search], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(false) - }) - - it('hides an item if any keep-list claim in the stack excludes it, whatever is on top', () => { - const searchKeepList = { pluginId: 'search', name: null, keep: ['mapStyles', 'datasetsLayers'] } - expect(isHiddenByExclusiveControl([draw, searchKeepList], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(true) - expect(isHiddenByExclusiveControl([draw, search], { ids: ['datasetsLayers'], pluginId: 'datasets' })).toBe(true) - }) - - it('hides nothing with no claims or no exclusiveControl state', () => { - expect(isHiddenByExclusiveControl([], { ids: ['x'] })).toBe(false) - expect(isHiddenByExclusiveControl(undefined, { ids: ['x'] })).toBe(false) - }) -}) diff --git a/src/App/store/AppProvider.jsx b/src/App/store/AppProvider.jsx index d2345c492..a700f0dbb 100755 --- a/src/App/store/AppProvider.jsx +++ b/src/App/store/AppProvider.jsx @@ -2,7 +2,6 @@ import React, { createContext, useRef, useEffect, useReducer, useMemo, useCallback } from 'react' import { initialState, reducer } from './appReducer.js' import { handleActionSideEffects } from './appDispatchMiddleware.js' -import { EVENTS as events } from '../../config/events.js' import { ConfigContext } from './configContext.js' import { subscribeToInterfaceChanges } from '../../utils/detectInterfaceType.js' import { useMediaQueryDispatch } from '../hooks/useMediaQueryDispatch.js' @@ -51,18 +50,7 @@ export const AppProvider = ({ options, children }) => { useMediaQueryDispatch(rawDispatch, options) - const handleSetMode = (mode) => { - dispatch({ type: 'SET_MODE', payload: mode }) - } - - const handleRevertMode = () => { - dispatch({ type: 'REVERT_MODE' }) - } - useEffect(() => { - eventBus.on(events.APP_SET_MODE, handleSetMode) - eventBus.on(events.APP_REVERT_MODE, handleRevertMode) - const unsubBreakpoint = breakpointDetector.subscribe((breakpoint) => { dispatch({ type: 'SET_BREAKPOINT', @@ -80,8 +68,6 @@ export const AppProvider = ({ options, children }) => { }) return () => { - eventBus.off(events.APP_SET_MODE, handleSetMode) - eventBus.off(events.APP_REVERT_MODE, handleRevertMode) unsubBreakpoint() unsubInterface() } diff --git a/src/App/store/AppProvider.test.jsx b/src/App/store/AppProvider.test.jsx index 95682dcf1..60d3c8d78 100755 --- a/src/App/store/AppProvider.test.jsx +++ b/src/App/store/AppProvider.test.jsx @@ -10,7 +10,6 @@ jest.mock('../hooks/useMediaQueryDispatch.js') jest.mock('../../utils/detectInterfaceType.js') describe('AppProvider', () => { - let capturedSetMode, capturedRevertMode let mockOptions let mockBreakpointDetector @@ -33,13 +32,8 @@ describe('AppProvider', () => { buttonConfig: { save: { label: 'Save' } } }) - capturedSetMode = null - capturedRevertMode = null const mockEventBus = { - on: jest.fn((event, handler) => { - if (event === 'app:setmode') capturedSetMode = handler - if (event === 'app:revertmode') capturedRevertMode = handler - }), + on: jest.fn(), off: jest.fn() } @@ -87,20 +81,6 @@ describe('AppProvider', () => { expect(detectInterface.subscribeToInterfaceChanges).toHaveBeenCalledWith(expect.any(Function)) }) - test('handles eventBus setmode and revertmode', () => { - render(
Child
) - act(() => { - capturedSetMode('newMode') - capturedRevertMode() - }) - act(() => { - mockOptions.eventBus.off('app:setmode', capturedSetMode) - mockOptions.eventBus.off('app:revertmode', capturedRevertMode) - }) - expect(mockOptions.eventBus.on).toHaveBeenCalledWith('app:setmode', capturedSetMode) - expect(mockOptions.eventBus.on).toHaveBeenCalledWith('app:revertmode', capturedRevertMode) - }) - test('provides state, dispatch, and layoutRefs via context', () => { let contextValue render( @@ -112,7 +92,7 @@ describe('AppProvider', () => { ) expect(contextValue).toHaveProperty('dispatch') - expect(contextValue).toHaveProperty('mode') + expect(contextValue).toHaveProperty('applicationModeEntries') expect(contextValue).toHaveProperty('openPanels') expect(contextValue.layoutRefs).toHaveProperty('mainRef') expect(contextValue.layoutRefs).toHaveProperty('bottomRef') @@ -123,18 +103,18 @@ describe('AppProvider', () => { // Mock initialState to return state without panelConfig but with panelRegistry jest.spyOn(appReducerModule, 'initialState').mockImplementation(() => ({ - mode: 'view', - previousMode: 'edit', openPanels: {}, previousOpenPanels: {}, interfaceType: 'default', isFullscreen: false, - exclusiveControl: [], + applicationModeEntries: [], panelRegistry: { getPanelConfig: getPanelConfigMock } // <-- provide it here! })) const mockEventBus = { on: jest.fn(), off: jest.fn() } const mockBreakpointDetector = { subscribe: jest.fn(() => jest.fn()) } + let interfaceChangeCallback + detectInterface.subscribeToInterfaceChanges.mockImplementation((callback) => { interfaceChangeCallback = callback; return jest.fn() }) const mockOptions = { ...createMockRegistries({ panelConfig: undefined }), eventBus: mockEventBus, @@ -147,13 +127,10 @@ describe('AppProvider', () => { ) - // Trigger a dispatch via eventBus to hit the dispatch wrapper - act(() => { - mockEventBus.on.mock.calls.forEach(([event, handler]) => { - if (event === 'app:setmode') handler('newMode') - }) - }) + // Trigger a dispatch (an interface type change) to hit the dispatch wrapper + const optionsGetPanelConfig = jest.spyOn(mockOptions.panelRegistry, 'getPanelConfig') + act(() => interfaceChangeCallback('touch')) - expect(getPanelConfigMock).toHaveBeenCalled() + expect(optionsGetPanelConfig).toHaveBeenCalled() }) }) diff --git a/src/App/store/appActionsMap.js b/src/App/store/appActionsMap.js index ea0917e70..bd01bb907 100755 --- a/src/App/store/appActionsMap.js +++ b/src/App/store/appActionsMap.js @@ -1,5 +1,4 @@ // src/App/store/appActionsMap.js -import { getInitialOpenPanels } from '../../config/getInitialOpenPanels.js' import { getIsFullscreen } from '../../utils/getIsFullscreen.js' import { shallowEqual } from '../../utils/shallowEqual.js' import { registerButton as registerButtonFn, addButton as addButtonFn } from '../registry/buttonRegistry.js' @@ -27,28 +26,6 @@ function buildOpenPanels (state, panelId, breakpoint, props, focusOnOpen) { } } -const setMode = (state, payload) => { - const panelConfig = state.panelConfig || state.panelRegistry.getPanelConfig() - - return { - ...state, - mode: payload, - previousMode: state.mode, - openPanels: getInitialOpenPanels(panelConfig, state.breakpoint, state.openPanels) - } -} - -const revertMode = (state) => { - const panelConfig = state.panelConfig || state.panelRegistry.getPanelConfig() - - return { - ...state, - mode: state.previousMode, - previousMode: state.mode, - openPanels: getInitialOpenPanels(panelConfig, state.breakpoint, state.openPanels) - } -} - const setMedia = (state, payload) => { return { ...state, @@ -145,25 +122,24 @@ const restorePreviousPanels = (state) => { } } -const isSameKeep = (a, b) => a === b || (!!a && !!b && a.length === b.length && a.every((id, i) => id === b[i])) - -// 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. A claim with a keep list -// also has the slot renderers hide every item not on it (see isHiddenByExclusiveControl); a claim -// with keep: null hides nothing itself. 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, keep = null, active }) => { - const stack = state.exclusiveControl - const top = stack[stack.length - 1] // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support - if (active && top?.pluginId === pluginId && top.name === name && isSameKeep(top.keep, keep)) { - return state - } - const others = stack.filter(claim => claim.pluginId !== pluginId) - if (!active && others.length === stack.length) { +const isSameList = (first, second) => + first === second || (!!first && !!second && first.length === second.length && first.every((id, i) => id === second[i])) + +// Application modes are a stack of { id, include, exclude }, most recently set last (see +// applicationModes.js for what they show). Setting a mode replaces any existing entry for that id +// and moves it to the end; clearing removes it, whoever set it, so the mode underneath takes over. +const setApplicationMode = (state, { id, include = null, exclude = null }) => { + const entries = state.applicationModeEntries + const last = entries[entries.length - 1] // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support + if (last?.id === id && isSameList(last.include, include) && isSameList(last.exclude, exclude)) { return state } - return { ...state, exclusiveControl: active ? [...others, { pluginId, name, keep }] : others } + return { ...state, applicationModeEntries: [...entries.filter(entry => entry.id !== id), { id, include, exclude }] } +} + +const clearApplicationMode = (state, id) => { + const remaining = state.applicationModeEntries.filter(entry => entry.id !== id) + return remaining.length === state.applicationModeEntries.length ? state : { ...state, applicationModeEntries: remaining } } const toggleNudgeStep = (state) => { @@ -394,18 +370,17 @@ export const actionsMap = { SET_MEDIA: setMedia, SET_HYBRID_FULLSCREEN: setHybridFullscreen, SET_INTERFACE_TYPE: setInterfaceType, - SET_MODE: setMode, PLUGINS_EVALUATED: setPluginsEvaluated, CLEAR_PLUGINS_EVALUATED: clearPluginsEvaluated, SET_LISTBOX_ACTIVE: setListboxActive, SET_SAFE_ZONE_INSET: setSafeZoneInset, - REVERT_MODE: revertMode, OPEN_PANEL: openPanel, CLOSE_PANEL: closePanel, CLOSE_ALL_PANELS: closeAllPanels, RESTORE_PREVIOUS_PANELS: restorePreviousPanels, TOGGLE_APP_VISIBLE: toggleAppVisible, - SET_EXCLUSIVE_CONTROL: setExclusiveControl, + SET_APPLICATION_MODE: setApplicationMode, + CLEAR_APPLICATION_MODE: clearApplicationMode, 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 ddf10abef..6c3fa44fe 100755 --- a/src/App/store/appActionsMap.test.js +++ b/src/App/store/appActionsMap.test.js @@ -18,13 +18,11 @@ describe('actionsMap full coverage', () => { } state = { - mode: 'view', - previousMode: 'edit', breakpoint: 'desktop', interfaceType: 'default', openPanels: { panel1: { props: {} } }, previousOpenPanels: {}, - exclusiveControl: [], + applicationModeEntries: [], nudgeStepSize: 'small', safeZoneInset: { top: 0, bottom: 0 }, isLayoutReady: false, @@ -60,22 +58,8 @@ describe('actionsMap full coverage', () => { afterEach(() => jest.restoreAllMocks()) // ---------------------- EXISTING COVERAGE ---------------------- - test('SET_MODE updates mode, previousMode, and openPanels', () => { - const result = actionsMap.SET_MODE(state, 'edit') - expect(result.mode).toBe('edit') - expect(result.previousMode).toBe('view') - expect(result.openPanels).toHaveProperty('panel1') - }) - - test('REVERT_MODE swaps mode and previousMode and updates openPanels', () => { - const result = actionsMap.REVERT_MODE(state) - expect(result.mode).toBe('edit') - expect(result.previousMode).toBe('view') - expect(result.openPanels).toHaveProperty('panel1') - }) - test('SET_MEDIA merges payload into state', () => { - const payload = { interfaceType: 'compact', mode: 'edit' } + const payload = { interfaceType: 'compact', breakpoint: 'mobile' } const result = actionsMap.SET_MEDIA(state, payload) expect(result).toMatchObject(payload) }) @@ -134,69 +118,42 @@ describe('actionsMap full coverage', () => { expect(result.previousOpenPanels).toBe(localState.openPanels) }) - 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, keep: null }]) - }) - - test('SET_EXCLUSIVE_CONTROL stacks a new plugin\'s claim on top', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point', keep: null }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: true }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point', keep: null }, { pluginId: 'search', name: null, keep: null }]) - }) + describe('application modes', () => { + const mode = (id, lists = {}) => ({ id, include: null, exclude: null, ...lists }) + const withModes = (entries) => ({ ...state, applicationModeEntries: entries }) - test('SET_EXCLUSIVE_CONTROL reinstates the claim underneath when the top claim is released', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point', keep: null }, { pluginId: 'search', name: null, keep: null }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: 'edit-point', keep: null }]) - }) - - test('SET_EXCLUSIVE_CONTROL releasing a claim underneath leaves the top claim in place', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-point', keep: null }, { pluginId: 'search', name: null, keep: null }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', active: false }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null, keep: 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, keep: 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', keep: null }, { pluginId: 'search', name: null, keep: null }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', name: 'edit-vertex', active: true }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'search', name: null, keep: null }, { pluginId: 'draw', name: 'edit-vertex', keep: null }]) - }) + test('SET_APPLICATION_MODE adds the mode, with null lists by default', () => { + const result = actionsMap.SET_APPLICATION_MODE(state, { id: 'search' }) + expect(result.applicationModeEntries).toEqual([mode('search')]) + }) - test('SET_EXCLUSIVE_CONTROL stores the claim\'s keep list', () => { - const result = actionsMap.SET_EXCLUSIVE_CONTROL(state, { pluginId: 'draw', keep: ['mapStyles'], active: true }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: null, keep: ['mapStyles'] }]) - }) + test('SET_APPLICATION_MODE stores the include and exclude lists', () => { + const result = actionsMap.SET_APPLICATION_MODE(state, { id: 'draw', include: ['mapStyles'], exclude: ['search'] }) + expect(result.applicationModeEntries).toEqual([mode('draw', { include: ['mapStyles'], exclude: ['search'] })]) + }) - test('SET_EXCLUSIVE_CONTROL re-claiming with a different keep list replaces it', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', keep: ['mapStyles', 'search'], active: true }) - expect(result.exclusiveControl).toEqual([{ pluginId: 'draw', name: null, keep: ['mapStyles', 'search'] }]) - }) + test('SET_APPLICATION_MODE replaces an existing mode with the same id and moves it last', () => { + const localState = withModes([mode('draw', { include: ['mapStyles'] }), mode('search')]) + const result = actionsMap.SET_APPLICATION_MODE(localState, { id: 'draw', include: ['scaleBar'] }) + expect(result.applicationModeEntries).toEqual([mode('search'), mode('draw', { include: ['scaleBar'] })]) + }) - test('SET_EXCLUSIVE_CONTROL re-claiming with an equal keep list returns the same state', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', keep: ['mapStyles'], active: true }) - expect(result).toBe(localState) - }) + test('SET_APPLICATION_MODE returns the same state when the top mode is set again unchanged', () => { + const localState = withModes([mode('draw', { include: ['mapStyles'] })]) + const result = actionsMap.SET_APPLICATION_MODE(localState, { id: 'draw', include: ['mapStyles'] }) + expect(result).toBe(localState) + }) - test('SET_EXCLUSIVE_CONTROL re-claiming the top claim unchanged returns the same state', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'edit-vertex', keep: null }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'draw', name: 'edit-vertex', active: true }) - expect(result).toBe(localState) - }) + test('CLEAR_APPLICATION_MODE removes the mode, so the one underneath takes over', () => { + const localState = withModes([mode('draw'), mode('search')]) + const result = actionsMap.CLEAR_APPLICATION_MODE(localState, 'search') + expect(result.applicationModeEntries).toEqual([mode('draw')]) + }) - test('SET_EXCLUSIVE_CONTROL releasing without a claim returns the same state', () => { - const localState = { ...state, exclusiveControl: [{ pluginId: 'draw', name: 'draw-polygon', keep: null }] } - const result = actionsMap.SET_EXCLUSIVE_CONTROL(localState, { pluginId: 'search', active: false }) - expect(result).toBe(localState) + test('CLEAR_APPLICATION_MODE returns the same state when the mode isn\'t set', () => { + const localState = withModes([mode('draw')]) + expect(actionsMap.CLEAR_APPLICATION_MODE(localState, 'search')).toBe(localState) + }) }) test('TOGGLE_NUDGE_STEP flips small to large', () => { @@ -341,18 +298,6 @@ describe('actionsMap full coverage', () => { }) // ---------------------- FALLBACK / OPTIONAL BRANCHES ---------------------- - test('SET_MODE uses panelRegistry.getPanelConfig() when panelConfig missing', () => { - const tmp = { ...state, panelConfig: undefined } - const result = actionsMap.SET_MODE(tmp, 'edit') - expect(result.openPanels.panel1).toBeDefined() - }) - - test('REVERT_MODE uses panelRegistry.getPanelConfig() when panelConfig missing', () => { - const tmp = { ...state, panelConfig: undefined } - const result = actionsMap.REVERT_MODE(tmp) - expect(result.openPanels.panel1).toBeDefined() - }) - test('OPEN_PANEL uses panelRegistry.getPanelConfig() when panelConfig missing', () => { const tmp = { ...state, panelConfig: undefined } const result = actionsMap.OPEN_PANEL(tmp, { panelId: 'panel2' }) diff --git a/src/App/store/appReducer.js b/src/App/store/appReducer.js index 901a4f545..00f3f1dee 100755 --- a/src/App/store/appReducer.js +++ b/src/App/store/appReducer.js @@ -12,8 +12,7 @@ export const initialState = (config) => { pluginRegistry, buttonRegistry, panelRegistry, - controlRegistry, - mode + controlRegistry } = config const { @@ -37,14 +36,12 @@ export const initialState = (config) => { preferredColorScheme: autoColorScheme ? preferredColorScheme : appColorScheme, prefersReducedMotion, isFullscreen, - mode: mode || null, - previousMode: null, safeZoneInset: null, disabledButtons: config.backAndContinue?.continueLabel ? new Set(['journeyContinue']) : new Set(), hiddenButtons: new Set(), pressedButtons: new Set(), expandedButtons: new Set(), - exclusiveControl: [], + applicationModeEntries: [], nudgeStepSize: 'large', openPanels, previousOpenPanels: {}, diff --git a/src/App/store/appReducer.test.js b/src/App/store/appReducer.test.js index a9cf4912b..e98c75458 100755 --- a/src/App/store/appReducer.test.js +++ b/src/App/store/appReducer.test.js @@ -37,7 +37,6 @@ describe('initialState', () => { initialInterfaceType: 'mobile', appColorScheme: 'light', autoColorScheme: true, - mode: 'edit', ...createMockRegistries({ panelConfig: { panel1: {} } }) } @@ -45,7 +44,7 @@ describe('initialState', () => { expect(result.breakpoint).toBe('sm') expect(result.preferredColorScheme).toBe('dark') - expect(result.mode).toBe('edit') + expect(result.applicationModeEntries).toEqual([]) expect(result.panelConfig).toEqual({ panel1: {} }) expect(result.nudgeStepSize).toBe('large') }) @@ -61,7 +60,6 @@ describe('initialState', () => { initialInterfaceType: 'desktop', appColorScheme: 'light', autoColorScheme: false, - mode: null, ...createMockRegistries() } @@ -108,7 +106,7 @@ describe('initialState', () => { expect(result.disabledButtons.has('journeyContinue')).toBe(false) }) - test('defaults mode to null when missing', () => { + test('starts with no application mode entries', () => { mockMedia({ prefersReducedMotion: false }) mockPanels({}) mockFullscreen(false) @@ -124,7 +122,7 @@ describe('initialState', () => { const result = initialState(config) - expect(result.mode).toBeNull() + expect(result.applicationModeEntries).toEqual([]) }) }) diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index 2d73206d0..f3089fea6 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -392,15 +392,6 @@ export default class InteractiveMap { this.eventBus.emit(events.APP_REMOVE_MARKER, id) } - /** - * Set the application mode. - * - * @param {string} mode - Mode identifier. - */ - setMode (mode) { - this.eventBus.emit(events.APP_SET_MODE, mode) - } - /** * Add a button to the UI. * @@ -489,6 +480,31 @@ export default class InteractiveMap { this.eventBus.emit(events.APP_HIDE_PANEL, id) } + /** + * Enter an application mode, putting it on top of the stack, or replace its lists if it's already + * set. While it's the most recently set active mode the app root gets `im-o-app--mode-{id}`. With + * `include`, only the listed buttons, panels and controls stay visible; with `exclude`, the listed + * ones are hidden. Hidden items stay mounted, so their state survives, and modal panels are never + * hidden. The `applicationModes` option still has the final say over the mode's lists. + * + * @param {string} id - Mode id, e.g. `'review'`. Used as-is in the class, so keep it class-safe. + * @param {ApplicationModeOptions} [options] + */ + setApplicationMode (id, { include = null, exclude = null } = {}) { + this.eventBus.emit(events.APP_SET_APPLICATION_MODE, { id, include, exclude }) + } + + /** + * Leave an application mode, so the mode underneath (if any) takes over. Clearing a mode a plugin set + * (e.g. `'draw'`) only changes the interface, not the plugin; use `applicationModes` to adjust or + * disable a plugin's mode instead. + * + * @param {string} id - Mode id. + */ + clearApplicationMode (id) { + this.eventBus.emit(events.APP_CLEAR_APPLICATION_MODE, id) + } + /** * Add a custom control to the UI. * diff --git a/src/InteractiveMap/InteractiveMap.test.js b/src/InteractiveMap/InteractiveMap.test.js index a9db3d53d..a09b98039 100755 --- a/src/InteractiveMap/InteractiveMap.test.js +++ b/src/InteractiveMap/InteractiveMap.test.js @@ -550,14 +550,12 @@ describe('InteractiveMap — Public API Methods', () => { map.addMarker('marker-1', coords, options) map.updateMarker('marker-1', options) map.removeMarker('marker-1') - map.setMode('test-mode') expect(map.eventBus.on).toHaveBeenCalledWith('testEvent', cb) expect(map.eventBus.off).toHaveBeenCalledWith('testEvent', cb) expect(map.eventBus.emit).toHaveBeenCalledWith('customEvent', 123) expect(map.eventBus.emit).toHaveBeenCalledWith('app:addmarker', { id: 'marker-1', coords, options }) expect(map.eventBus.emit).toHaveBeenCalledWith('app:updatemarker', { id: 'marker-1', options }) expect(map.eventBus.emit).toHaveBeenCalledWith('app:removemarker', 'marker-1') - expect(map.eventBus.emit).toHaveBeenCalledWith('app:setmode', 'test-mode') }) it('delegates Panel, Button, and Control API calls', () => { @@ -571,6 +569,12 @@ describe('InteractiveMap — Public API Methods', () => { map.showPanel('panel2') map.hidePanel('panel3') map.toggleButtonState('btn-1', 'disabled', true) + map.setApplicationMode('review', { include: ['mapStyles'] }) + map.setApplicationMode('focus') + map.clearApplicationMode('review') + expect(map.eventBus.emit).toHaveBeenCalledWith('app:setapplicationmode', { id: 'review', include: ['mapStyles'], exclude: null }) + expect(map.eventBus.emit).toHaveBeenCalledWith('app:setapplicationmode', { id: 'focus', include: null, exclude: null }) + expect(map.eventBus.emit).toHaveBeenCalledWith('app:clearapplicationmode', 'review') expect(map.eventBus.emit).toHaveBeenCalledWith('app:addbutton', { id: 'btn1', config: buttonConfig }) expect(map.eventBus.emit).toHaveBeenCalledWith('app:addpanel', { id: 'panel1', config: panelConfig }) expect(map.eventBus.emit).toHaveBeenCalledWith('app:addcontrol', { id: 'ctrl1', config: controlConfig }) diff --git a/src/config/events.js b/src/config/events.js index 6a2ec94b9..bcbf511cc 100644 --- a/src/config/events.js +++ b/src/config/events.js @@ -29,10 +29,6 @@ export const EVENTS = { APP_UPDATE_MARKER: 'app:updatemarker', /** @internal Remove a marker. Payload: id */ APP_REMOVE_MARKER: 'app:removemarker', - /** @internal Set application mode. Payload: mode */ - APP_SET_MODE: 'app:setmode', - /** @internal Revert to previous mode. */ - APP_REVERT_MODE: 'app:revertmode', /** @internal Add a button. Payload: { id, config } */ APP_ADD_BUTTON: 'app:addbutton', /** @internal Set a buttons state. Payload: { id, prop, value? } */ @@ -47,6 +43,10 @@ export const EVENTS = { APP_HIDE_PANEL: 'app:hidepanel', /** @internal Add a control. Payload: { id, config } */ APP_ADD_CONTROL: 'app:addcontrol', + /** @internal Set the host's entry for an application mode. Payload: { id, include, exclude } */ + APP_SET_APPLICATION_MODE: 'app:setapplicationmode', + /** @internal Clear the host's entry for an application mode. Payload: id */ + APP_CLEAR_APPLICATION_MODE: 'app:clearapplicationmode', /** @internal Show a toast hint, announced to screen readers. Payload: { text, options } */ APP_SHOW_HINT: 'app:showhint', /** @internal Dismiss the active toast hint, if any. */ diff --git a/src/test-utils.js b/src/test-utils.js index 6627bd3d2..f77d5fe16 100644 --- a/src/test-utils.js +++ b/src/test-utils.js @@ -55,7 +55,6 @@ export const createMockAppState = (overrides = {}) => { return { breakpoint: 'desktop', - mode: 'view', openPanels: {}, dispatch: jest.fn(), disabledButtons: new Set(), diff --git a/src/types.js b/src/types.js index 1eb1ad980..74e905bc6 100644 --- a/src/types.js +++ b/src/types.js @@ -114,22 +114,34 @@ * @property {Object} [services] * Core services (announce, reverseGeocode, closeApp, etc.). * - * @property {(value?: boolean | string | null, options?: ExclusiveControlOptions) => void} [setExclusiveControl] - * Plugin components only. Claims (true, or a string name) or releases (falsy) exclusive control of - * the interface for this plugin, adding im-o-app--exclusive-control-{pluginId}[--{name}] to the app - * root while its claim is the most recent. The plugin id is bound automatically. + * @property {(id: string, options?: ApplicationModeOptions) => void} [setApplicationMode] + * Plugin components only. Enters an application mode, putting it on top of the stack, or replaces its + * lists if it's already set. See ApplicationModeOptions. + * + * @property {(id: string) => void} [clearApplicationMode] + * Plugin components only. Leaves an application mode, so the mode underneath (if any) takes over. */ /** - * Options for a plugin's exclusive-control claim. + * What an application mode shows. The mode adds `im-o-app--mode-{id}` to the app root while it's the + * most recently set active mode. Hidden items stay mounted (display: none), so their state survives, + * and modal panels are never hidden. Without either list, nothing is hidden. + * + * @typedef {Object} ApplicationModeOptions * - * @typedef {Object} ExclusiveControlOptions + * @property {string[] | null} [include] + * Only these buttons, panels and controls (by id) stay visible. List your own items too. * - * @property {string[] | null} [keep=null] - * When given, core hides every button, panel and control, in every slot, whose id isn't listed and - * which doesn't belong to the claiming plugin, until the claim is released. Hidden items stay - * mounted (display: none), so their state survives. Modal panels are never hidden. Without keep, - * core hides nothing and the plugin's own CSS decides what to hide. + * @property {string[] | null} [exclude] + * These buttons, panels and controls (by id) are hidden. + */ + +/** + * The host's adjustments to application modes, keyed by mode id. A mode's include appends items to + * the lists it was set with (bringing back any it excluded); exclude removes items; false disables the + * mode entirely (no class, nothing hidden), whoever sets it. + * + * @typedef {Object} ApplicationModesConfig */ /** @@ -777,6 +789,10 @@ * @property {'light' | 'dark'} [appColorScheme='light'] * Application colour scheme. * + * @property {ApplicationModesConfig} [applicationModes] + * Adjusts or disables application modes, keyed by mode id, e.g. `{ draw: { include: ['search'] } }`. + * Applied whenever that mode is active, whoever sets it, and has the final say over the mode's own lists. + * * @property {boolean} [autoColorScheme=false] * Whether to automatically determine the colour scheme based on system preferences. * From a8e0d3d4bbc571f34e9c3492ec54b94027e4b9a4 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 29 Sep 2026 17:03:04 +0100 Subject: [PATCH 07/12] Apply only the current application mode --- docs/api.md | 4 +- docs/plugins/plugin-context.md | 4 +- src/App/layout/Layout.test.jsx | 2 +- src/App/renderer/applicationModes.js | 56 +++++++++++------------ src/App/renderer/applicationModes.test.js | 33 +++++++------ src/InteractiveMap/InteractiveMap.js | 2 +- src/types.js | 4 +- 7 files changed, 55 insertions(+), 50 deletions(-) diff --git a/docs/api.md b/docs/api.md index 75c284cb2..d4250e5d8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -805,9 +805,9 @@ interactiveMap.hidePanel('info-panel') ### `setApplicationMode(id, options?)` -Enters an application mode — for example for a step in a journey that needs a pared-down interface. Modes form a stack: the new mode goes on top, and setting a mode that's already on the stack replaces its lists and moves it to the top. While it's the most recently set active mode, the app root gets the class `im-o-app--mode-{id}`. Hidden items stay mounted, so their state is preserved, and modal panels are never hidden. +Enters an application mode — for example for a step in a journey that needs a pared-down interface. Modes form a stack: the new mode goes on top, and setting a mode that's already on the stack replaces its lists and moves it to the top. Only the current mode, the top of the stack, applies: the app root gets the class `im-o-app--mode-{id}`, and modes underneath wait until they're current again. Hidden items stay mounted, so their state is preserved, and modal panels are never hidden. -Plugins set modes too (e.g. the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'`). An item only shows if every active mode allows it, and your [`applicationModes`](#applicationmodes) option always has the final say over a mode's lists. +Plugins set modes too (e.g. the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'`). Your [`applicationModes`](#applicationmodes) option always has the final say over a mode's lists. | Parameter | Type | Description | |-----------|------|-------------| diff --git a/docs/plugins/plugin-context.md b/docs/plugins/plugin-context.md index 486cd4bac..484ca4038 100644 --- a/docs/plugins/plugin-context.md +++ b/docs/plugins/plugin-context.md @@ -78,7 +78,7 @@ Available to plugin components (InitComponent, panel and control render componen Application modes let your plugin change what the interface shows while it's in a particular state, e.g. draw while drawing or search while its form is open. Modes form a stack: `setApplicationMode(id, options)` puts a mode on top (or replaces its lists and moves it to the top if it's already set), and `clearApplicationMode(id)` removes it, so the mode underneath takes over. -While a mode is the most recently set active mode, the app root gets `im-o-app--mode-{id}`. What's hidden depends on its lists: +Only the current mode, the top of the stack, applies: the app root gets `im-o-app--mode-{id}`, and what's hidden depends on its lists: - **No lists** — nothing is hidden; your CSS can respond to the class instead (as search does). - **`include`** — only the listed buttons, panels and controls stay visible. Use this to take over the interface, and list your own items too (e.g. draw adds its own button ids). @@ -100,7 +100,7 @@ useLayoutEffect(() => { - One id can name several items, e.g. `mapStyles` is both the map styles button and its panel. - Hidden items are hidden with `display: none`, not removed, so their state, scroll position and focus-return targets survive, and they reappear as they were when the mode ends. If focus was on something that's hidden, it moves to the map. - Modal panels are never hidden, so focus can't get trapped in a hidden one. -- If several modes are active, an item only shows if every one of them allows it. +- Only the current mode applies. If another mode is set on top of yours, yours waits underneath and applies again once it's back on top. - The host has the final say: its [`applicationModes`](../api.md#applicationmodes) option can add to, remove from or disable your mode. Document your mode's id and default lists so they can. - Modes only change the interface. Your plugin's own behaviour, and other plugins', carries on as normal. diff --git a/src/App/layout/Layout.test.jsx b/src/App/layout/Layout.test.jsx index 383f63d91..f6d217e77 100755 --- a/src/App/layout/Layout.test.jsx +++ b/src/App/layout/Layout.test.jsx @@ -102,7 +102,7 @@ describe('Layout', () => { expect(backdrop).not.toHaveClass('im-o-app__modal-backdrop--visible') }) - test('adds the class for the most recently set active mode only', () => { + test('adds the class for the current mode (the top of the stack) only', () => { useApp.mockReturnValueOnce({ breakpoint: 'desktop', interfaceType: 'map', diff --git a/src/App/renderer/applicationModes.js b/src/App/renderer/applicationModes.js index 38483861c..6c9eda301 100644 --- a/src/App/renderer/applicationModes.js +++ b/src/App/renderer/applicationModes.js @@ -14,52 +14,52 @@ export const selectApplicationModes = (appState, appConfig) => ({ }) /** - * The active application modes, least recently set first: every mode on the stack, unless the - * consumer's config disables it (false). + * The current application mode: the top of the stack, skipping any mode the consumer's config + * disables (false). Only the current mode applies; modes underneath wait until they're on top again. * * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. - * @returns {Array<{ id: string, include: string[] | null, exclude: string[] | null }>} + * @returns {{ id: string, include: string[] | null, exclude: string[] | null } | null} */ -export const getActiveApplicationModes = ({ entries, config }) => - entries.filter(mode => config[mode.id] !== false) +export const getCurrentApplicationMode = ({ entries, config }) => { + const enabledModes = entries.filter(mode => config[mode.id] !== false) + return enabledModes[enabledModes.length - 1] ?? null // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support +} /** - * The root class for the most recently set active mode, or null when no mode is active. + * The root class for the current mode, or null when there's no current mode. * * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. * @returns {string | null} */ export const getApplicationModeClass = (modes) => { - const activeModes = getActiveApplicationModes(modes) - const topMode = activeModes[activeModes.length - 1] // NOSONAR, .length - 1 used instead of .at(-1) for wider browser support - return topMode ? `im-o-app--mode-${topMode.id}` : null + const currentMode = getCurrentApplicationMode(modes) + return currentMode ? `im-o-app--mode-${currentMode.id}` : null } const includesAny = (list, ids) => !!list && ids.some(id => list.includes(id)) /** - * Whether one active mode shows an item. The consumer's config for the mode adjusts what the mode - * was set with, so it's checked first and wins for the items it names: exclude removes, include - * appends. Every other item follows the mode's own lists: with include, only listed items show; - * with exclude, listed items are hidden. + * Whether the current application mode hides an item. The consumer's config for the mode adjusts + * what the mode was set with, so it's checked first and wins for the items it names: exclude + * removes, include appends. Every other item follows the mode's own lists: with include, only listed + * items show; with exclude, listed items are hidden. Items are hidden with CSS, never unmounted, so + * their state survives. + * + * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. + * @param {string[]} ids - The item's id(s); one id can name a button and its panel. + * @returns {boolean} */ -const isVisibleInMode = (mode, config, ids) => { - if (includesAny(config?.exclude, ids)) { +export const isHiddenByApplicationMode = (modes, ids) => { + const currentMode = getCurrentApplicationMode(modes) + if (!currentMode) { return false } - if (includesAny(config?.include, ids)) { + const config = modes.config[currentMode.id] + if (includesAny(config?.exclude, ids)) { return true } - return (!mode.include || includesAny(mode.include, ids)) && !includesAny(mode.exclude, ids) + if (includesAny(config?.include, ids)) { + return false + } + return (!!currentMode.include && !includesAny(currentMode.include, ids)) || includesAny(currentMode.exclude, ids) } - -/** - * Whether the active application modes hide an item: true when any active mode doesn't show it. - * Items are hidden with CSS, never unmounted, so their state survives. - * - * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. - * @param {string[]} ids - The item's id(s); one id can name a button and its panel. - * @returns {boolean} - */ -export const isHiddenByApplicationMode = (modes, ids) => - getActiveApplicationModes(modes).some(mode => !isVisibleInMode(mode, modes.config[mode.id], ids)) diff --git a/src/App/renderer/applicationModes.test.js b/src/App/renderer/applicationModes.test.js index 7e35de79c..9a61667fe 100644 --- a/src/App/renderer/applicationModes.test.js +++ b/src/App/renderer/applicationModes.test.js @@ -1,6 +1,6 @@ import { selectApplicationModes, - getActiveApplicationModes, + getCurrentApplicationMode, getApplicationModeClass, isHiddenByApplicationMode } from './applicationModes.js' @@ -21,29 +21,33 @@ describe('selectApplicationModes', () => { }) }) -describe('getActiveApplicationModes', () => { - it('lists the modes on the stack, least recently set first', () => { - expect(getActiveApplicationModes(modesWith([mode('draw'), mode('search')])).map(active => active.id)).toEqual(['draw', 'search']) +describe('getCurrentApplicationMode', () => { + it('is the top of the stack', () => { + expect(getCurrentApplicationMode(modesWith([mode('draw'), mode('search')]))).toEqual(mode('search')) }) - it('leaves out a mode the consumer\'s config disables', () => { - expect(getActiveApplicationModes(modesWith([mode('draw'), mode('search')], { draw: false })).map(active => active.id)).toEqual(['search']) + it('skips a mode the consumer\'s config disables, so the one underneath is current', () => { + expect(getCurrentApplicationMode(modesWith([mode('search'), mode('draw')], { draw: false }))).toEqual(mode('search')) + }) + + it('is null with an empty stack', () => { + expect(getCurrentApplicationMode(modesWith([]))).toBeNull() }) }) describe('getApplicationModeClass', () => { - it('uses the most recently set active mode', () => { + it('uses the current mode', () => { expect(getApplicationModeClass(modesWith([mode('draw'), mode('search')]))).toBe('im-o-app--mode-search') }) - it('is null with no active mode', () => { + it('is null with no current mode', () => { expect(getApplicationModeClass(modesWith([]))).toBeNull() expect(getApplicationModeClass(modesWith([mode('draw')], { draw: false }))).toBeNull() }) }) describe('isHiddenByApplicationMode', () => { - it('hides nothing with no active mode, or a mode without lists', () => { + it('hides nothing with no current mode, or a mode without lists', () => { expect(isHiddenByApplicationMode(modesWith([]), ['mapKey'])).toBe(false) expect(isHiddenByApplicationMode(modesWith([mode('search')]), ['mapKey'])).toBe(false) }) @@ -102,10 +106,11 @@ describe('isHiddenByApplicationMode', () => { }) }) - it('hides an item when any active mode hides it', () => { - const modes = modesWith([mode('draw', { include: ['mapStyles', 'search'] }), mode('focus', { exclude: ['mapStyles'] })]) - expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(true) - expect(isHiddenByApplicationMode(modes, ['mapStyles'])).toBe(true) - expect(isHiddenByApplicationMode(modes, ['search'])).toBe(false) + it('applies only the current mode\'s lists, not those of modes underneath', () => { + const modes = modesWith([mode('draw', { include: ['mapStyles'] }), mode('search')]) + // search is current and has no lists, so draw's include (underneath) doesn't hide anything + expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(false) + // once search is cleared, draw is current again and its include applies + expect(isHiddenByApplicationMode(modesWith([mode('draw', { include: ['mapStyles'] })]), ['mapKey'])).toBe(true) }) }) diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index f3089fea6..64fc92fee 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -482,7 +482,7 @@ export default class InteractiveMap { /** * Enter an application mode, putting it on top of the stack, or replace its lists if it's already - * set. While it's the most recently set active mode the app root gets `im-o-app--mode-{id}`. With + * set. Only the current mode (the top of the stack) applies: the app root gets `im-o-app--mode-{id}`. With * `include`, only the listed buttons, panels and controls stay visible; with `exclude`, the listed * ones are hidden. Hidden items stay mounted, so their state survives, and modal panels are never * hidden. The `applicationModes` option still has the final say over the mode's lists. diff --git a/src/types.js b/src/types.js index 74e905bc6..56c17aa1e 100644 --- a/src/types.js +++ b/src/types.js @@ -123,8 +123,8 @@ */ /** - * What an application mode shows. The mode adds `im-o-app--mode-{id}` to the app root while it's the - * most recently set active mode. Hidden items stay mounted (display: none), so their state survives, + * What an application mode shows while it's the current mode (the top of the stack; modes underneath + * don't apply). The current mode adds `im-o-app--mode-{id}` to the app root. Hidden items stay mounted (display: none), so their state survives, * and modal panels are never hidden. Without either list, nothing is hidden. * * @typedef {Object} ApplicationModeOptions From 0ac5c2e2a368d758bb0182e147d3752e77fe8d72 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 1 Oct 2026 14:25:36 +0100 Subject: [PATCH 08/12] Declare application modes in plugin manifests --- docs/api.md | 14 +- docs/plugins/plugin-context.md | 19 +-- docs/plugins/plugin-manifest.md | 19 +++ plugins/draw/src/DrawInit.jsx | 11 +- plugins/draw/src/DrawInit.test.jsx | 4 +- plugins/draw/src/defaults.js | 5 +- plugins/draw/src/manifest.js | 6 + plugins/draw/src/manifest.test.js | 6 + plugins/search/src/manifest.js | 6 + plugins/search/src/manifest.test.js | 4 + src/App/renderer/HtmlElementHost.jsx | 4 +- src/App/renderer/applicationModes.js | 69 +++++++--- src/App/renderer/applicationModes.test.js | 150 +++++++++++++--------- src/App/renderer/mapButtons.js | 10 +- src/App/renderer/mapControls.js | 2 +- src/App/renderer/mapPanels.js | 2 +- src/types.js | 30 +++-- 17 files changed, 232 insertions(+), 129 deletions(-) diff --git a/docs/api.md b/docs/api.md index d4250e5d8..55d3642ac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -61,13 +61,13 @@ Uses a dark colour scheme. | ### `applicationModes` **Type:** `Object` -Adjusts [application modes](#setapplicationmodeid-options), keyed by mode id. Plugins document the modes they set — for example the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'` while drawing or editing. Your settings apply whenever that mode is active, whoever sets it, and have the final say. +Adjusts plugins' [application modes](#setapplicationmodeid-options), or defines your own, keyed by mode id. Plugins document the modes they set — for example the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'` while drawing or editing. Your settings apply whenever that mode is current, whoever sets it. Each mode's value is either: - **an object**, with either or both of: - - `include` — adds items to the mode's visible items, and brings back any a plugin excluded. - - `exclude` — hides items, whoever included them. + - `include` — for a plugin's mode, adds items to what it shows (including any a plugin excluded). For your own mode, makes it take over the interface: only these items stay visible. + - `exclude` — hides items. - **`false`** — turns the mode off entirely, so it adds no class and hides nothing. Ids name buttons, panels and controls, and don't need to exist yet — an item you add later with [`addControl`](#addcontrolid-config) or similar is picked up when it appears. @@ -807,15 +807,15 @@ interactiveMap.hidePanel('info-panel') Enters an application mode — for example for a step in a journey that needs a pared-down interface. Modes form a stack: the new mode goes on top, and setting a mode that's already on the stack replaces its lists and moves it to the top. Only the current mode, the top of the stack, applies: the app root gets the class `im-o-app--mode-{id}`, and modes underneath wait until they're current again. Hidden items stay mounted, so their state is preserved, and modal panels are never hidden. -Plugins set modes too (e.g. the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'`). Your [`applicationModes`](#applicationmodes) option always has the final say over a mode's lists. +Plugins set modes too (e.g. the [draw plugin](./plugins/draw.md#application-mode) sets `'draw'`). Define your own mode's lists in the [`applicationModes`](#applicationmodes) option and call `setApplicationMode(id)`, or pass them here. Options passed here are applied last, after the plugins' manifests and your `applicationModes` option. | Parameter | Type | Description | |-----------|------|-------------| | `id` | `string` | Mode id, used as-is in the class, so keep it class-safe | -| `options.include` | `string[]` | Only these buttons, panels and controls stay visible | -| `options.exclude` | `string[]` | These buttons, panels and controls are hidden | +| `options.include` | `string[]` | Buttons, panels and controls to keep visible. If nothing else defines the mode, only these stay visible | +| `options.exclude` | `string[]` | Buttons, panels and controls to hide | -Without either list, nothing is hidden and only the class is added. +Without any lists, nothing is hidden and only the class is added. ```js // Only the map styles button and panel, and your own button, stay visible diff --git a/docs/plugins/plugin-context.md b/docs/plugins/plugin-context.md index 484ca4038..3dc2d8b25 100644 --- a/docs/plugins/plugin-context.md +++ b/docs/plugins/plugin-context.md @@ -76,13 +76,7 @@ context.pluginState.dispatch({ type: 'setActive', payload: true }) Available to plugin components (InitComponent, panel and control render components) as props. -Application modes let your plugin change what the interface shows while it's in a particular state, e.g. draw while drawing or search while its form is open. Modes form a stack: `setApplicationMode(id, options)` puts a mode on top (or replaces its lists and moves it to the top if it's already set), and `clearApplicationMode(id)` removes it, so the mode underneath takes over. - -Only the current mode, the top of the stack, applies: the app root gets `im-o-app--mode-{id}`, and what's hidden depends on its lists: - -- **No lists** — nothing is hidden; your CSS can respond to the class instead (as search does). -- **`include`** — only the listed buttons, panels and controls stay visible. Use this to take over the interface, and list your own items too (e.g. draw adds its own button ids). -- **`exclude`** — the listed ones are hidden and everything else stays. +Application modes let your plugin change what the interface shows while it's in a particular state, e.g. draw while drawing or search while its form is open. Declare the mode and what it shows in your manifest's [`applicationModes`](./plugin-manifest.md#applicationmodes), then enter and leave it. Modes form a stack: `setApplicationMode(id)` puts a mode on top (or moves it to the top if it's already set), and `clearApplicationMode(id)` removes it, so the mode underneath takes over. ```js // Enter the mode for as long as your plugin is in that state, and leave it on unmount too @@ -90,21 +84,22 @@ useLayoutEffect(() => { if (!isDrawing) { return undefined } - // buttonConfig (a prop) holds your plugin's own buttons, so listing its keys keeps them visible - setApplicationMode('my-plugin', { include: ['mapStyles', 'mapControls', ...Object.keys(buttonConfig)] }) + setApplicationMode('my-plugin') return () => clearApplicationMode('my-plugin') }, [isDrawing]) ``` +Only the current mode, the top of the stack, applies: the app root gets `im-o-app--mode-{id}`, and its lists decide what's hidden. Pass `options` (`{ include, exclude }`) only to adjust the mode for one call; they're applied last, on top of the manifests and the host's config. + - Use your plugin's id as the mode id, or as a prefix for several modes (e.g. `'draw-polygon'`). - One id can name several items, e.g. `mapStyles` is both the map styles button and its panel. - Hidden items are hidden with `display: none`, not removed, so their state, scroll position and focus-return targets survive, and they reappear as they were when the mode ends. If focus was on something that's hidden, it moves to the map. - Modal panels are never hidden, so focus can't get trapped in a hidden one. -- Only the current mode applies. If another mode is set on top of yours, yours waits underneath and applies again once it's back on top. -- The host has the final say: its [`applicationModes`](../api.md#applicationmodes) option can add to, remove from or disable your mode. Document your mode's id and default lists so they can. +- If another mode is set on top of yours, yours waits underneath and applies again once it's back on top. +- The host can add to, remove from or disable your mode with its [`applicationModes`](../api.md#applicationmodes) option. Document your mode's id so they can. - Modes only change the interface. Your plugin's own behaviour, and other plugins', carries on as normal. -Use `include` for a mode the user stays in until they end it (like drawing), where hidden items shouldn't be reachable with Tab. Use no lists, and hide things with your own CSS, when hidden items must stay focusable — for example if your UI closes when focus leaves it, as search does, so Tab can still move on to the next item: +Use `include` (in your manifest) for a mode the user stays in until they end it (like drawing), where hidden items shouldn't be reachable with Tab. Use no lists, and hide things with your own CSS, when hidden items must stay focusable — for example if your UI closes when focus leaves it, as search does, so Tab can still move on to the next item: ```scss .im-o-app--mode-my-plugin { diff --git a/docs/plugins/plugin-manifest.md b/docs/plugins/plugin-manifest.md index 649b6a062..f22fd0510 100644 --- a/docs/plugins/plugin-manifest.md +++ b/docs/plugins/plugin-manifest.md @@ -30,6 +30,25 @@ Each function receives a [PluginContext](./plugin-context.md) as its first argum --- +### `applicationModes` +**Type:** `Object` + +The [application modes](./plugin-context.md#setapplicationmode) your plugin enters, keyed by mode id, and what each one shows. Your plugin enters and leaves them with `setApplicationMode(id)` and `clearApplicationMode(id)`. + +- **`include`** — the mode takes over the interface: everything is hidden except these buttons, panels and controls and your plugin's own items, which are always kept. +- **`exclude`** — these are hidden; everything else stays. +- **No lists** — nothing is hidden; only the `im-o-app--mode-{id}` class is added, for your CSS to respond to. + +```js +applicationModes: { + draw: { include: ['mapStyles', 'mapControls', 'scaleBar'] } +} +``` + +Several plugins can declare the same mode, e.g. to keep their own control visible in another plugin's mode: their lists combine, and each declaring plugin's own items are kept. The host can then add to, remove from or disable the mode with its [`applicationModes`](../api.md#applicationmodes) option, and any options passed to `setApplicationMode` are applied last. + +--- + ### `buttons` **Type:** `ButtonDefinition[]` diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index 313fb9466..5862891d2 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -3,7 +3,7 @@ import { EVENTS } from '../../../src/config/events.js' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' import { useSpatialList } from './hooks/useSpatialList.js' -import { APPLICATION_MODE_ID, APPLICATION_MODE_INCLUDE } from './defaults.js' +import { APPLICATION_MODE_ID } from './defaults.js' // Loads the draw adapter once the map is ready; tears it down (and releases MapControls' D-pad) // on cleanup. @@ -53,17 +53,14 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt useLoadDrawAdapter({ mapState, pluginConfig, pluginState, mapProvider, eventBus }) - // Enters the 'draw' application mode in any draw/edit mode: core hides every button, panel and - // control except APPLICATION_MODE_INCLUDE and draw's own buttons (buttonConfig holds only draw's) - // until the draw/edit mode ends, keeping them mounted so their state survives. The host adjusts or - // disables it via its applicationModes config. useLayoutEffect so the class lands in the same paint - // as the draw/edit mode change. + // Enters the 'draw' application mode (declared in the manifest) in any draw/edit mode, and leaves it + // when the draw/edit mode ends. useLayoutEffect so the class lands in the same paint as the change. useLayoutEffect(() => { if (!pluginState.mode) { clearApplicationMode(APPLICATION_MODE_ID) return undefined } - setApplicationMode(APPLICATION_MODE_ID, { include: [...APPLICATION_MODE_INCLUDE, ...Object.keys(buttonConfig)] }) + setApplicationMode(APPLICATION_MODE_ID) return () => clearApplicationMode(APPLICATION_MODE_ID) }, [pluginState.mode]) diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index 73c0a8665..41a064621 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -252,10 +252,10 @@ describe('event attachment', () => { }) describe('application mode', () => { - test('enters the draw application mode in a draw/edit mode, including its default items', async () => { + test('enters the draw application mode (lists come from the manifest) in a draw/edit mode', async () => { const { props } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } }) await renderInit(props) - expect(props.setApplicationMode).toHaveBeenLastCalledWith('draw', { include: ['mapStyles', 'mapControls', 'scaleBar'] }) + expect(props.setApplicationMode).toHaveBeenLastCalledWith('draw') }) test('leaves the draw application mode when the draw/edit mode ends', async () => { diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js index da4cf240e..476f94334 100644 --- a/plugins/draw/src/defaults.js +++ b/plugins/draw/src/defaults.js @@ -51,8 +51,5 @@ export const MAP_SIZE_SCALES = { large: 2 } -// The application mode draw enters while drawing or editing, and the button, panel and control ids -// it keeps visible alongside its own buttons; everything else in the interface is hidden meanwhile. -// Hosts adjust or disable it via the applicationModes config, keyed by this id. +// The application mode draw enters while drawing or editing (declared in manifest.js) export const APPLICATION_MODE_ID = 'draw' -export const APPLICATION_MODE_INCLUDE = ['mapStyles', 'mapControls', 'scaleBar'] diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index 3fc994a51..5b22c02ca 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -10,6 +10,7 @@ import { deleteFeature } from './api/deleteFeature.js' import { split } from './api/split.js' import { merge } from './api/merge.js' import { isMac } from '../../../src/utils/isMac.js' +import { APPLICATION_MODE_ID } from './defaults.js' // edit_point behaves like edit_vertex for Done/Undo, but stays out of drawDeletePoint and mergeShapes (neither applies to a single coordinate). const EDIT_MODES = new Set(['edit_vertex', 'edit_point']) @@ -39,6 +40,11 @@ export const manifest = { InitComponent: DrawInit, + // While drawing or editing, hide everything except draw's own items (kept automatically) and these + applicationModes: { + [APPLICATION_MODE_ID]: { include: ['mapStyles', 'mapControls', 'scaleBar'] } + }, + buttons: [ { id: 'drawCancel', diff --git a/plugins/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js index 916be3a3c..4dba4aac0 100644 --- a/plugins/draw/src/manifest.test.js +++ b/plugins/draw/src/manifest.test.js @@ -13,6 +13,12 @@ describe('manifest structure', () => { }) }) +describe('application mode', () => { + test('declares the draw mode, keeping map styles, map controls and the scale bar (its own items are kept automatically)', () => { + expect(manifest.applicationModes).toEqual({ draw: { include: ['mapStyles', 'mapControls', 'scaleBar'] } }) + }) +}) + describe('drawCancel', () => { test('is hidden only when there is no active mode', () => { expect(findButton('drawCancel').hiddenWhen({ pluginState: { mode: null } })).toBe(true) diff --git a/plugins/search/src/manifest.js b/plugins/search/src/manifest.js index cf1a6996d..11dc69065 100755 --- a/plugins/search/src/manifest.js +++ b/plugins/search/src/manifest.js @@ -1,6 +1,7 @@ // /plugins/search/manifest.js import { initialState, actions } from './reducer.js' import { Search } from './Search.jsx' +import { APPLICATION_MODE_ID } from './defaults.js' export const manifest = { reducer: { @@ -8,6 +9,11 @@ export const manifest = { actions }, + // Set while the form is open. No lists: search.scss hides things itself, keyed on its class + applicationModes: { + [APPLICATION_MODE_ID]: {} + }, + // Standard MapButton that toggles the plugin's own `isExpanded` state to reveal the form. buttons: [{ id: 'search', diff --git a/plugins/search/src/manifest.test.js b/plugins/search/src/manifest.test.js index cf70df0e1..4eac89e51 100644 --- a/plugins/search/src/manifest.test.js +++ b/plugins/search/src/manifest.test.js @@ -4,6 +4,10 @@ import { manifest } from './manifest.js' describe('search manifest', () => { const getButton = () => manifest.buttons.find(b => b.id === 'search') + it('declares the search application mode with no lists (search.scss does the hiding)', () => { + expect(manifest.applicationModes).toEqual({ search: {} }) + }) + it('declares a single search button and control', () => { expect(manifest.buttons).toHaveLength(1) expect(getButton()).toBeDefined() diff --git a/src/App/renderer/HtmlElementHost.jsx b/src/App/renderer/HtmlElementHost.jsx index 9fcc8c4c7..f1fbf98f5 100644 --- a/src/App/renderer/HtmlElementHost.jsx +++ b/src/App/renderer/HtmlElementHost.jsx @@ -97,7 +97,7 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, const isEligible = Boolean(bpConfig && targetSlot && isPanelSlotEligible(config, { targetSlot, isFullscreen })) const isAllowedModal = !bpConfig?.modal || panelId === allowedModalPanelId // Modal panels are never hidden by an application mode, so focus is never trapped in a hidden one - const isModeHidden = !bpConfig?.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [panelId]) + const isModeHidden = !bpConfig?.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [panelId], pluginId: config.pluginId }) const isVisible = isOpen && isEligible && isAllowedModal && !isModeHidden useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint) @@ -127,7 +127,7 @@ const PersistentControl = ({ control, appState, appConfig }) => { const bpConfig = control[breakpoint] // An application mode hides it by the same display toggle, so it stays mounted either way const isVisible = isControlVisible(control, { breakpoint, isFullscreen }) && - !isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [control.id]) + !isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [control.id], pluginId: control.pluginId }) const targetSlot = bpConfig?.slot || null // A control targeting a panel's body (`-panel`) needs its DOM anchor re-resolved diff --git a/src/App/renderer/applicationModes.js b/src/App/renderer/applicationModes.js index 6c9eda301..828cb8fa4 100644 --- a/src/App/renderer/applicationModes.js +++ b/src/App/renderer/applicationModes.js @@ -1,15 +1,19 @@ // src/App/renderer/applicationModes.js /** - * The two halves of the application mode data the helpers below need: the stack of modes set at - * runtime (app state) and the consumer's fixed applicationModes option (app config). + * Everything the helpers below need: the stack of modes set at runtime (app state), the modes plugins + * declare in their manifests (via the plugin registry in app state) and the consumer's fixed + * applicationModes option (app config). * - * @param {{ applicationModeEntries?: Array }} appState + * @param {{ applicationModeEntries?: Array, pluginRegistry?: Object }} appState * @param {{ applicationModes?: Object }} [appConfig] - * @returns {{ entries: Array<{ id: string, include: string[] | null, exclude: string[] | null }>, config: Object }} + * @returns {{ entries: Array, declarations: Array, config: Object }} */ export const selectApplicationModes = (appState, appConfig) => ({ entries: appState?.applicationModeEntries ?? [], + declarations: (appState?.pluginRegistry?.registeredPlugins ?? []).flatMap(plugin => + Object.entries(plugin.manifest?.applicationModes ?? {}).map(([id, lists]) => ({ id, pluginId: plugin.id, ...lists })) + ), config: appConfig?.applicationModes ?? {} }) @@ -38,28 +42,53 @@ export const getApplicationModeClass = (modes) => { const includesAny = (list, ids) => !!list && ids.some(id => list.includes(id)) +// One list combining a layer's sources (several manifests can contribute to the same mode) +const mergeLists = (sources, key) => { + const lists = sources.map(source => source?.[key]).filter(Boolean) + return lists.length ? lists.flat() : null +} + +/** + * The layers that decide the current mode, in order. The earliest layer that mentions the mode + * defines it: every plugin manifest that declares it (combined), else the consumer's config, else the + * options it was set with. Later layers only adjust it. + */ +const getLayers = (mode, { declarations, config }) => { + const manifests = declarations.filter(declaration => declaration.id === mode.id) + const layers = [ + manifests.length ? { include: mergeLists(manifests, 'include'), exclude: mergeLists(manifests, 'exclude') } : null, + config[mode.id] || null, + mode.include || mode.exclude ? { include: mode.include, exclude: mode.exclude } : null + ].filter(Boolean) + return { layers, ownerIds: manifests.map(manifest => manifest.pluginId) } +} + /** - * Whether the current application mode hides an item. The consumer's config for the mode adjusts - * what the mode was set with, so it's checked first and wins for the items it names: exclude - * removes, include appends. Every other item follows the mode's own lists: with include, only listed - * items show; with exclude, listed items are hidden. Items are hidden with CSS, never unmounted, so - * their state survives. + * Whether the current application mode hides an item. The mode's definition (its first layer) sets + * the starting point: with an include, only the included items and the declaring plugins' own items + * show; otherwise everything does. Then every layer in turn appends its include and removes its + * exclude, so later layers have the final say. Items are hidden with CSS, never unmounted, so their + * state survives. * - * @param {{ entries: Array, config: Object }} modes - From selectApplicationModes. - * @param {string[]} ids - The item's id(s); one id can name a button and its panel. + * @param {{ entries: Array, declarations: Array, config: Object }} modes - From selectApplicationModes. + * @param {{ ids: string[], pluginId?: string }} item - The item's id(s) and owning plugin, if any. * @returns {boolean} */ -export const isHiddenByApplicationMode = (modes, ids) => { +export const isHiddenByApplicationMode = (modes, { ids, pluginId }) => { const currentMode = getCurrentApplicationMode(modes) if (!currentMode) { return false } - const config = modes.config[currentMode.id] - if (includesAny(config?.exclude, ids)) { - return true - } - if (includesAny(config?.include, ids)) { - return false - } - return (!!currentMode.include && !includesAny(currentMode.include, ids)) || includesAny(currentMode.exclude, ids) + const { layers, ownerIds } = getLayers(currentMode, modes) + const isTakeover = !!layers[0]?.include + let isVisible = !isTakeover || ownerIds.includes(pluginId) + layers.forEach(layer => { + if (includesAny(layer.include, ids)) { + isVisible = true + } + if (includesAny(layer.exclude, ids)) { + isVisible = false + } + }) + return !isVisible } diff --git a/src/App/renderer/applicationModes.test.js b/src/App/renderer/applicationModes.test.js index 9a61667fe..6eacaaa49 100644 --- a/src/App/renderer/applicationModes.test.js +++ b/src/App/renderer/applicationModes.test.js @@ -5,112 +5,146 @@ import { isHiddenByApplicationMode } from './applicationModes.js' +// A mode on the stack, as setApplicationMode stores it (its lists are the call options) const mode = (id, lists = {}) => ({ id, include: null, exclude: null, ...lists }) -// The stack of modes set at runtime (app state) and the consumer's applicationModes option (app config) -const modesWith = (entries, config = {}) => ({ entries, config }) +// A mode a plugin declares in its manifest +const declared = (id, pluginId, lists = {}) => ({ id, pluginId, ...lists }) +const modesWith = ({ entries = [], declarations = [], config = {} } = {}) => ({ entries, declarations, config }) + +// An item as the renderers describe it: its id and owning plugin (none for host-added items) +const item = (id, pluginId) => ({ ids: [id], pluginId }) +const isHidden = (modes, target) => isHiddenByApplicationMode(modes, target) describe('selectApplicationModes', () => { - it('pairs the stack from app state with the applicationModes option from app config', () => { - const entries = [mode('draw')] - expect(selectApplicationModes({ applicationModeEntries: entries }, { applicationModes: { draw: false } })) - .toEqual({ entries, config: { draw: false } }) + it('gathers the stack, the manifests\' declarations and the consumer\'s config', () => { + const appState = { + applicationModeEntries: [mode('draw')], + pluginRegistry: { + registeredPlugins: [ + { id: 'draw', manifest: { applicationModes: { draw: { include: ['mapStyles'] } } } }, + { id: 'search', manifest: {} } + ] + } + } + expect(selectApplicationModes(appState, { applicationModes: { draw: false } })).toEqual({ + entries: [mode('draw')], + declarations: [declared('draw', 'draw', { include: ['mapStyles'] })], + config: { draw: false } + }) }) - it('defaults both halves when missing', () => { - expect(selectApplicationModes({}, undefined)).toEqual({ entries: [], config: {} }) + it('defaults everything when missing', () => { + expect(selectApplicationModes({}, undefined)).toEqual({ entries: [], declarations: [], config: {} }) }) }) describe('getCurrentApplicationMode', () => { it('is the top of the stack', () => { - expect(getCurrentApplicationMode(modesWith([mode('draw'), mode('search')]))).toEqual(mode('search')) + expect(getCurrentApplicationMode(modesWith({ entries: [mode('draw'), mode('search')] }))).toEqual(mode('search')) }) it('skips a mode the consumer\'s config disables, so the one underneath is current', () => { - expect(getCurrentApplicationMode(modesWith([mode('search'), mode('draw')], { draw: false }))).toEqual(mode('search')) + expect(getCurrentApplicationMode(modesWith({ entries: [mode('search'), mode('draw')], config: { draw: false } }))).toEqual(mode('search')) }) it('is null with an empty stack', () => { - expect(getCurrentApplicationMode(modesWith([]))).toBeNull() + expect(getCurrentApplicationMode(modesWith())).toBeNull() }) }) describe('getApplicationModeClass', () => { it('uses the current mode', () => { - expect(getApplicationModeClass(modesWith([mode('draw'), mode('search')]))).toBe('im-o-app--mode-search') + expect(getApplicationModeClass(modesWith({ entries: [mode('draw'), mode('search')] }))).toBe('im-o-app--mode-search') }) it('is null with no current mode', () => { - expect(getApplicationModeClass(modesWith([]))).toBeNull() - expect(getApplicationModeClass(modesWith([mode('draw')], { draw: false }))).toBeNull() + expect(getApplicationModeClass(modesWith())).toBeNull() + expect(getApplicationModeClass(modesWith({ entries: [mode('draw')], config: { draw: false } }))).toBeNull() }) }) describe('isHiddenByApplicationMode', () => { - it('hides nothing with no current mode, or a mode without lists', () => { - expect(isHiddenByApplicationMode(modesWith([]), ['mapKey'])).toBe(false) - expect(isHiddenByApplicationMode(modesWith([mode('search')]), ['mapKey'])).toBe(false) + it('hides nothing with no current mode, or a mode without lists anywhere', () => { + expect(isHidden(modesWith(), item('mapKey', 'mapKey'))).toBe(false) + const search = modesWith({ entries: [mode('search')], declarations: [declared('search', 'search')] }) + expect(isHidden(search, item('mapKey', 'mapKey'))).toBe(false) }) - it('shows only included items when a mode has an include list', () => { - const modes = modesWith([mode('draw', { include: ['mapStyles', 'drawUndo'] })]) - expect(isHiddenByApplicationMode(modes, ['mapStyles'])).toBe(false) - expect(isHiddenByApplicationMode(modes, ['drawUndo'])).toBe(false) - expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(true) + it('applies only the current mode, not modes underneath', () => { + const declarations = [declared('draw', 'draw', { include: ['mapStyles'] }), declared('search', 'search')] + expect(isHidden(modesWith({ entries: [mode('draw'), mode('search')], declarations }), item('mapKey', 'mapKey'))).toBe(false) + expect(isHidden(modesWith({ entries: [mode('draw')], declarations }), item('mapKey', 'mapKey'))).toBe(true) }) - it('shows an item if any of its ids is included', () => { - const modes = modesWith([mode('draw', { include: ['mapStyles'] })]) - expect(isHiddenByApplicationMode(modes, ['zoomIn', 'mapStyles'])).toBe(false) - }) + describe('a mode defined in a plugin manifest', () => { + const declarations = [declared('draw', 'draw', { include: ['mapStyles'] })] + const drawing = (overrides = {}) => modesWith({ entries: [mode('draw', overrides.call)], declarations, config: overrides.config }) - it('hides excluded items and nothing else when a mode has only an exclude list', () => { - const modes = modesWith([mode('focus', { exclude: ['search'] })]) - expect(isHiddenByApplicationMode(modes, ['search'])).toBe(true) - expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(false) - }) + it('is a takeover with an include: only included items and the declaring plugin\'s own items show', () => { + expect(isHidden(drawing(), item('mapStyles', 'mapStyles'))).toBe(false) + expect(isHidden(drawing(), item('drawUndo', 'draw'))).toBe(false) + expect(isHidden(drawing(), item('mapKey', 'mapKey'))).toBe(true) + }) - it('lets a mode\'s exclude beat its include', () => { - const modes = modesWith([mode('review', { include: ['layers'], exclude: ['layers'] })]) - expect(isHiddenByApplicationMode(modes, ['layers'])).toBe(true) - }) + it('shows an item if any of its ids is included', () => { + expect(isHidden(drawing(), { ids: ['zoomIn', 'mapStyles'] })).toBe(false) + }) - describe('the consumer\'s config adjusts the mode', () => { - const draw = mode('draw', { include: ['mapStyles', 'scaleBar'] }) + it('combines every manifest that declares the mode, keeping each declaring plugin\'s own items', () => { + const modes = modesWith({ + entries: [mode('draw')], + declarations: [...declarations, declared('draw', 'measure', { include: ['scaleBar'], exclude: ['mapStyles'] })] + }) + expect(isHidden(modes, item('scaleBar', 'scaleBar'))).toBe(false) + expect(isHidden(modes, item('measureTool', 'measure'))).toBe(false) + expect(isHidden(modes, item('mapStyles', 'mapStyles'))).toBe(true) + }) - it('appends items with include', () => { - expect(isHiddenByApplicationMode(modesWith([draw], { draw: { include: ['search'] } }), ['search'])).toBe(false) + it('lets the consumer\'s config append and remove items, without changing what it is', () => { + const modes = drawing({ config: { draw: { include: ['search'], exclude: ['mapStyles'] } } }) + expect(isHidden(modes, item('search', 'search'))).toBe(false) + expect(isHidden(modes, item('mapStyles', 'mapStyles'))).toBe(true) + expect(isHidden(modes, item('mapKey', 'mapKey'))).toBe(true) }) - it('removes items with exclude', () => { - expect(isHiddenByApplicationMode(modesWith([draw], { draw: { exclude: ['scaleBar'] } }), ['scaleBar'])).toBe(true) + it('can\'t be turned into a takeover by the consumer\'s include', () => { + const search = modesWith({ entries: [mode('search')], declarations: [declared('search', 'search')], config: { search: { include: ['myControl'] } } }) + expect(isHidden(search, item('mapKey', 'mapKey'))).toBe(false) }) - it('brings back an item the mode excluded', () => { - const focus = mode('focus', { exclude: ['search'] }) - expect(isHiddenByApplicationMode(modesWith([focus], { focus: { include: ['search'] } }), ['search'])).toBe(false) + it('applies call options last, so they have the final say', () => { + const modes = drawing({ config: { draw: { include: ['search'] } }, call: { exclude: ['search'] } }) + expect(isHidden(modes, item('search', 'search'))).toBe(true) + expect(isHidden(drawing({ call: { include: ['mapKey'] } }), item('mapKey', 'mapKey'))).toBe(false) }) - it('leaves items it doesn\'t name to the mode', () => { - const modes = modesWith([draw], { draw: { include: ['search'] } }) - expect(isHiddenByApplicationMode(modes, ['mapStyles'])).toBe(false) - expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(true) + it('lets a layer\'s exclude beat its own include', () => { + expect(isHidden(drawing({ config: { draw: { include: ['layers'], exclude: ['layers'] } } }), item('layers', 'datasets'))).toBe(true) + }) + }) + + describe('a mode no manifest declares', () => { + it('is defined by the consumer\'s config, whose include makes it a takeover', () => { + const modes = modesWith({ entries: [mode('review')], config: { review: { include: ['mapStyles'] } } }) + expect(isHidden(modes, item('mapStyles', 'mapStyles'))).toBe(false) + expect(isHidden(modes, item('mapKey', 'mapKey'))).toBe(true) }) - it('only applies to its own mode', () => { - expect(isHiddenByApplicationMode(modesWith([draw], { review: { include: ['search'] } }), ['search'])).toBe(true) + it('is defined by its call options when there\'s no config either', () => { + const modes = modesWith({ entries: [mode('review', { include: ['myButton'] })] }) + expect(isHidden(modes, item('myButton', undefined))).toBe(false) + expect(isHidden(modes, item('mapKey', 'mapKey'))).toBe(true) }) - it('ignores a mode it disables', () => { - expect(isHiddenByApplicationMode(modesWith([draw], { draw: false }), ['mapKey'])).toBe(false) + it('hides only excluded items when its definition has just an exclude', () => { + const modes = modesWith({ entries: [mode('focus')], config: { focus: { exclude: ['search'] } } }) + expect(isHidden(modes, item('search', 'search'))).toBe(true) + expect(isHidden(modes, item('mapKey', 'mapKey'))).toBe(false) }) }) - it('applies only the current mode\'s lists, not those of modes underneath', () => { - const modes = modesWith([mode('draw', { include: ['mapStyles'] }), mode('search')]) - // search is current and has no lists, so draw's include (underneath) doesn't hide anything - expect(isHiddenByApplicationMode(modes, ['mapKey'])).toBe(false) - // once search is cleared, draw is current again and its include applies - expect(isHiddenByApplicationMode(modesWith([mode('draw', { include: ['mapStyles'] })]), ['mapKey'])).toBe(true) + it('ignores a mode the consumer\'s config disables', () => { + const modes = modesWith({ entries: [mode('draw')], declarations: [declared('draw', 'draw', { include: [] })], config: { draw: false } }) + expect(isHidden(modes, item('mapKey', 'mapKey'))).toBe(false) }) }) diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index 74f37d703..7fcc94c47 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -92,9 +92,9 @@ function applySlotExclusivity (matching, appState) { // Hidden via toggleButtonState/hiddenWhen, or by an application mode. Either way the button stays // mounted (display: none), so its refs, focus-return target and state survive. -const isButtonHidden = (buttonId, appState, appConfig) => +const isButtonHidden = (buttonId, config, appState, appConfig) => appState.hiddenButtons.has(buttonId) || - isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [buttonId]) + isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [buttonId], pluginId: config.pluginId }) /** * Builds the props for a . isHidden/variant are included here (not just derived @@ -108,7 +108,7 @@ const slotButtonProps = ({ buttonId, config, appState, appConfig, evaluateProp } appState, appConfig, evaluateProp, - isHidden: isButtonHidden(buttonId, appState, appConfig), + isHidden: isButtonHidden(buttonId, config, appState, appConfig), variant: config.variant }) @@ -133,7 +133,7 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) { href={evaluateProp(config.href, config.pluginId)} showLabel={bpConfig.showLabel ?? true} isDisabled={appState.disabledButtons.has(buttonId)} - isHidden={isButtonHidden(buttonId, appState, appConfig)} + isHidden={isButtonHidden(buttonId, config, appState, appConfig)} isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined} isExpanded={(config.isExpanded !== undefined || config.expandedWhen) ? appState.expandedButtons.has(buttonId) : undefined} isPanelOpen={isPanelOpen} @@ -209,7 +209,7 @@ function buildGroupItem (key, members, ctx) { aria-label={firstConfig.group.label} className='im-c-button-group' // Hidden too once every member is, so an empty group doesn't hold a gap in its slot - style={members.every(([buttonId]) => isButtonHidden(buttonId, ctx.appState, ctx.appConfig)) ? { display: 'none' } : undefined} + style={members.every(([buttonId, config]) => isButtonHidden(buttonId, config, ctx.appState, ctx.appConfig)) ? { display: 'none' } : undefined} > {sorted.map(({ buttonId, config }) => )} diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js index 5bb7e5ce2..243404939 100755 --- a/src/App/renderer/mapControls.js +++ b/src/App/renderer/mapControls.js @@ -49,7 +49,7 @@ export function mapControls ({ slot, appState, appConfig, evaluateProp }) { ) const pluginId = plugin?.id - const isHidden = isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [control.id]) + const isHidden = isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [control.id], pluginId }) let element diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js index 8b34c4579..74271771f 100755 --- a/src/App/renderer/mapPanels.js +++ b/src/App/renderer/mapPanels.js @@ -59,7 +59,7 @@ export function mapPanels ({ slot, appState, appConfig, evaluateProp }) { const pluginId = plugin?.id // Modal panels are never hidden by an application mode, so focus is never trapped in a hidden one - const isHidden = !bpConfig.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), [panelId]) + const isHidden = !bpConfig.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [panelId], pluginId }) const html = pluginId ? evaluateProp(config.html, pluginId) : config.html const label = evaluateProp(config.label, pluginId) diff --git a/src/types.js b/src/types.js index 56c17aa1e..03ff3e44d 100644 --- a/src/types.js +++ b/src/types.js @@ -115,8 +115,9 @@ * Core services (announce, reverseGeocode, closeApp, etc.). * * @property {(id: string, options?: ApplicationModeOptions) => void} [setApplicationMode] - * Plugin components only. Enters an application mode, putting it on top of the stack, or replaces its - * lists if it's already set. See ApplicationModeOptions. + * Plugin components only. Enters an application mode, putting it on top of the stack (or moving it to + * the top if it's already set). Its lists usually come from the manifest's applicationModes; options + * adjust them for this call, applied last. * * @property {(id: string) => void} [clearApplicationMode] * Plugin components only. Leaves an application mode, so the mode underneath (if any) takes over. @@ -124,22 +125,26 @@ /** * What an application mode shows while it's the current mode (the top of the stack; modes underneath - * don't apply). The current mode adds `im-o-app--mode-{id}` to the app root. Hidden items stay mounted (display: none), so their state survives, - * and modal panels are never hidden. Without either list, nothing is hidden. + * don't apply). The current mode adds `im-o-app--mode-{id}` to the app root. A mode is defined by the + * plugin manifests that declare it (combined), else the host's applicationModes config, else the + * options it was set with; later layers append (include) and remove (exclude) items. Hidden items stay + * mounted (display: none), so their state survives, and modal panels are never hidden. Without any + * lists, nothing is hidden. * * @typedef {Object} ApplicationModeOptions * * @property {string[] | null} [include] - * Only these buttons, panels and controls (by id) stay visible. List your own items too. + * In a mode's definition: only these buttons, panels and controls (by id), plus the declaring + * plugins' own items, stay visible. In a later layer: these are added. * * @property {string[] | null} [exclude] * These buttons, panels and controls (by id) are hidden. */ /** - * The host's adjustments to application modes, keyed by mode id. A mode's include appends items to - * the lists it was set with (bringing back any it excluded); exclude removes items; false disables the - * mode entirely (no class, nothing hidden), whoever sets it. + * The host's application modes, keyed by mode id. For a mode a plugin declares, include appends + * items and exclude removes them; for any other mode it's the definition. false disables a mode + * entirely (no class, nothing hidden), whoever sets it. * * @typedef {Object} ApplicationModesConfig */ @@ -728,6 +733,11 @@ * @property {Record} [api] * API methods. * + * @property {Object} [applicationModes] + * Application modes this plugin enters (with setApplicationMode), keyed by mode id, and what each + * shows. With an include, the mode hides everything except the included items and this plugin's own + * items. Several plugins can declare the same mode; their lists combine. + * * @property {ButtonDefinition[]} [buttons] * Button definitions. * @@ -790,8 +800,8 @@ * Application colour scheme. * * @property {ApplicationModesConfig} [applicationModes] - * Adjusts or disables application modes, keyed by mode id, e.g. `{ draw: { include: ['search'] } }`. - * Applied whenever that mode is active, whoever sets it, and has the final say over the mode's own lists. + * Defines, adjusts or disables application modes, keyed by mode id, e.g. `{ draw: { include: ['search'] } }`. + * Applied whenever that mode is current, whoever sets it, after the plugins' manifests. * * @property {boolean} [autoColorScheme=false] * Whether to automatically determine the colour scheme based on system preferences. From 65b39e9418fc8cbc99c206efa690788258108fa4 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 1 Oct 2026 14:58:38 +0100 Subject: [PATCH 09/12] Tidy application mode helpers and their call sites --- src/App/layout/Layout.jsx | 4 ++- src/App/renderer/HtmlElementHost.jsx | 8 +++--- src/App/renderer/applicationModes.js | 31 ++++++++++++----------- src/App/renderer/applicationModes.test.js | 2 +- src/App/renderer/mapButtons.js | 10 +++++--- src/App/renderer/mapControls.js | 3 ++- src/App/renderer/mapPanels.js | 3 ++- src/types.js | 4 +-- 8 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx index 2a3bd57e3..429b9122c 100755 --- a/src/App/layout/Layout.jsx +++ b/src/App/layout/Layout.jsx @@ -26,6 +26,8 @@ export const Layout = () => { const { mapStyle } = useMap() const showModalBackdrop = hasOpenModalPanel(openPanels ?? {}, panelConfig ?? {}, breakpoint) + const applicationModes = selectApplicationModes(appState, appConfig) + useLayoutMeasurements() useFocusVisible() useApplicationModeFocus() @@ -39,7 +41,7 @@ export const Layout = () => { `im-o-app--${interfaceType}`, `im-o-app--${isFullscreen ? 'fullscreen' : 'inline'}`, `im-o-app--${mapStyle?.appColorScheme || preferredColorScheme}-app`, - getApplicationModeClass(selectApplicationModes(appState, appConfig)) + getApplicationModeClass(applicationModes) ].filter(Boolean).join(' ')} style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }} ref={layoutRefs.appContainerRef} diff --git a/src/App/renderer/HtmlElementHost.jsx b/src/App/renderer/HtmlElementHost.jsx index f1fbf98f5..1e8dd56bd 100644 --- a/src/App/renderer/HtmlElementHost.jsx +++ b/src/App/renderer/HtmlElementHost.jsx @@ -97,7 +97,8 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, const isEligible = Boolean(bpConfig && targetSlot && isPanelSlotEligible(config, { targetSlot, isFullscreen })) const isAllowedModal = !bpConfig?.modal || panelId === allowedModalPanelId // Modal panels are never hidden by an application mode, so focus is never trapped in a hidden one - const isModeHidden = !bpConfig?.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [panelId], pluginId: config.pluginId }) + const applicationModes = selectApplicationModes(appState, appConfig) + const isModeHidden = !bpConfig?.modal && isHiddenByApplicationMode(applicationModes, { ids: [panelId], pluginId: config.pluginId }) const isVisible = isOpen && isEligible && isAllowedModal && !isModeHidden useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint) @@ -126,8 +127,9 @@ const PersistentControl = ({ control, appState, appConfig }) => { const bpConfig = control[breakpoint] // An application mode hides it by the same display toggle, so it stays mounted either way - const isVisible = isControlVisible(control, { breakpoint, isFullscreen }) && - !isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [control.id], pluginId: control.pluginId }) + const applicationModes = selectApplicationModes(appState, appConfig) + const isModeHidden = isHiddenByApplicationMode(applicationModes, { ids: [control.id], pluginId: control.pluginId }) + const isVisible = isControlVisible(control, { breakpoint, isFullscreen }) && !isModeHidden const targetSlot = bpConfig?.slot || null // A control targeting a panel's body (`-panel`) needs its DOM anchor re-resolved diff --git a/src/App/renderer/applicationModes.js b/src/App/renderer/applicationModes.js index 828cb8fa4..a44becff9 100644 --- a/src/App/renderer/applicationModes.js +++ b/src/App/renderer/applicationModes.js @@ -42,32 +42,32 @@ export const getApplicationModeClass = (modes) => { const includesAny = (list, ids) => !!list && ids.some(id => list.includes(id)) -// One list combining a layer's sources (several manifests can contribute to the same mode) +// One list combining several manifests' lists (several manifests can contribute to the same mode) const mergeLists = (sources, key) => { const lists = sources.map(source => source?.[key]).filter(Boolean) return lists.length ? lists.flat() : null } /** - * The layers that decide the current mode, in order. The earliest layer that mentions the mode - * defines it: every plugin manifest that declares it (combined), else the consumer's config, else the - * options it was set with. Later layers only adjust it. + * The rules that decide the current mode, in order. The earliest rule that mentions the mode defines + * it: every plugin manifest that declares it (combined), else the consumer's config, else the options + * it was set with. Later rules only adjust it. */ -const getLayers = (mode, { declarations, config }) => { +const getModeRules = (mode, { declarations, config }) => { const manifests = declarations.filter(declaration => declaration.id === mode.id) - const layers = [ + const modeRules = [ manifests.length ? { include: mergeLists(manifests, 'include'), exclude: mergeLists(manifests, 'exclude') } : null, config[mode.id] || null, mode.include || mode.exclude ? { include: mode.include, exclude: mode.exclude } : null ].filter(Boolean) - return { layers, ownerIds: manifests.map(manifest => manifest.pluginId) } + return { modeRules, ownerIds: manifests.map(manifest => manifest.pluginId) } } /** - * Whether the current application mode hides an item. The mode's definition (its first layer) sets + * Whether the current application mode hides an item. The mode's definition (its first rule) sets * the starting point: with an include, only the included items and the declaring plugins' own items - * show; otherwise everything does. Then every layer in turn appends its include and removes its - * exclude, so later layers have the final say. Items are hidden with CSS, never unmounted, so their + * show; otherwise everything does. Then every rule in turn appends its include and removes its + * exclude, so later rules have the final say. Items are hidden with CSS, never unmounted, so their * state survives. * * @param {{ entries: Array, declarations: Array, config: Object }} modes - From selectApplicationModes. @@ -79,14 +79,15 @@ export const isHiddenByApplicationMode = (modes, { ids, pluginId }) => { if (!currentMode) { return false } - const { layers, ownerIds } = getLayers(currentMode, modes) - const isTakeover = !!layers[0]?.include + const { modeRules, ownerIds } = getModeRules(currentMode, modes) + // The first rule is the mode's definition, so it alone decides whether the mode is a takeover + const isTakeover = !!modeRules[0]?.include let isVisible = !isTakeover || ownerIds.includes(pluginId) - layers.forEach(layer => { - if (includesAny(layer.include, ids)) { + modeRules.forEach(rule => { + if (includesAny(rule.include, ids)) { isVisible = true } - if (includesAny(layer.exclude, ids)) { + if (includesAny(rule.exclude, ids)) { isVisible = false } }) diff --git a/src/App/renderer/applicationModes.test.js b/src/App/renderer/applicationModes.test.js index 6eacaaa49..5196d2d1d 100644 --- a/src/App/renderer/applicationModes.test.js +++ b/src/App/renderer/applicationModes.test.js @@ -118,7 +118,7 @@ describe('isHiddenByApplicationMode', () => { expect(isHidden(drawing({ call: { include: ['mapKey'] } }), item('mapKey', 'mapKey'))).toBe(false) }) - it('lets a layer\'s exclude beat its own include', () => { + it('lets a rule\'s exclude beat its own include', () => { expect(isHidden(drawing({ config: { draw: { include: ['layers'], exclude: ['layers'] } } }), item('layers', 'datasets'))).toBe(true) }) }) diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index 7fcc94c47..223c6ee6d 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -92,9 +92,13 @@ function applySlotExclusivity (matching, appState) { // Hidden via toggleButtonState/hiddenWhen, or by an application mode. Either way the button stays // mounted (display: none), so its refs, focus-return target and state survive. -const isButtonHidden = (buttonId, config, appState, appConfig) => - appState.hiddenButtons.has(buttonId) || - isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [buttonId], pluginId: config.pluginId }) +const isButtonHidden = (buttonId, config, appState, appConfig) => { + if (appState.hiddenButtons.has(buttonId)) { + return true + } + const applicationModes = selectApplicationModes(appState, appConfig) + return isHiddenByApplicationMode(applicationModes, { ids: [buttonId], pluginId: config.pluginId }) +} /** * Builds the props for a . isHidden/variant are included here (not just derived diff --git a/src/App/renderer/mapControls.js b/src/App/renderer/mapControls.js index 243404939..d23901882 100755 --- a/src/App/renderer/mapControls.js +++ b/src/App/renderer/mapControls.js @@ -12,6 +12,7 @@ import { stringToKebab } from '../../utils/stringToKebab.js' */ export function mapControls ({ slot, appState, appConfig, evaluateProp }) { const { breakpoint, pluginRegistry, controlConfig } = appState + const applicationModes = selectApplicationModes(appState, appConfig) return Object.values(controlConfig) .filter(control => { // NOSONAR, extracting to a helper wouldn't necessarily improve readability @@ -49,7 +50,7 @@ export function mapControls ({ slot, appState, appConfig, evaluateProp }) { ) const pluginId = plugin?.id - const isHidden = isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [control.id], pluginId }) + const isHidden = isHiddenByApplicationMode(applicationModes, { ids: [control.id], pluginId }) let element diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js index 74271771f..7eb87b8d7 100755 --- a/src/App/renderer/mapPanels.js +++ b/src/App/renderer/mapPanels.js @@ -43,6 +43,7 @@ export function mapPanels ({ slot, appState, appConfig, evaluateProp }) { // Only the most-recently-opened modal panel is ever actually shown — see isOpen below. const allowedModalPanelId = getAllowedModalPanelId(openPanels, panelConfig, breakpoint) + const applicationModes = selectApplicationModes(appState, appConfig) return Object.keys(panelConfig).map((panelId) => { const eligible = getEligiblePanelConfig(panelId, panelConfig, breakpoint, { slot, isFullscreen: appState.isFullscreen }) @@ -59,7 +60,7 @@ export function mapPanels ({ slot, appState, appConfig, evaluateProp }) { const pluginId = plugin?.id // Modal panels are never hidden by an application mode, so focus is never trapped in a hidden one - const isHidden = !bpConfig.modal && isHiddenByApplicationMode(selectApplicationModes(appState, appConfig), { ids: [panelId], pluginId }) + const isHidden = !bpConfig.modal && isHiddenByApplicationMode(applicationModes, { ids: [panelId], pluginId }) const html = pluginId ? evaluateProp(config.html, pluginId) : config.html const label = evaluateProp(config.label, pluginId) diff --git a/src/types.js b/src/types.js index 03ff3e44d..7c89a63e8 100644 --- a/src/types.js +++ b/src/types.js @@ -127,7 +127,7 @@ * What an application mode shows while it's the current mode (the top of the stack; modes underneath * don't apply). The current mode adds `im-o-app--mode-{id}` to the app root. A mode is defined by the * plugin manifests that declare it (combined), else the host's applicationModes config, else the - * options it was set with; later layers append (include) and remove (exclude) items. Hidden items stay + * options it was set with; later rules append (include) and remove (exclude) items. Hidden items stay * mounted (display: none), so their state survives, and modal panels are never hidden. Without any * lists, nothing is hidden. * @@ -135,7 +135,7 @@ * * @property {string[] | null} [include] * In a mode's definition: only these buttons, panels and controls (by id), plus the declaring - * plugins' own items, stay visible. In a later layer: these are added. + * plugins' own items, stay visible. In a later rule: these are added. * * @property {string[] | null} [exclude] * These buttons, panels and controls (by id) are hidden. From 9ac963a3e8d498e1f7fadeef7e528a709bf291a8 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 1 Oct 2026 16:33:52 +0100 Subject: [PATCH 10/12] Decide application mode visibility once and hide with the hidden attribute --- src/App/components/MapButton/MapButton.jsx | 4 +- src/App/components/Panel/Panel.module.scss | 5 --- src/App/components/PopupMenu/PopupMenu.jsx | 2 +- src/App/hooks/useApplicationModeFilter.js | 16 ++++++++ src/App/hooks/useLayoutMeasurements.js | 11 +++--- src/App/hooks/useLayoutMeasurements.test.js | 21 +++++++--- src/App/layout/layout.module.scss | 4 +- src/App/renderer/HtmlElementHost.jsx | 20 ++++------ src/App/renderer/SlotRenderer.jsx | 4 +- src/App/renderer/SlotRenderer.test.jsx | 11 +++++- src/App/renderer/applicationModes.js | 43 ++++++++++++--------- src/App/renderer/applicationModes.test.js | 11 ++++-- src/App/renderer/mapButtons.js | 43 +++++++++------------ src/App/renderer/mapButtons.test.js | 29 +++++++------- src/App/renderer/mapControls.js | 22 ++++------- src/App/renderer/mapControls.test.js | 25 +++++------- src/App/renderer/mapPanels.js | 16 ++++---- src/App/renderer/mapPanels.test.js | 15 +++---- src/App/renderer/slotAggregator.js | 10 +++-- src/scss/components/_control-wrapper.scss | 5 +++ src/scss/components/_index.scss | 3 +- src/scss/utilities/_state.scss | 9 ++++- 22 files changed, 181 insertions(+), 148 deletions(-) create mode 100644 src/App/hooks/useApplicationModeFilter.js create mode 100644 src/scss/components/_control-wrapper.scss diff --git a/src/App/components/MapButton/MapButton.jsx b/src/App/components/MapButton/MapButton.jsx index dfb961ba2..3a910e80e 100755 --- a/src/App/components/MapButton/MapButton.jsx +++ b/src/App/components/MapButton/MapButton.jsx @@ -196,7 +196,7 @@ const buildButtonProps = ({ * @param {boolean} [props.isDisabled=false] - Whether the button is disabled * @param {boolean} [props.isPressed] - Whether the button is in pressed state (aria-pressed) * @param {boolean} [props.isExpanded] - Whether content controlled by the button is expanded - * @param {boolean} [props.isHidden=false] - Whether to hide the button (CSS display: none) + * @param {boolean} [props.isHidden=false] - Whether to hide the button (hidden attribute, so it stays mounted) * @param {boolean} [props.isPanelOpen=false] - Whether the controlled panel is open * @param {string} [props.panelRole] - The controlled panel's own ARIA role, used for aria-haspopup * @param {string} [props.variant] - CSS variant class for styling (e.g., 'primary') @@ -306,7 +306,7 @@ export const MapButton = ({