Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demo/draw-ol.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draw tools demo (OpenLayers)</title>
<link href="/assets/govuk-frontend.min.css" rel="stylesheet" media="all">
<link href="./index.css" rel="stylesheet" media="all">
<link href="./draw-ol.css" rel="stylesheet" media="all">
</head>
<body style="padding: 20px">
<script>document.body.classList.add('im-is-loading')</script>
Expand Down
8 changes: 7 additions & 1 deletion demo/draw.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React TypeScript Webpack App</title>
<link href="/assets/govuk-frontend.min.css" rel="stylesheet" media="all">
<link href="./index.css" rel="stylesheet" media="all">
<link href="./draw.css" rel="stylesheet" media="all">
<style>
:root {
--button-border-radius: 4px;
--panel-border-radius: 6px;
}
</style>
</head>
<body style="padding: 20px">
<script>document.body.classList.add('im-is-loading')</script>
Expand Down
6 changes: 6 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
<title>React TypeScript Webpack App</title>
<link href="/assets/govuk-frontend.min.css" rel="stylesheet" media="all">
<link href="./index.css" rel="stylesheet" media="all">
<style>
:root {
--button-border-radius: 4px;
--panel-border-radius: 6px;
}
</style>
</head>
<body style="padding: 20px">
<script>document.body.classList.add('im-is-loading')</script>
Expand Down
5 changes: 1 addition & 4 deletions demo/js/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 1 addition & 5 deletions demo/js/esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/api/button-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
48 changes: 46 additions & 2 deletions docs/plugins/draw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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[]`
Expand Down Expand Up @@ -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 |
|----------|--------|
Expand Down
18 changes: 17 additions & 1 deletion docs/plugins/plugin-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
55 changes: 53 additions & 2 deletions plugins/draw/src/DrawInit.jsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
110 changes: 109 additions & 1 deletion plugins/draw/src/DrawInit.test.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -26,6 +26,7 @@ const makeProps = (overrides = {}) => {
services: { eventBus: { emit: jest.fn() } },
mapProvider: { draw: null },
buttonConfig: {},
setExclusiveControl: jest.fn(),
...overrides
}
return { props, adapter }
Expand Down Expand Up @@ -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(<DrawInit {...props} pluginState={{ dispatch: jest.fn(), mode: null }} />)
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(<DrawInit {...props} pluginState={{ dispatch: jest.fn(), mode: null }} />)
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([])
})
})
6 changes: 6 additions & 0 deletions plugins/draw/src/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Loading
Loading