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/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/demo/js/draw.js b/demo/js/draw.js index 30cf992d6..990610368 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/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 015c4a368..e0e47e611 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,50 @@ 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[]` @@ -396,7 +440,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/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 d71e82b89..cda9f63e2 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -1,8 +1,40 @@ -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' 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. @@ -40,7 +72,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 +87,25 @@ 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: 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 + } + 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]) + // 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..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' @@ -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,110 @@ describe('event attachment', () => { expect(attachEvents).not.toHaveBeenCalled() }) }) + +describe('exclusive control', () => { + 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, { 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.mock.lastCall[0]).toBe(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() + }) +}) + +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 1b9dfe0e4..007d6ddb7 100644 --- a/plugins/draw/src/draw.scss +++ b/plugins/draw/src/draw.scss @@ -25,19 +25,10 @@ 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%; - } -} -.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; - } +// 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); } diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index b80272be9..3fc994a51 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,13 @@ const undoCommand = isMac() ? 'Command + Z' : 'CtrlOption' : 'Alt' -const createButtonSlots = (showLabel) => ({ - mobile: { slot: 'actions', showLabel }, - tablet: { slot: 'actions', showLabel }, - desktop: { slot: 'actions', showLabel } +// 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 }, + desktop: { slot, showLabel } }) export const manifest = { @@ -74,49 +75,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 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', + // 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, TOOLBAR_SLOT) + }, + { + id: 'drawSnap', + label: 'Snap', + iconId: 'magnet', + hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, + pressedWhen: ({ pluginState }) => !!pluginState.snap, + ...createButtonSlots(true, TOOLBAR_SLOT) + }, + { + 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, TOOLBAR_SLOT) } ], @@ -158,9 +152,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) diff --git a/plugins/search/src/search.scss b/plugins/search/src/search.scss index da54c811b..b7b816d29 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/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/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/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..513050001 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,18 @@ 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. The + // --hidden modifier lets slot CSS ignore it (e.g. the header's trailing gap). + element = ( +
+ +
+ ) } return { diff --git a/src/App/renderer/mapControls.test.js b/src/App/renderer/mapControls.test.js index 83cf4d26c..188853feb 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).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' }) + }) + 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. */ /**