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 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/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 30404323b..55d3642ac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,6 +58,40 @@ Uses a dark colour scheme. | --- +### `applicationModes` +**Type:** `Object` + +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` — 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. + +```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. 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'`). 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[]` | 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 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 +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 | +| `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 abd789bad..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. +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/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/docs/plugins/datasets.md b/docs/plugins/datasets.md index bc8382dcc..b56a4d7b9 100644 --- a/docs/plugins/datasets.md +++ b/docs/plugins/datasets.md @@ -92,22 +92,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 beb9de9e7..f831e7654 100644 --- a/docs/plugins/draw.md +++ b/docs/plugins/draw.md @@ -61,7 +61,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({ @@ -79,22 +79,6 @@ Plugin-level validation callback, called throughout the draw/edit lifecycle so y --- -### `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] @@ -397,9 +381,34 @@ 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), 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/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 0335b42c7..3dc2d8b25 100644 --- a/docs/plugins/plugin-context.md +++ b/docs/plugins/plugin-context.md @@ -68,33 +68,43 @@ context.pluginState.dispatch({ type: 'setActive', payload: true }) --- -### `setExclusiveControl` -**Type:** `(value: boolean | string | null) => 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. - -Only one class is ever present, for the most recent claim. Claims stack: if another plugin claims control while yours holds it, its class replaces yours, and when it releases, your class comes back. Releasing only removes your own claim, so it never affects another plugin's. Each plugin holds one claim at a time, so claiming with a new name replaces your previous one rather than stacking on it. Release in your effect's cleanup too, so a claim isn't left behind if your component unmounts. - -Your plugin's own CSS decides what to hide in response, so it can pick what suits its focus behaviour. For example, it could use `opacity: 0` to keep hidden buttons in the tab order, or `display: none` to free up their space. +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 -// 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 + } + 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. +- 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` (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--exclusive-control-my-plugin { +.im-o-app--mode-my-plugin { .im-o-app__right .im-c-button-wrapper { - display: none; + opacity: 0; } } ``` 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/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 d71e82b89..5862891d2 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -1,17 +1,15 @@ -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 { APPLICATION_MODE_ID } from './defaults.js' -// 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 } @@ -37,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 }) => { +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) @@ -53,7 +51,18 @@ 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 }) + + // 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) + return () => clearApplicationMode(APPLICATION_MODE_ID) + }, [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 diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index d72389ae8..41a064621 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -26,6 +26,8 @@ const makeProps = (overrides = {}) => { services: { eventBus: { emit: jest.fn() } }, mapProvider: { draw: null }, buttonConfig: {}, + setApplicationMode: jest.fn(), + clearApplicationMode: jest.fn(), ...overrides } return { props, adapter } @@ -60,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) @@ -266,3 +250,26 @@ describe('event attachment', () => { expect(attachEvents).not.toHaveBeenCalled() }) }) + +describe('application mode', () => { + 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') + }) + + 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.clearApplicationMode).toHaveBeenLastCalledWith('draw') + }) + + 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.clearApplicationMode).toHaveBeenCalledWith('draw') + }) +}) diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js index 47c484d52..476f94334 100644 --- a/plugins/draw/src/defaults.js +++ b/plugins/draw/src/defaults.js @@ -50,3 +50,6 @@ export const MAP_SIZE_SCALES = { medium: 1.5, large: 2 } + +// The application mode draw enters while drawing or editing (declared in manifest.js) +export const APPLICATION_MODE_ID = 'draw' 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..5b22c02ca 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -10,10 +10,9 @@ 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' -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 +23,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 = { @@ -38,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', @@ -74,49 +81,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 +158,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..f1831b842 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', () => { @@ -14,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) @@ -63,32 +68,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) - }) - - 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') +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(button => button.desktop?.slot === 'top-middle').map(button => button.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('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 +108,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 +116,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 +127,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.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/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/plugins/search/src/search.scss b/plugins/search/src/search.scss index fbfd26124..0b2ffeb00 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; } @@ -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/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 = ({