diff --git a/demo/js/index.js b/demo/js/index.js index ab3d7d0b..07dfcb8f 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -247,7 +247,7 @@ let selectedFeatureIds = [] interactiveMap.on('draw:ready', function () { // interactiveMap.addButton('drawPolygon', { // label: 'Draw polygon', - // group: 'Drawing tools', + // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, // iconSvgContent: '', // isPressed: false, // mobile: { slot: 'right-top' }, @@ -263,7 +263,7 @@ interactiveMap.on('draw:ready', function () { // }) // interactiveMap.addButton('drawLine', { // label: 'Draw line', - // group: 'Drawing tools', + // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, // iconSvgContent: '', // isPressed: false, // mobile: { slot: 'right-top' }, @@ -278,7 +278,7 @@ interactiveMap.on('draw:ready', function () { // }) // interactiveMap.addButton('editFeature', { // label: 'Edit feature', - // group: 'Drawing tools', + // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, // iconSvgContent: '', // isDisabled: true, // mobile: { slot: 'right-top' }, @@ -294,7 +294,7 @@ interactiveMap.on('draw:ready', function () { // }) // interactiveMap.addButton('deleteFeature', { // label: 'Delete feature', - // group: 'Drawing tools', + // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, // iconSvgContent: '', // isDisabled: true, // mobile: { slot: 'right-top' }, diff --git a/demo/js/planning.js b/demo/js/planning.js index 5edd21b1..a65f29ba 100755 --- a/demo/js/planning.js +++ b/demo/js/planning.js @@ -77,9 +77,11 @@ const interactiveMap = new InteractiveMap('map', { searchPlugin({ transformRequest: transformGeocodeRequest, osNamesURL: process.env.OS_NAMES_URL, + regions: ['england', 'wales'], customDatasets: [gridRefSearchOSGB36], width: '300px', - showMarker: true + showMarker: true, + // expanded: true }), useLocationPlugin(), interactPlugin, diff --git a/docs/api.md b/docs/api.md index 2f48962d..51728722 100644 --- a/docs/api.md +++ b/docs/api.md @@ -666,6 +666,51 @@ interactiveMap.toggleButtonState('opt-a', 'pressed', true) --- +### `fitToBounds(bounds)` + +Fit the map view to a bounding box or GeoJSON geometry. Safe zone padding is automatically applied so the content remains fully visible. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `bounds` | `[number, number, number, number]` | Bounds as [west, south, east, north] or [minX, minY, maxX, maxY] depending on CRS | +| `bounds` | `object` | A GeoJSON Feature, FeatureCollection, or geometry — bbox is computed automatically | + +```js +// Flat bbox +interactiveMap.fitToBounds([-0.489, 51.28, 0.236, 51.686]) + +// GeoJSON Feature +interactiveMap.fitToBounds({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [-0.1276, 51.5074] }, + properties: {} +}) + +// GeoJSON FeatureCollection +interactiveMap.fitToBounds({ + type: 'FeatureCollection', + features: [featureA, featureB] +}) +``` + +--- + +### `setView(opts)` + +Set the map center and zoom. Safe zone padding is automatically applied. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `opts` | `Object` | View options | +| `opts.center` | `[number, number]` | Optional center [lng, lat] or [easting, northing] depending on CRS | +| `opts.zoom` | `number` | Optional zoom level | + +```js +interactiveMap.setView({ center: [-0.1276, 51.5074], zoom: 12 }) +``` + +--- + ## Events Subscribe to events using `interactiveMap.on()` and unsubscribe with `interactiveMap.off()`. @@ -709,8 +754,6 @@ Emitted when the underlying map is ready and initial app state (style and size) | `map` | Object | The underlying map instance (all providers) | | `view` | Object | The map view (ESRI only) | | `crs` | string | The coordinate reference system (e.g. `'EPSG:4326'`) | -| `fitToBounds` | Function | Fit the map to a bounding box | -| `setView` | Function | Set the map center and zoom | | `mapStyleId` | string | The ID of the active map style | | `mapSize` | string | The active map size (`'small'`, `'medium'`, or `'large'`) | diff --git a/docs/api/button-definition.md b/docs/api/button-definition.md index c83597ce..a09ac028 100644 --- a/docs/api/button-definition.md +++ b/docs/api/button-definition.md @@ -64,9 +64,24 @@ Raw SVG content for the button icon. The outer `` tag should be excluded. --- ### `group` -**Type:** `string` +**Type:** `{ name: string, label?: string, order?: number }` + +Groups this button with other buttons that share the same `name`. Two or more buttons sharing a group are rendered inside a semantic `
` wrapper, collapsing their visual borders into a single grouped unit. + +- **`name`** — Internal identifier used to collect group members. Buttons with the same `name` are grouped together. +- **`label`** — Accessible label for the group, announced by screen readers. Defaults to `name` if not provided. Should be a short human-readable description (e.g. `'Zoom controls'`). +- **`order`** — The group's position within its slot, equivalent to the `order` property on singleton buttons. All buttons in the same group must share the same value. Defaults to `0`. -Button group label for grouping related buttons. +```js +// Two buttons rendered as a labelled group at slot position 2 +{ group: { name: 'zoom', label: 'Zoom controls', order: 2 }, ... } +``` + +The `order` property on each button's breakpoint config (e.g. `desktop.order`) controls the button's position *within* the group when an explicit sequence is needed. If omitted, buttons appear in their declaration order. + +> If only one button declares a given group name, it is rendered as a standalone button and the group wrapper is not created. + +> **Note:** Passing a plain string (e.g. `group: 'zoom'`) is deprecated and will log a warning in development. --- @@ -216,7 +231,9 @@ The [slot](./slots.md) where the button should appear at this breakpoint. Slots ### `order` **Type:** `number` -The order the button appears within its slot. +For **ungrouped buttons**, this is the button's position within its slot. + +For **grouped buttons**, this is the button's position *within its group*. The group's position within the slot is controlled by `group.order` instead. If omitted, buttons appear in their declaration order within the group. ### `showLabel` **Type:** `boolean` diff --git a/docs/api/panel-definition.md b/docs/api/panel-definition.md index e1c67e78..07e1e9b0 100644 --- a/docs/api/panel-definition.md +++ b/docs/api/panel-definition.md @@ -83,7 +83,7 @@ Each breakpoint (`mobile`, `tablet`, `desktop`) accepts the following properties The [slot](./slots.md) where the panel should appear at this breakpoint. Slots are named regions in the UI layout. -### `dismissable` +### `dismissible` **Type:** `boolean` Whether the panel can be dismissed (closed) by the user. When `false` and `open` is `true`, the panel is always visible at this breakpoint and any associated panel-toggle button is automatically suppressed. @@ -96,7 +96,7 @@ Whether the panel is exclusive. An exclusive panel will hide other panels when i ### `open` **Type:** `boolean` -Whether the panel is open. When `true` and combined with `dismissable: false`, the panel is always visible at this breakpoint and will be restored automatically when the breakpoint is entered. +Whether the panel is open. When `true` and combined with `dismissible: false`, the panel is always visible at this breakpoint and will be restored automatically when the breakpoint is entered. ### `showLabel` **Type:** `boolean` diff --git a/docs/plugins/plugin-manifest.md b/docs/plugins/plugin-manifest.md index c110e3ad..d9840e1f 100644 --- a/docs/plugins/plugin-manifest.md +++ b/docs/plugins/plugin-manifest.md @@ -92,7 +92,7 @@ const InitComponent = ({ context }) => { Panel definitions to register in the UI. -Panels come with pre-built behaviour and styling, including headings, dismissable states, and modal overlays. Supports responsive breakpoint configuration. +Panels come with pre-built behaviour and styling, including headings, dismissible states, and modal overlays. Supports responsive breakpoint configuration. See [PanelDefinition](../api/panel-definition.md) for full details. diff --git a/package-lock.json b/package-lock.json index 1dbfba10..1a80ed2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@docusaurus/theme-common": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.0", "@turf/area": "^7.2.0", + "@turf/bbox": "^7.3.4", "@turf/bearing": "^7.3.3", "@turf/boolean-disjoint": "^7.3.3", "@turf/boolean-valid": "^7.2.0", @@ -9110,11 +9111,40 @@ } }, "node_modules/@turf/bbox": { - "version": "7.3.3", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.4.tgz", + "integrity": "sha512-D5ErVWtfQbEPh11yzI69uxqrcJmbPU/9Y59f1uTapgwAwQHQztDWgsYpnL3ns8r1GmPWLP8sGJLVTIk2TZSiYA==", "license": "MIT", "dependencies": { - "@turf/helpers": "7.3.3", - "@turf/meta": "7.3.3", + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox/node_modules/@turf/helpers": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz", + "integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox/node_modules/@turf/meta": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.4.tgz", + "integrity": "sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -9276,6 +9306,21 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@turf/boolean-valid/node_modules/@turf/bbox": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.3.tgz", + "integrity": "sha512-1zNO/JUgDp0N+3EG5fG7+8EolE95OW1LD8ur0hRP0JK+lRyN0gAvJT7n1I9pu/NIqTa8x/zXxGRc1dcOdohYkg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.3", + "@turf/meta": "7.3.3", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@turf/circle": { "version": "7.3.3", "dev": true, @@ -9357,6 +9402,21 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@turf/geojson-rbush/node_modules/@turf/bbox": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.3.tgz", + "integrity": "sha512-1zNO/JUgDp0N+3EG5fG7+8EolE95OW1LD8ur0hRP0JK+lRyN0gAvJT7n1I9pu/NIqTa8x/zXxGRc1dcOdohYkg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.3", + "@turf/meta": "7.3.3", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@turf/helpers": { "version": "7.3.3", "license": "MIT", diff --git a/package.json b/package.json index e0f567a5..e99a92d9 100755 --- a/package.json +++ b/package.json @@ -197,6 +197,7 @@ "@docusaurus/theme-common": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.0", "@turf/area": "^7.2.0", + "@turf/bbox": "^7.3.4", "@turf/bearing": "^7.3.3", "@turf/boolean-disjoint": "^7.3.3", "@turf/boolean-valid": "^7.2.0", diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index d845a5b3..9a6d6658 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -24,18 +24,18 @@ export const manifest = { mobile: { slot: 'bottom', modal: true, - dismissable: true + dismissible: true }, tablet: { slot: 'inset', - dismissable: true, + dismissible: true, exclusive: true, width: '300px' }, desktop: { slot: 'inset', modal: false, - dismissable: true, + dismissible: true, exclusive: true, width: '320px' }, diff --git a/plugins/beta/map-styles/src/manifest.js b/plugins/beta/map-styles/src/manifest.js index 32995517..34954279 100755 --- a/plugins/beta/map-styles/src/manifest.js +++ b/plugins/beta/map-styles/src/manifest.js @@ -11,19 +11,19 @@ export const manifest = { mobile: { slot: 'bottom', modal: true, - dismissable: true + dismissible: true }, tablet: { slot: 'inset', modal: true, width: '400px', - dismissable: true + dismissible: true }, desktop: { slot: 'inset', modal: true, width: '400px', - dismissable: true + dismissible: true }, render: MapStyles // will be wrapped automatically }], diff --git a/plugins/beta/use-location/src/manifest.js b/plugins/beta/use-location/src/manifest.js index 85bb8540..1905f799 100755 --- a/plugins/beta/use-location/src/manifest.js +++ b/plugins/beta/use-location/src/manifest.js @@ -18,7 +18,7 @@ export const manifest = { buttons: [{ id: 'useLocation', - group: 'location', + group: { name: 'location', label: 'Location', order: 0 }, label: 'Use your location', iconId: 'locateFixed', hiddenWhen: () => !navigator.geolocation, @@ -33,19 +33,19 @@ export const manifest = { mobile: { slot: 'banner', open: false, - dismissable: true, + dismissible: true, modal: true }, tablet: { slot: 'banner', open: false, - dismissable: true, + dismissible: true, modal: true }, desktop: { slot: 'banner', open: false, - dismissable: true, + dismissible: true, modal: true }, render: UseLocation diff --git a/plugins/search/src/Search.jsx b/plugins/search/src/Search.jsx index 540dc1c1..a928ad90 100755 --- a/plugins/search/src/Search.jsx +++ b/plugins/search/src/Search.jsx @@ -3,13 +3,14 @@ import { useRef, useEffect } from 'react' import { OpenButton } from './components/OpenButton/OpenButton' import { Form } from './components/Form/Form' import { CloseButton } from './components/CloseButton/CloseButton' +import { SubmitButton } from './components/SubmitButton/SubmitButton' import { createDatasets } from './datasets.js' import { attachEvents } from './events/index.js' export function Search({ appConfig, iconRegistry, pluginState, pluginConfig, appState, mapState, services, mapProvider }) { const { id } = appConfig const { interfaceType } = appState - const { expanded: defaultExpanded, customDatasets, osNamesURL } = pluginConfig + const { expanded: defaultExpanded, customDatasets, osNamesURL, regions } = pluginConfig const { dispatch, isExpanded, areSuggestionsVisible, suggestions } = pluginState const closeIcon = iconRegistry['close'] @@ -17,13 +18,13 @@ export function Search({ appConfig, iconRegistry, pluginState, pluginConfig, app const searchContainerRef = useRef(null) const buttonRef = useRef(null) const inputRef = useRef(null) - const closeButtonRef = useRef(null) const viewportRef = appState.layoutRefs.viewportRef // Build datasets array from default plus custom const mergedDatasets = createDatasets({ customDatasets, osNamesURL, + regions, crs: mapProvider.crs }) @@ -93,12 +94,17 @@ export function Search({ appConfig, iconRegistry, pluginState, pluginConfig, app appState={appState} inputRef={inputRef} events={events} + services={services} > events.handleCloseClick(e, buttonRef, appState)} closeIcon={closeIcon} - ref={closeButtonRef} + /> + events.handleCloseClick(e, buttonRef, appState)} + submitIcon={searchIcon} />
diff --git a/plugins/search/src/Search.test.jsx b/plugins/search/src/Search.test.jsx index 25849447..4c163b21 100644 --- a/plugins/search/src/Search.test.jsx +++ b/plugins/search/src/Search.test.jsx @@ -21,6 +21,14 @@ jest.mock('./components/CloseButton/CloseButton', () => ({ ), })) +jest.mock('./components/SubmitButton/SubmitButton', () => ({ + SubmitButton: ({ defaultExpanded, submitIcon, onClick }) => ( + + ), +})) + jest.mock('./components/Form/Form', () => ({ Form: ({ children }) =>
{children}
, })) @@ -114,6 +122,18 @@ describe('Search component', () => { expect(events.handleOpenClick).toHaveBeenCalledTimes(1) }) + it('renders SubmitButton when expanded is false', () => { + render() + expect(screen.getByTestId('submit-button')).toBeInTheDocument() + }) + + it('SubmitButton click triggers handleCloseClick', () => { + render() + const events = attachEvents.mock.results[0].value + fireEvent.click(screen.getByTestId('submit-button')) + expect(events.handleCloseClick).toHaveBeenCalledTimes(1) + }) + it('CloseButton click triggers handleCloseClick', () => { render() const events = attachEvents.mock.results[0].value diff --git a/plugins/search/src/components/Form/Form.jsx b/plugins/search/src/components/Form/Form.jsx index 8948d406..6ce9210f 100755 --- a/plugins/search/src/components/Form/Form.jsx +++ b/plugins/search/src/components/Form/Form.jsx @@ -1,6 +1,20 @@ // src/plugins/search/Form.jsx +import { useEffect } from 'react' import { Suggestions } from '../Suggestions/Suggestions' +const getResultMessage = (count) => { + if (count === 0) { + return 'No results available' + } + const plural = count === 1 ? 'result' : 'results' + return `${count} ${plural} available` +} + +const getFormStyle = (pluginConfig, pluginState, appState) => ({ + display: pluginConfig.expanded || pluginState.isExpanded ? 'flex' : undefined, + ...(appState.breakpoint !== 'mobile' && pluginConfig?.width && { width: pluginConfig.width }), +}) + export const Form = ({ id, pluginState, @@ -8,8 +22,19 @@ export const Form = ({ appState, inputRef, events, + services, children, // For SearchClose }) => { + const { areSuggestionsVisible, hasFetchedSuggestions, suggestions = [] } = pluginState + + // Announce when a fetch has completed (hasFetchedSuggestions flips to true), + // not when the input is merely focused/clicked (SHOW_SUGGESTIONS resets it to false). + useEffect(() => { + if (!areSuggestionsVisible || !hasFetchedSuggestions) { + return + } + services.announce(getResultMessage(suggestions.length)) + }, [suggestions, hasFetchedSuggestions]) const classNames = [ 'im-c-search-form', @@ -17,15 +42,14 @@ export const Form = ({ 'im-c-panel' ].filter(Boolean).join(' ') + const showNoResults = areSuggestionsVisible && hasFetchedSuggestions && !suggestions.length + return (