@defra/interactive-map 0.0.41-alpha → 0.0.43-fmp-experimental
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/button-definition.md +7 -8
- package/docs/api/control-definition.md +8 -0
- package/docs/api/panel-definition.md +5 -0
- package/docs/api/slots.md +24 -1
- package/docs/examples/add-polygons.mdx +8 -2
- package/docs/examples/add-symbols.mdx +8 -2
- package/docs/plugins/datasets.md +23 -15
- package/docs/plugins/map-key.md +147 -0
- package/docs/plugins.md +5 -1
- package/package.json +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +2 -25
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/src/manifest.js +1 -1
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/src/registry/dataset.js +12 -0
- package/plugins/datasets/src/registry/datasetRegistry.js +4 -2
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/index.js +1 -1
- package/plugins/draw/src/api/createNewShape.js +41 -0
- package/plugins/draw/src/api/createNewShape.test.js +63 -0
- package/plugins/draw/src/api/newLine.js +2 -40
- package/plugins/draw/src/api/newPolygon.js +2 -40
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/manifest.js +1 -1
- package/plugins/map-key/dist/css/index.css +55 -11
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/index.js +1 -1
- package/plugins/map-key/src/components/Key/Key.jsx +6 -3
- package/plugins/map-key/src/components/Key/Key.module.scss +66 -13
- package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +12 -8
- package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +10 -0
- package/plugins/map-key/src/components/Key/KeyItem.jsx +9 -12
- package/plugins/map-key/src/components/Key/KeyItem.test.jsx +3 -3
- package/plugins/map-key/src/components/Key/KeySvg.jsx +27 -22
- package/plugins/map-key/src/components/Key/KeySvg.test.jsx +9 -0
- package/plugins/map-key/src/components/Key/KeySvgLine.jsx +3 -9
- package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +2 -7
- package/plugins/map-key/src/components/Key/KeySvgPattern.test.jsx +6 -0
- package/plugins/map-key/src/components/Key/KeySvgRamp.jsx +13 -0
- package/plugins/map-key/src/components/Key/KeySvgRamp.test.jsx +43 -0
- package/plugins/map-key/src/components/Key/KeySvgRect.jsx +4 -11
- package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +5 -11
- package/plugins/map-key/src/components/Key/KeySvgSymbol.test.jsx +12 -0
- package/plugins/map-key/src/components/Key/MapKey.jsx +5 -2
- package/plugins/map-key/src/components/Key/MapKey.test.jsx +28 -11
- package/plugins/map-key/src/components/Key/svgProperties.js +2 -2
- package/plugins/map-key/src/components/Key/svgProperties.test.js +2 -2
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
- package/src/App/components/Panel/Panel.jsx +50 -8
- package/src/App/components/Panel/Panel.module.scss +31 -7
- package/src/App/components/Panel/Panel.test.jsx +81 -2
- package/src/App/components/Tabs/Tabs.jsx +43 -17
- package/src/App/components/Tabs/Tabs.module.scss +32 -5
- package/src/App/components/Tabs/Tabs.test.jsx +40 -10
- package/src/App/renderer/groupByKey.js +28 -0
- package/src/App/renderer/groupByKey.test.js +39 -0
- package/src/App/renderer/groupIntoTabs.js +42 -0
- package/src/App/renderer/groupIntoTabs.test.js +95 -0
- package/src/App/renderer/mapButtons.js +74 -122
- package/src/App/renderer/mapButtons.test.js +51 -80
- package/src/App/renderer/mapControls.js +1 -0
- package/src/App/renderer/mapControls.test.js +16 -0
- package/src/App/renderer/mapPanels.js +26 -19
- package/src/App/renderer/mapPanels.test.js +44 -0
- package/src/config/appConfig.js +2 -2
- package/src/types.js +20 -2
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import React, { useState, useCallback } from 'react'
|
|
2
|
+
import { useConfig } from '../../store/configContext'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
// Prefixed with the app's own id (not the im-c- BEM prefix, which is for CSS classes, not DOM
|
|
5
|
+
// ids — see Panel.jsx's/MapButton.jsx's own ${id}-... ids for the same convention) so these
|
|
6
|
+
// stay unique when more than one map instance is on the same page.
|
|
7
|
+
const toTabId = (idPrefix, name) => `${idPrefix}-tabs-tab-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
|
|
8
|
+
const toPanelId = (idPrefix, name) => `${idPrefix}-tabs-panel-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
|
|
5
9
|
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} [panelProps] Extra props merged onto the tabpanel div (not the tablist) —
|
|
12
|
+
* e.g. a scrollable container's ref/className/tabIndex, for a caller (Panel.jsx) that needs
|
|
13
|
+
* that behaviour scoped to just the active tab's content, not the tablist above it. `id`,
|
|
14
|
+
* `role` and `aria-labelledby` are structural to the tabs pattern and always win over any
|
|
15
|
+
* same-named key in `panelProps`; `tabIndex` defers to `panelProps.tabIndex` when provided,
|
|
16
|
+
* since a scrollable tabpanel legitimately wants tabIndex 0 rather than the -1 default.
|
|
17
|
+
*/
|
|
6
18
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
7
19
|
// sonarjs/disable-next-line function-name
|
|
8
|
-
export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use PropTypes
|
|
20
|
+
export const Tabs = ({ tabs, defaultTab, panelProps }) => { // NOSONAR: project does not use PropTypes
|
|
21
|
+
const { id: idPrefix } = useConfig()
|
|
9
22
|
const names = tabs.map(t => t.name)
|
|
10
23
|
const [activeTab, setActiveTab] = useState(defaultTab ?? names[0])
|
|
11
24
|
|
|
12
25
|
const activateTab = useCallback((name) => {
|
|
13
26
|
setActiveTab(name)
|
|
14
|
-
document.getElementById(toTabId(name))?.focus()
|
|
15
|
-
}, [])
|
|
27
|
+
document.getElementById(toTabId(idPrefix, name))?.focus()
|
|
28
|
+
}, [idPrefix])
|
|
16
29
|
|
|
17
30
|
const handleKeyDown = useCallback((e) => {
|
|
18
31
|
const idx = names.indexOf(activeTab)
|
|
@@ -33,7 +46,7 @@ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use
|
|
|
33
46
|
}
|
|
34
47
|
}, [names, activeTab, activateTab])
|
|
35
48
|
|
|
36
|
-
const
|
|
49
|
+
const panelClassName = ['im-c-tabs__panel', panelProps?.className].filter(Boolean).join(' ')
|
|
37
50
|
|
|
38
51
|
return (
|
|
39
52
|
<div className='im-c-tabs'>
|
|
@@ -42,10 +55,10 @@ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use
|
|
|
42
55
|
<button
|
|
43
56
|
type='button'
|
|
44
57
|
key={name}
|
|
45
|
-
id={toTabId(name)}
|
|
58
|
+
id={toTabId(idPrefix, name)}
|
|
46
59
|
role='tab'
|
|
47
60
|
aria-selected={activeTab === name}
|
|
48
|
-
aria-controls={toPanelId(name)}
|
|
61
|
+
aria-controls={toPanelId(idPrefix, name)}
|
|
49
62
|
tabIndex={activeTab === name ? 0 : -1}
|
|
50
63
|
className='im-c-tabs__tab'
|
|
51
64
|
onClick={() => activateTab(name)}
|
|
@@ -55,15 +68,28 @@ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use
|
|
|
55
68
|
</button>
|
|
56
69
|
))}
|
|
57
70
|
</div>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
{/* All tabpanels render, hidden unless active — not swapped in/out — so every tab's
|
|
72
|
+
aria-controls always resolves to a real element, rather than only the active one's.
|
|
73
|
+
panelProps (ref/tabIndex — a scrollable container's, from a caller like Panel.jsx)
|
|
74
|
+
can only meaningfully target one DOM node, so it's applied to the active panel only;
|
|
75
|
+
className is shared by all of them so there's nothing to restyle on switch. */}
|
|
76
|
+
{tabs.map(({ name, content }) => {
|
|
77
|
+
const isActive = activeTab === name
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
key={name}
|
|
81
|
+
ref={isActive ? panelProps?.ref : undefined}
|
|
82
|
+
id={toPanelId(idPrefix, name)}
|
|
83
|
+
role='tabpanel'
|
|
84
|
+
aria-labelledby={toTabId(idPrefix, name)}
|
|
85
|
+
tabIndex={isActive ? (panelProps?.tabIndex ?? -1) : -1} // nosonar — panel has no focusable children by default (-1); a scrollable active one wants 0, via panelProps
|
|
86
|
+
hidden={!isActive}
|
|
87
|
+
className={panelClassName}
|
|
88
|
+
>
|
|
89
|
+
{content}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
})}
|
|
67
93
|
</div>
|
|
68
94
|
)
|
|
69
95
|
}
|
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
// Component: Tabs
|
|
5
5
|
// ===================================================
|
|
6
6
|
|
|
7
|
+
// Lets a caller (e.g. Panel.jsx) give Tabs a bounded height — only .im-c-tabs__panel scrolls.
|
|
8
|
+
.im-c-tabs {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
min-height: 0;
|
|
12
|
+
flex: 1 1 auto;
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
.im-c-tabs__list {
|
|
8
16
|
position: relative;
|
|
9
17
|
display: flex;
|
|
18
|
+
flex: 0 0 auto;
|
|
10
19
|
flex-wrap: wrap;
|
|
11
20
|
padding: 0;
|
|
12
21
|
margin: 0;
|
|
@@ -14,8 +23,14 @@
|
|
|
14
23
|
z-index: 1;
|
|
15
24
|
}
|
|
16
25
|
|
|
26
|
+
.im-c-tabs__panel {
|
|
27
|
+
flex: 1 1 auto;
|
|
28
|
+
overflow: auto;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
.im-c-tabs__tab {
|
|
18
32
|
position: relative;
|
|
33
|
+
z-index: 0; // scopes the active tab's ::before (below) to this button's own stacking context
|
|
19
34
|
appearance: none;
|
|
20
35
|
background: var(--button-hover-color);
|
|
21
36
|
border: 1px solid transparent;
|
|
@@ -42,12 +57,24 @@
|
|
|
42
57
|
}
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
// The selected tab looks taller via an absolutely-positioned ::before rather than changing
|
|
61
|
+
// the button's own box, so the label never moves and the tablist's height never shifts.
|
|
45
62
|
&[aria-selected='true'] {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
63
|
+
z-index: 1; // sit above unselected siblings
|
|
64
|
+
background: transparent;
|
|
65
|
+
|
|
66
|
+
&::before {
|
|
67
|
+
content: '';
|
|
68
|
+
position: absolute;
|
|
69
|
+
z-index: -1;
|
|
70
|
+
top: -5px;
|
|
71
|
+
right: -1px;
|
|
72
|
+
bottom: -6px;
|
|
73
|
+
left: -1px;
|
|
74
|
+
background: var(--background-color);
|
|
75
|
+
border: 1px solid var(--app-border-color);
|
|
76
|
+
border-bottom-color: var(--background-color);
|
|
77
|
+
}
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
&:focus .im-c-tabs__label {
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
3
|
import { Tabs } from './Tabs'
|
|
4
|
+
import { useConfig } from '../../store/configContext'
|
|
5
|
+
|
|
6
|
+
jest.mock('../../store/configContext', () => ({ useConfig: jest.fn() }))
|
|
4
7
|
|
|
5
8
|
const TAB_A = { name: 'Alpha', content: <p>Alpha content</p> }
|
|
6
9
|
const TAB_B = { name: 'Beta', content: <p>Beta content</p> }
|
|
7
10
|
const TAB_C = { name: 'Gamma', content: <p>Gamma content</p> }
|
|
8
11
|
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
useConfig.mockReturnValue({ id: 'test-app' })
|
|
14
|
+
})
|
|
15
|
+
|
|
9
16
|
// ─── rendering ───────────────────────────────────────────────────────────────
|
|
10
17
|
|
|
11
18
|
describe('Tabs — rendering', () => {
|
|
@@ -15,17 +22,17 @@ describe('Tabs — rendering', () => {
|
|
|
15
22
|
expect(screen.getByRole('tab', { name: 'Beta' })).toBeInTheDocument()
|
|
16
23
|
})
|
|
17
24
|
|
|
18
|
-
it('shows content for the active tab only', () => {
|
|
25
|
+
it('shows content for the active tab only — the inactive one stays in the DOM (so its aria-controls target exists) but hidden', () => {
|
|
19
26
|
render(<Tabs tabs={[TAB_A, TAB_B]} />)
|
|
20
|
-
expect(screen.getByText('Alpha content')).
|
|
21
|
-
expect(screen.
|
|
27
|
+
expect(screen.getByText('Alpha content')).toBeVisible()
|
|
28
|
+
expect(screen.getByText('Beta content')).not.toBeVisible()
|
|
22
29
|
})
|
|
23
30
|
|
|
24
31
|
it('uses defaultTab to set the initial active tab', () => {
|
|
25
32
|
render(<Tabs tabs={[TAB_A, TAB_B]} defaultTab='Beta' />)
|
|
26
33
|
expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
|
|
27
|
-
expect(screen.getByText('Beta content')).
|
|
28
|
-
expect(screen.
|
|
34
|
+
expect(screen.getByText('Beta content')).toBeVisible()
|
|
35
|
+
expect(screen.getByText('Alpha content')).not.toBeVisible()
|
|
29
36
|
})
|
|
30
37
|
|
|
31
38
|
it('falls back to first tab when defaultTab is not provided', () => {
|
|
@@ -41,8 +48,8 @@ describe('Tabs — click behaviour', () => {
|
|
|
41
48
|
render(<Tabs tabs={[TAB_A, TAB_B]} />)
|
|
42
49
|
fireEvent.click(screen.getByRole('tab', { name: 'Beta' }))
|
|
43
50
|
expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
|
|
44
|
-
expect(screen.getByText('Beta content')).
|
|
45
|
-
expect(screen.
|
|
51
|
+
expect(screen.getByText('Beta content')).toBeVisible()
|
|
52
|
+
expect(screen.getByText('Alpha content')).not.toBeVisible()
|
|
46
53
|
})
|
|
47
54
|
})
|
|
48
55
|
|
|
@@ -55,10 +62,33 @@ describe('Tabs — WCAG attributes', () => {
|
|
|
55
62
|
expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('tabindex', '-1')
|
|
56
63
|
})
|
|
57
64
|
|
|
58
|
-
it('
|
|
65
|
+
it('every tab has aria-controls pointing to a real element — including inactive tabs, since their panel stays in the DOM (hidden) rather than being unmounted', () => {
|
|
66
|
+
render(<Tabs tabs={[TAB_A, TAB_B]} />)
|
|
67
|
+
const activeTab = screen.getByRole('tab', { name: 'Alpha' })
|
|
68
|
+
const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
|
|
69
|
+
expect(document.getElementById(activeTab.getAttribute('aria-controls'))).toBeInTheDocument()
|
|
70
|
+
expect(document.getElementById(inactiveTab.getAttribute('aria-controls'))).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('gives every tabpanel its own stable id, distinct per tab, not just the active one', () => {
|
|
74
|
+
render(<Tabs tabs={[TAB_A, TAB_B]} />)
|
|
75
|
+
const activeTab = screen.getByRole('tab', { name: 'Alpha' })
|
|
76
|
+
const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
|
|
77
|
+
expect(activeTab.getAttribute('aria-controls')).not.toBe(inactiveTab.getAttribute('aria-controls'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('hides the inactive tabpanel via the hidden attribute rather than unmounting it', () => {
|
|
81
|
+
render(<Tabs tabs={[TAB_A, TAB_B]} />)
|
|
82
|
+
const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
|
|
83
|
+
const inactivePanel = document.getElementById(inactiveTab.getAttribute('aria-controls'))
|
|
84
|
+
expect(inactivePanel).toHaveAttribute('hidden')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("labels every tabpanel with its own tab, not just the active one's", () => {
|
|
59
88
|
render(<Tabs tabs={[TAB_A, TAB_B]} />)
|
|
60
|
-
const
|
|
61
|
-
|
|
89
|
+
const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
|
|
90
|
+
const inactivePanel = document.getElementById(inactiveTab.getAttribute('aria-controls'))
|
|
91
|
+
expect(inactivePanel.getAttribute('aria-labelledby')).toBe(inactiveTab.id)
|
|
62
92
|
})
|
|
63
93
|
|
|
64
94
|
it('panel has aria-labelledby pointing to the active tab', () => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { stringToKebab } from '../../utils/stringToKebab.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Partitions a list of items into buckets, keyed by `keyFn(item)` kebab-cased — two items
|
|
5
|
+
* share a bucket by producing the same key (so 'Map Size' and 'map size' merge). Items for
|
|
6
|
+
* which `keyFn` returns a falsy value all share one `null`-keyed bucket. Bucket iteration
|
|
7
|
+
* order follows first-encountered order, same as `Map`.
|
|
8
|
+
*
|
|
9
|
+
* Shared by `groupIntoTabs` (panel/control tabs) and `mapButtons` (button groups) — the two
|
|
10
|
+
* callers differ only in what happens *after* bucketing (how a bucket's own order/label is
|
|
11
|
+
* derived), not in how items get partitioned.
|
|
12
|
+
*
|
|
13
|
+
* @returns {Map<string|null, object[]>}
|
|
14
|
+
*/
|
|
15
|
+
export function groupByKey ({ items, keyFn }) {
|
|
16
|
+
const buckets = new Map()
|
|
17
|
+
|
|
18
|
+
for (const item of items) {
|
|
19
|
+
const raw = keyFn(item)
|
|
20
|
+
const key = raw ? stringToKebab(raw) : null
|
|
21
|
+
if (!buckets.has(key)) {
|
|
22
|
+
buckets.set(key, [])
|
|
23
|
+
}
|
|
24
|
+
buckets.get(key).push(item)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return buckets
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { groupByKey } from './groupByKey.js'
|
|
2
|
+
|
|
3
|
+
describe('groupByKey', () => {
|
|
4
|
+
it('returns one bucket per distinct key', () => {
|
|
5
|
+
const items = [{ id: 'a', k: 'Styles' }, { id: 'b', k: 'Sizes' }]
|
|
6
|
+
const buckets = groupByKey({ items, keyFn: item => item.k })
|
|
7
|
+
expect([...buckets.keys()].sort()).toEqual(['sizes', 'styles'])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('lowercases the key, hyphenating only camelCase-style boundaries (stringToKebab)', () => {
|
|
11
|
+
const items = [{ id: 'a', k: 'mapSize' }]
|
|
12
|
+
const buckets = groupByKey({ items, keyFn: item => item.k })
|
|
13
|
+
expect([...buckets.keys()]).toEqual(['map-size'])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('merges items whose key differs only by case/whitespace into one bucket', () => {
|
|
17
|
+
const items = [{ id: 'a', k: 'Map Size' }, { id: 'b', k: 'map size' }]
|
|
18
|
+
const buckets = groupByKey({ items, keyFn: item => item.k })
|
|
19
|
+
expect(buckets.size).toBe(1)
|
|
20
|
+
expect([...buckets.values()][0]).toHaveLength(2)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('puts items with no key into one null-keyed bucket', () => {
|
|
24
|
+
const items = [{ id: 'a' }, { id: 'b' }]
|
|
25
|
+
const buckets = groupByKey({ items, keyFn: () => null })
|
|
26
|
+
expect(buckets.size).toBe(1)
|
|
27
|
+
expect(buckets.get(null)).toHaveLength(2)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps items within a bucket in their original relative order', () => {
|
|
31
|
+
const items = [{ id: 'a', k: 'g' }, { id: 'b', k: 'g' }, { id: 'c', k: 'g' }]
|
|
32
|
+
const buckets = groupByKey({ items, keyFn: item => item.k })
|
|
33
|
+
expect(buckets.get('g').map(i => i.id)).toEqual(['a', 'b', 'c'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns an empty map for an empty list', () => {
|
|
37
|
+
expect(groupByKey({ items: [], keyFn: () => 'x' }).size).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { orderItems } from './orderItems.js'
|
|
2
|
+
import { groupByKey } from './groupByKey.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Groups a flat list of `{ id, order, tab, element }` items into tabs, when warranted.
|
|
6
|
+
*
|
|
7
|
+
* Items are partitioned by `tab` via `groupByKey` — two items share a tab by producing the
|
|
8
|
+
* same kebab-cased key. Items with no `tab` share one fallback bucket. Returns `null` when
|
|
9
|
+
* there's one bucket or fewer, so the caller can fall back to flat rendering unchanged.
|
|
10
|
+
*
|
|
11
|
+
* Each bucket's members are ordered via `orderItems`; the resulting tab's own `order` and
|
|
12
|
+
* displayed `name` both come from whichever member ends up first in that ordering — the same
|
|
13
|
+
* item that "wins" the position also wins the label, so there's nothing separate to reconcile
|
|
14
|
+
* when members disagree. The tabs themselves are then ordered the same way, via `orderItems`
|
|
15
|
+
* again — no new ordering concept, just the existing one applied twice.
|
|
16
|
+
*
|
|
17
|
+
* This derived-order approach is safe here because tabs are mutually exclusive — only the
|
|
18
|
+
* active tab's content is ever visible, so a "losing" member's own order never has to compete
|
|
19
|
+
* against anything outside its own tab at the same time. `mapButtons`'s button groups don't
|
|
20
|
+
* have that property (every group renders simultaneously), so they don't derive a group's
|
|
21
|
+
* order from its members the same way — see `mapButtons.js`.
|
|
22
|
+
*/
|
|
23
|
+
export function groupIntoTabs ({ items, fallbackLabel }) {
|
|
24
|
+
const buckets = groupByKey({ items, keyFn: item => item.tab })
|
|
25
|
+
|
|
26
|
+
if (buckets.size <= 1) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tabs = Array.from(buckets.entries()).map(([key, members]) => {
|
|
31
|
+
const orderedMembers = orderItems(members)
|
|
32
|
+
const first = orderedMembers[0]
|
|
33
|
+
return {
|
|
34
|
+
id: key ?? 'default',
|
|
35
|
+
order: first.order,
|
|
36
|
+
name: key ? first.tab : fallbackLabel,
|
|
37
|
+
items: orderedMembers
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return orderItems(tabs)
|
|
42
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { groupIntoTabs } from './groupIntoTabs.js'
|
|
2
|
+
|
|
3
|
+
describe('groupIntoTabs', () => {
|
|
4
|
+
it('returns null when there are no items', () => {
|
|
5
|
+
expect(groupIntoTabs({ items: [], fallbackLabel: 'Panel' })).toBeNull()
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('returns null when all items share the same tab', () => {
|
|
9
|
+
const items = [
|
|
10
|
+
{ id: 'a', order: 0, tab: 'Styles' },
|
|
11
|
+
{ id: 'b', order: 0, tab: 'Styles' }
|
|
12
|
+
]
|
|
13
|
+
expect(groupIntoTabs({ items, fallbackLabel: 'Panel' })).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns null when no items have a tab', () => {
|
|
17
|
+
const items = [{ id: 'a', order: 0 }, { id: 'b', order: 0 }]
|
|
18
|
+
expect(groupIntoTabs({ items, fallbackLabel: 'Panel' })).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('partitions items into distinct tabs', () => {
|
|
22
|
+
const items = [
|
|
23
|
+
{ id: 'a', order: 0, tab: 'Styles' },
|
|
24
|
+
{ id: 'b', order: 0, tab: 'Sizes' }
|
|
25
|
+
]
|
|
26
|
+
const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
|
|
27
|
+
expect(tabs.map(t => t.name).sort()).toEqual(['Sizes', 'Styles'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('groups untagged items into one fallback bucket named after fallbackLabel', () => {
|
|
31
|
+
const items = [
|
|
32
|
+
{ id: 'a', order: 0, tab: 'Styles' },
|
|
33
|
+
{ id: 'b', order: 0 },
|
|
34
|
+
{ id: 'c', order: 0 }
|
|
35
|
+
]
|
|
36
|
+
const tabs = groupIntoTabs({ items, fallbackLabel: 'Map styles' })
|
|
37
|
+
const fallback = tabs.find(t => t.name === 'Map styles')
|
|
38
|
+
expect(fallback.items.map(i => i.id).sort()).toEqual(['b', 'c'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('merges items whose tab differs only by case/whitespace into one bucket', () => {
|
|
42
|
+
const items = [
|
|
43
|
+
{ id: 'a', order: 0, tab: 'Map Size' },
|
|
44
|
+
{ id: 'b', order: 1, tab: 'map size' },
|
|
45
|
+
{ id: 'c', order: 0, tab: 'Styles' }
|
|
46
|
+
]
|
|
47
|
+
const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
|
|
48
|
+
expect(tabs).toHaveLength(2)
|
|
49
|
+
const merged = tabs.find(t => t.items.length === 2)
|
|
50
|
+
expect(merged.items.map(i => i.id)).toEqual(expect.arrayContaining(['a', 'b']))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("uses the winning (lowest-order) member's raw tab string as the displayed name", () => {
|
|
54
|
+
const items = [
|
|
55
|
+
{ id: 'a', order: 2, tab: 'MAP SIZE' },
|
|
56
|
+
{ id: 'b', order: 1, tab: 'map size' }, // lower order wins the label
|
|
57
|
+
{ id: 'c', order: 0, tab: 'Styles' }
|
|
58
|
+
]
|
|
59
|
+
const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
|
|
60
|
+
const merged = tabs.find(t => t.items.length === 2)
|
|
61
|
+
expect(merged.name).toBe('map size')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("derives a tab's order from its first ordered member", () => {
|
|
65
|
+
const items = [
|
|
66
|
+
{ id: 'a', order: 5, tab: 'Styles' },
|
|
67
|
+
{ id: 'b', order: 1, tab: 'Styles' },
|
|
68
|
+
{ id: 'c', order: 0, tab: 'Sizes' }
|
|
69
|
+
]
|
|
70
|
+
const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
|
|
71
|
+
const stylesTab = tabs.find(t => t.name === 'Styles')
|
|
72
|
+
expect(stylesTab.order).toBe(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('orders tabs among themselves using the derived order', () => {
|
|
76
|
+
const items = [
|
|
77
|
+
{ id: 'a', order: 2, tab: 'Second' },
|
|
78
|
+
{ id: 'b', order: 1, tab: 'First' }
|
|
79
|
+
]
|
|
80
|
+
const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
|
|
81
|
+
expect(tabs.map(t => t.name)).toEqual(['First', 'Second'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('orders items within a tab using orderItems', () => {
|
|
85
|
+
const items = [
|
|
86
|
+
{ id: 'a', order: 0, tab: 'Styles' },
|
|
87
|
+
{ id: 'b', order: 1, tab: 'Styles' },
|
|
88
|
+
{ id: 'c', order: 0, tab: 'Sizes' }
|
|
89
|
+
]
|
|
90
|
+
const tabs = groupIntoTabs({ items, fallbackLabel: 'Panel' })
|
|
91
|
+
const stylesTab = tabs.find(t => t.name === 'Styles')
|
|
92
|
+
// b (order 1) is spliced ahead of the unordered item a
|
|
93
|
+
expect(stylesTab.items.map(i => i.id)).toEqual(['b', 'a'])
|
|
94
|
+
})
|
|
95
|
+
})
|