@defra/interactive-map 0.0.41-alpha → 0.0.42-alpha
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/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/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 +6 -0
- 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/src/components/Key/Key.jsx +5 -3
- package/plugins/map-key/src/components/Key/Key.module.scss +6 -0
- package/plugins/map-key/src/components/Key/KeyItem.jsx +5 -8
- package/plugins/map-key/src/components/Key/KeySvg.jsx +21 -22
- 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/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/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
|
@@ -116,11 +116,11 @@ describe('KeyboardHelp — tabs', () => {
|
|
|
116
116
|
expect(screen.getByRole('tab', { name: 'Group A' })).toHaveAttribute('aria-selected', 'true')
|
|
117
117
|
})
|
|
118
118
|
|
|
119
|
-
it('shows only the active tab panel content', () => {
|
|
119
|
+
it('shows only the active tab panel content — the inactive one stays in the DOM (so its aria-controls target exists) but hidden', () => {
|
|
120
120
|
getKeyboardShortcuts.mockReturnValue(allShortcuts)
|
|
121
121
|
render(<KeyboardHelp context='viewport' />)
|
|
122
|
-
expect(screen.getByText('Move')).
|
|
123
|
-
expect(screen.
|
|
122
|
+
expect(screen.getByText('Move')).toBeVisible()
|
|
123
|
+
expect(screen.getByText('Select')).not.toBeVisible()
|
|
124
124
|
})
|
|
125
125
|
|
|
126
126
|
it('switches tab and content on click', () => {
|
|
@@ -128,8 +128,8 @@ describe('KeyboardHelp — tabs', () => {
|
|
|
128
128
|
render(<KeyboardHelp context='viewport' />)
|
|
129
129
|
fireEvent.click(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP }))
|
|
130
130
|
expect(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP })).toHaveAttribute('aria-selected', 'true')
|
|
131
|
-
expect(screen.getByText('Select')).
|
|
132
|
-
expect(screen.
|
|
131
|
+
expect(screen.getByText('Select')).toBeVisible()
|
|
132
|
+
expect(screen.getByText('Move')).not.toBeVisible()
|
|
133
133
|
})
|
|
134
134
|
|
|
135
135
|
it('ungrouped shortcuts default to Navigate group', () => {
|
|
@@ -141,6 +141,23 @@ describe('KeyboardHelp — tabs', () => {
|
|
|
141
141
|
fireEvent.click(screen.getByRole('tab', { name: DEFAULT_GROUP }))
|
|
142
142
|
expect(screen.getByText('No group')).toBeInTheDocument()
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
it('merges group names that differ only by case/whitespace into one tab (groupIntoTabs)', () => {
|
|
146
|
+
getKeyboardShortcuts.mockReturnValue([
|
|
147
|
+
{ id: 'a', group: 'Select Features', context: 'listbox', title: 'Shortcut A', command: '<kbd>Ctrl+A</kbd>' },
|
|
148
|
+
{ id: 'b', group: 'select features', context: 'listbox', title: 'Shortcut B', command: '<kbd>Ctrl+B</kbd>' },
|
|
149
|
+
...VIEWPORT_SHORTCUTS
|
|
150
|
+
])
|
|
151
|
+
render(<KeyboardHelp context='listbox' />)
|
|
152
|
+
// Two groups present (Select Features, Navigate) — not one, so tabs render at all
|
|
153
|
+
expect(screen.getAllByRole('tab')).toHaveLength(2)
|
|
154
|
+
// First-encountered member's raw group string wins as the tab's displayed name
|
|
155
|
+
const tab = screen.getByRole('tab', { name: 'Select Features' })
|
|
156
|
+
expect(tab).toBeInTheDocument()
|
|
157
|
+
fireEvent.click(tab)
|
|
158
|
+
expect(screen.getByText('Shortcut A')).toBeVisible()
|
|
159
|
+
expect(screen.getByText('Shortcut B')).toBeVisible()
|
|
160
|
+
})
|
|
144
161
|
})
|
|
145
162
|
|
|
146
163
|
// ─── listboxIsActive filtering ────────────────────────────────────────────────
|
|
@@ -5,6 +5,7 @@ import { stringToKebab } from '../../../utils/stringToKebab.js'
|
|
|
5
5
|
import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
|
|
6
6
|
import { useIsScrollable } from '../../hooks/useIsScrollable.js'
|
|
7
7
|
import { Icon } from '../Icon/Icon'
|
|
8
|
+
import { Tabs } from '../Tabs/Tabs.jsx'
|
|
8
9
|
|
|
9
10
|
const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
|
|
10
11
|
const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
|
|
@@ -59,6 +60,8 @@ const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }
|
|
|
59
60
|
// Renders the panel body's content: an ordered list of items (own content plus any
|
|
60
61
|
// controls injected via the `<panelId>-panel` slot convention), or the legacy single
|
|
61
62
|
// WrappedChild/children shape for callers that don't build an items list (e.g. HtmlElementHost).
|
|
63
|
+
// Tabbed content is handled separately by Panel itself — see the tabs handling below — since
|
|
64
|
+
// the tablist needs to render outside this component's scrollable body, not inside it.
|
|
62
65
|
const BodyContent = ({ items, WrappedChild, props, children }) => { // NOSONAR
|
|
63
66
|
if (items) {
|
|
64
67
|
return items.map(item => <React.Fragment key={item.id}>{item.element}</React.Fragment>)
|
|
@@ -66,9 +69,44 @@ const BodyContent = ({ items, WrappedChild, props, children }) => { // NOSONAR
|
|
|
66
69
|
return WrappedChild ? <WrappedChild {...props} /> : children
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
// Chooses between the three body shapes: static html, tabbed, or the flat BodyContent above.
|
|
73
|
+
// .im-c-panel__body (bodyProps) always wraps everything, completely unchanged — same padding/
|
|
74
|
+
// overflow/box role it's always had, which is also what the tablist and tabpanel need to pick
|
|
75
|
+
// up their own inset from by simply being nested inside it. Only the scrollable-region
|
|
76
|
+
// behaviour (ref/tabIndex/role/aria-labelledby) moves down onto <Tabs>' own tabpanel div via
|
|
77
|
+
// panelProps when tabbed, since that's the part that should actually scroll/receive focus —
|
|
78
|
+
// the tablist (rendered by <Tabs> itself, ahead of the tabpanel) stays outside that inner
|
|
79
|
+
// scroll boundary so it can't scroll away with long tab content. The tabpanel's own focus ring
|
|
80
|
+
// clearance (so it isn't flush against its content) is a CSS-only concern — see
|
|
81
|
+
// Panel.module.scss's .im-c-tabs__panel override.
|
|
82
|
+
const PanelBody = ({ innerHtmlProp, tabs, items, WrappedChild, props, children, bodyProps, panelBodySlot }) => { // NOSONAR
|
|
83
|
+
if (innerHtmlProp) {
|
|
84
|
+
return <div {...bodyProps} dangerouslySetInnerHTML={innerHtmlProp} /> // nosonar
|
|
85
|
+
}
|
|
86
|
+
const { className, ...scrollableProps } = bodyProps
|
|
87
|
+
if (tabs) {
|
|
88
|
+
return (
|
|
89
|
+
<div className={className} data-panel-slot={panelBodySlot}>
|
|
90
|
+
<Tabs
|
|
91
|
+
tabs={tabs.map(tab => ({
|
|
92
|
+
name: tab.name,
|
|
93
|
+
content: tab.items.map(item => <React.Fragment key={item.id}>{item.element}</React.Fragment>)
|
|
94
|
+
}))}
|
|
95
|
+
panelProps={scrollableProps}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return (
|
|
101
|
+
<div {...bodyProps} data-panel-slot={panelBodySlot}> {/* nosonar */}
|
|
102
|
+
<BodyContent items={items} WrappedChild={WrappedChild} props={props}>{children}</BodyContent>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
69
107
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
70
108
|
// sonarjs/disable-next-line function-name
|
|
71
|
-
export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, items, label, html, children, isOpen = true, rootRef }) => {
|
|
109
|
+
export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, items, tabs, label, html, children, isOpen = true, rootRef }) => {
|
|
72
110
|
const { id } = useConfig()
|
|
73
111
|
const { dispatch, breakpoint, layoutRefs, interfaceType } = useApp()
|
|
74
112
|
|
|
@@ -141,13 +179,17 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
|
|
|
141
179
|
</button>
|
|
142
180
|
)}
|
|
143
181
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
182
|
+
<PanelBody
|
|
183
|
+
innerHtmlProp={innerHtmlProp}
|
|
184
|
+
tabs={tabs}
|
|
185
|
+
items={items}
|
|
186
|
+
WrappedChild={WrappedChild}
|
|
187
|
+
props={props}
|
|
188
|
+
bodyProps={bodyProps}
|
|
189
|
+
panelBodySlot={panelBodySlot}
|
|
190
|
+
>
|
|
191
|
+
{children}
|
|
192
|
+
</PanelBody>
|
|
151
193
|
</div>
|
|
152
194
|
)
|
|
153
195
|
}
|
|
@@ -59,15 +59,39 @@
|
|
|
59
59
|
.im-c-panel__body {
|
|
60
60
|
padding: 0 var(--panel-margin) var(--panel-margin);
|
|
61
61
|
overflow: auto;
|
|
62
|
+
// display: flex so a tabbed .im-c-tabs child (see Tabs.module.scss) has a flex context to
|
|
63
|
+
// size itself within — its tablist keeps its natural height, only its tabpanel grows and
|
|
64
|
+
// scrolls internally, rather than this whole element (tablist included) scrolling as one.
|
|
65
|
+
// A single non-tabbed child is unaffected — one flex item in a column sizes the same as a
|
|
66
|
+
// plain block child would.
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
}
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
// The tabpanel (see Tabs.module.scss) is its own scroll/focus region nested inside this
|
|
72
|
+
// already-padded wrapper — its content already sits correctly inset just by being nested, but
|
|
73
|
+
// its own border box (where the focus ring is drawn, and where its own overflow: auto clips)
|
|
74
|
+
// would otherwise sit tight against that content, with no room of its own. Reaching out with a
|
|
75
|
+
// negative margin — matching this wrapper's own padding — and re-padding by the same amount
|
|
76
|
+
// gives it that room without moving the content: the negative margin and the padding cancel
|
|
77
|
+
// out visually, but the border box itself is now larger. This is a panel-specific override —
|
|
78
|
+
// <Tabs> used anywhere else would need the same treatment if this clearance matters there too.
|
|
79
|
+
.im-c-panel__body .im-c-tabs__panel {
|
|
80
|
+
margin: 0 calc(-1 * var(--panel-margin)) calc(-1 * var(--panel-margin));
|
|
81
|
+
padding: 0 var(--panel-margin) var(--panel-margin);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The scrollable-region focus ring — normally on .im-c-panel__body itself, but when tabbed,
|
|
85
|
+
// the tabpanel (see Tabs.module.scss's .im-c-tabs__panel) is the element that's actually
|
|
86
|
+
// focusable/scrollable instead (see Panel.jsx's PanelBody), so it needs the same treatment.
|
|
87
|
+
.im-c-panel__body[data-focus-visible="true"],
|
|
88
|
+
.im-c-tabs__panel[data-focus-visible="true"] {
|
|
89
|
+
outline: var(--scrollable-outline);
|
|
90
|
+
outline-offset: var(--scrollable-outline-offset);
|
|
91
|
+
box-shadow: var(--scrollable-box-shadow);
|
|
67
92
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
93
|
+
@media (-ms-high-contrast:active), screen and (forced-colors: active) {
|
|
94
|
+
outline-width: 4px;
|
|
71
95
|
}
|
|
72
96
|
}
|
|
73
97
|
|
|
@@ -69,7 +69,7 @@ describe('Panel', () => {
|
|
|
69
69
|
const { container } = renderPanel()
|
|
70
70
|
|
|
71
71
|
// 2. Target by class to avoid role collision with the parent panel
|
|
72
|
-
const body = container.querySelector('
|
|
72
|
+
const body = container.querySelector('[data-panel-slot]')
|
|
73
73
|
|
|
74
74
|
expect(body).toHaveAttribute('tabIndex', '0')
|
|
75
75
|
expect(body).toHaveAttribute('role', 'region')
|
|
@@ -219,7 +219,7 @@ describe('Panel', () => {
|
|
|
219
219
|
|
|
220
220
|
it('stamps data-panel-slot on the items-capable body, for controls DOM-projected via the JS API', () => {
|
|
221
221
|
const { container } = renderPanel({}, { items: [{ id: 'a', element: <p>Item</p> }] })
|
|
222
|
-
const body = container.querySelector('
|
|
222
|
+
const body = container.querySelector('[data-panel-slot]')
|
|
223
223
|
expect(body).toHaveAttribute('data-panel-slot', 'settings-panel')
|
|
224
224
|
})
|
|
225
225
|
|
|
@@ -228,5 +228,84 @@ describe('Panel', () => {
|
|
|
228
228
|
const body = container.querySelector('.im-c-panel__body')
|
|
229
229
|
expect(body).not.toHaveAttribute('data-panel-slot')
|
|
230
230
|
})
|
|
231
|
+
|
|
232
|
+
describe('tabs', () => {
|
|
233
|
+
const tabs = [
|
|
234
|
+
{ name: 'First', items: [{ id: 'a', element: <p>First content</p> }] },
|
|
235
|
+
{ name: 'Second', items: [{ id: 'b', element: <p>Second content</p> }] }
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
it('renders items grouped into tabs when the tabs prop is provided', () => {
|
|
239
|
+
renderPanel({}, { tabs })
|
|
240
|
+
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
|
241
|
+
expect(screen.getByRole('tab', { name: 'First' })).toBeInTheDocument()
|
|
242
|
+
expect(screen.getByRole('tab', { name: 'Second' })).toBeInTheDocument()
|
|
243
|
+
expect(screen.getByText('First content')).toBeInTheDocument()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('switches tab content when a different tab is activated', () => {
|
|
247
|
+
renderPanel({}, { tabs })
|
|
248
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Second' }))
|
|
249
|
+
expect(screen.getByText('Second content')).toBeVisible()
|
|
250
|
+
expect(screen.getByText('First content')).not.toBeVisible()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('prefers tabs over items/WrappedChild/children when provided', () => {
|
|
254
|
+
renderPanel({}, {
|
|
255
|
+
tabs,
|
|
256
|
+
items: [{ id: 'x', element: <p>Flat item</p> }],
|
|
257
|
+
WrappedChild: () => <p>Wrapped</p>,
|
|
258
|
+
children: <p>Child content</p>
|
|
259
|
+
})
|
|
260
|
+
expect(screen.getByText('First content')).toBeInTheDocument()
|
|
261
|
+
expect(screen.queryByText('Flat item')).not.toBeInTheDocument()
|
|
262
|
+
expect(screen.queryByText('Wrapped')).not.toBeInTheDocument()
|
|
263
|
+
expect(screen.queryByText('Child content')).not.toBeInTheDocument()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('keeps the panel body wrapper (padding, data-panel-slot) around the whole tabs block', () => {
|
|
267
|
+
const { container } = renderPanel({}, { tabs })
|
|
268
|
+
const body = container.querySelector('[data-panel-slot]')
|
|
269
|
+
expect(body).toHaveAttribute('data-panel-slot', 'settings-panel')
|
|
270
|
+
expect(body.querySelector('[role="tablist"]')).not.toBeNull()
|
|
271
|
+
expect(body.querySelector('[role="tabpanel"]')).not.toBeNull()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('keeps the outer wrapper as the normal, unconditional im-c-panel__body — the tablist and tabpanel pick up their inset by being nested inside it, same as any other panel content', () => {
|
|
275
|
+
const { container } = renderPanel({}, { tabs })
|
|
276
|
+
const body = container.querySelector('[data-panel-slot]')
|
|
277
|
+
expect(body).toHaveClass('im-c-panel__body')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('keeps the tablist outside the scrollable tabpanel, so it does not scroll away with long tab content', () => {
|
|
281
|
+
const { container } = renderPanel({}, { tabs })
|
|
282
|
+
const tabpanel = container.querySelector('[role="tabpanel"]')
|
|
283
|
+
expect(tabpanel.querySelector('[role="tablist"]')).toBeNull()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('places the tablist before the tabpanel in DOM order, so keyboard focus reaches it first', () => {
|
|
287
|
+
const { container } = renderPanel({}, { tabs })
|
|
288
|
+
const tablist = container.querySelector('[role="tablist"]')
|
|
289
|
+
const tabpanel = container.querySelector('[role="tabpanel"]')
|
|
290
|
+
// DOCUMENT_POSITION_FOLLOWING (4) means `tabpanel` comes after `tablist`
|
|
291
|
+
// eslint-disable-next-line no-bitwise
|
|
292
|
+
expect(tablist.compareDocumentPosition(tabpanel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('moves the scrollable-region treatment onto the tabpanel itself, not the outer wrapper', () => {
|
|
296
|
+
useIsScrollable.mockReturnValue(true)
|
|
297
|
+
|
|
298
|
+
const { container } = renderPanel({}, { tabs })
|
|
299
|
+
const body = container.querySelector('[data-panel-slot]')
|
|
300
|
+
const tabpanel = screen.getByRole('tabpanel')
|
|
301
|
+
|
|
302
|
+
expect(tabpanel).toHaveAttribute('tabIndex', '0')
|
|
303
|
+
expect(body).not.toHaveAttribute('tabIndex')
|
|
304
|
+
expect(body).not.toHaveAttribute('role', 'region')
|
|
305
|
+
expect(tabpanel).not.toHaveAttribute('data-panel-slot')
|
|
306
|
+
|
|
307
|
+
useIsScrollable.mockReturnValue(false)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
231
310
|
})
|
|
232
311
|
})
|
|
@@ -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
|
+
}
|