@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.
Files changed (79) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/api/button-definition.md +7 -8
  6. package/docs/api/control-definition.md +8 -0
  7. package/docs/api/panel-definition.md +5 -0
  8. package/docs/api/slots.md +24 -1
  9. package/docs/examples/add-polygons.mdx +8 -2
  10. package/docs/examples/add-symbols.mdx +8 -2
  11. package/docs/plugins/datasets.md +23 -15
  12. package/docs/plugins/map-key.md +147 -0
  13. package/docs/plugins.md +5 -1
  14. package/package.json +1 -1
  15. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  16. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  17. package/plugins/beta/scale-bar/dist/css/index.css +2 -25
  18. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  19. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  20. package/plugins/beta/use-location/src/manifest.js +1 -1
  21. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/datasets/src/registry/dataset.js +12 -0
  24. package/plugins/datasets/src/registry/datasetRegistry.js +4 -2
  25. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  27. package/plugins/draw/dist/umd/index.js +1 -1
  28. package/plugins/draw/src/api/createNewShape.js +41 -0
  29. package/plugins/draw/src/api/createNewShape.test.js +63 -0
  30. package/plugins/draw/src/api/newLine.js +2 -40
  31. package/plugins/draw/src/api/newPolygon.js +2 -40
  32. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  33. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  34. package/plugins/interact/dist/umd/index.js +1 -1
  35. package/plugins/interact/src/manifest.js +1 -1
  36. package/plugins/map-key/dist/css/index.css +55 -11
  37. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  38. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  39. package/plugins/map-key/dist/umd/index.js +1 -1
  40. package/plugins/map-key/src/components/Key/Key.jsx +6 -3
  41. package/plugins/map-key/src/components/Key/Key.module.scss +66 -13
  42. package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +12 -8
  43. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +10 -0
  44. package/plugins/map-key/src/components/Key/KeyItem.jsx +9 -12
  45. package/plugins/map-key/src/components/Key/KeyItem.test.jsx +3 -3
  46. package/plugins/map-key/src/components/Key/KeySvg.jsx +27 -22
  47. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +9 -0
  48. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +3 -9
  49. package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +2 -7
  50. package/plugins/map-key/src/components/Key/KeySvgPattern.test.jsx +6 -0
  51. package/plugins/map-key/src/components/Key/KeySvgRamp.jsx +13 -0
  52. package/plugins/map-key/src/components/Key/KeySvgRamp.test.jsx +43 -0
  53. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +4 -11
  54. package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +5 -11
  55. package/plugins/map-key/src/components/Key/KeySvgSymbol.test.jsx +12 -0
  56. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -2
  57. package/plugins/map-key/src/components/Key/MapKey.test.jsx +28 -11
  58. package/plugins/map-key/src/components/Key/svgProperties.js +2 -2
  59. package/plugins/map-key/src/components/Key/svgProperties.test.js +2 -2
  60. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
  61. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
  62. package/src/App/components/Panel/Panel.jsx +50 -8
  63. package/src/App/components/Panel/Panel.module.scss +31 -7
  64. package/src/App/components/Panel/Panel.test.jsx +81 -2
  65. package/src/App/components/Tabs/Tabs.jsx +43 -17
  66. package/src/App/components/Tabs/Tabs.module.scss +32 -5
  67. package/src/App/components/Tabs/Tabs.test.jsx +40 -10
  68. package/src/App/renderer/groupByKey.js +28 -0
  69. package/src/App/renderer/groupByKey.test.js +39 -0
  70. package/src/App/renderer/groupIntoTabs.js +42 -0
  71. package/src/App/renderer/groupIntoTabs.test.js +95 -0
  72. package/src/App/renderer/mapButtons.js +74 -122
  73. package/src/App/renderer/mapButtons.test.js +51 -80
  74. package/src/App/renderer/mapControls.js +1 -0
  75. package/src/App/renderer/mapControls.test.js +16 -0
  76. package/src/App/renderer/mapPanels.js +26 -19
  77. package/src/App/renderer/mapPanels.test.js +44 -0
  78. package/src/config/appConfig.js +2 -2
  79. package/src/types.js +20 -2
@@ -0,0 +1,13 @@
1
+ import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js'
2
+
3
+ export const KeySvgRamp = ({ mapStyle, keyDefinition }) => {
4
+ const { style } = keyDefinition
5
+ const fill = getValueForStyle(style.fill, mapStyle.id)
6
+ const stroke = getValueForStyle(style.fill, mapStyle.id)
7
+
8
+ return (
9
+ <svg viewBox='0 0 5 5' preserveAspectRatio='none' fill={fill} stroke={stroke} strokeWidth='1'>
10
+ <path d='M0 0h5v5H0z' />
11
+ </svg>
12
+ )
13
+ }
@@ -0,0 +1,43 @@
1
+ import { render } from '@testing-library/react'
2
+ import { KeySvgRamp } from './KeySvgRamp'
3
+ import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js'
4
+
5
+ jest.mock('../../../../../src/utils/getValueForStyle.js', () => ({
6
+ getValueForStyle: jest.fn()
7
+ }))
8
+
9
+ const mapStyle = { id: 'outdoor' }
10
+ const keyDefinition = { style: { fill: { outdoor: '#3d9', dark: '#1a5' } } }
11
+
12
+ beforeEach(() => {
13
+ getValueForStyle.mockImplementation((value) =>
14
+ typeof value === 'object' ? value.outdoor : value
15
+ )
16
+ })
17
+
18
+ describe('KeySvgRamp', () => {
19
+ it('renders an svg element', () => {
20
+ const { container } = render(<KeySvgRamp mapStyle={mapStyle} keyDefinition={keyDefinition} />)
21
+ expect(container.querySelector('svg')).toBeTruthy()
22
+ })
23
+
24
+ it('passes the resolved fill to the svg', () => {
25
+ const { container } = render(<KeySvgRamp mapStyle={mapStyle} keyDefinition={keyDefinition} />)
26
+ expect(container.querySelector('svg').getAttribute('fill')).toBe('#3d9')
27
+ })
28
+
29
+ it('passes the resolved stroke to the svg', () => {
30
+ const { container } = render(<KeySvgRamp mapStyle={mapStyle} keyDefinition={keyDefinition} />)
31
+ expect(container.querySelector('svg').getAttribute('stroke')).toBe('#3d9')
32
+ })
33
+
34
+ it('uses preserveAspectRatio none', () => {
35
+ const { container } = render(<KeySvgRamp mapStyle={mapStyle} keyDefinition={keyDefinition} />)
36
+ expect(container.querySelector('svg').getAttribute('preserveAspectRatio')).toBe('none')
37
+ })
38
+
39
+ it('renders a path child', () => {
40
+ const { container } = render(<KeySvgRamp mapStyle={mapStyle} keyDefinition={keyDefinition} />)
41
+ expect(container.querySelector('path')).toBeTruthy()
42
+ })
43
+ })
@@ -1,18 +1,11 @@
1
- import { useEffect, useState } from 'react'
2
1
  import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js'
3
2
  import { svgProps, SVG_SIZE } from './svgProperties.js'
4
3
 
5
4
  export const KeySvgRect = ({ mapStyle, keyDefinition }) => {
6
- const [strokeWidth, setStrokeWidth] = useState(2)
7
- const [fill, setFill] = useState('#ff0000')
8
- const [stroke, setStroke] = useState('#ff0000')
9
-
10
- useEffect(() => {
11
- const { style } = keyDefinition
12
- setStrokeWidth(style.strokeWidth)
13
- setFill(getValueForStyle(style.fill, mapStyle.id))
14
- setStroke(getValueForStyle(style.stroke, mapStyle.id))
15
- }, [mapStyle.id, keyDefinition])
5
+ const { style } = keyDefinition
6
+ const strokeWidth = style.strokeWidth
7
+ const fill = getValueForStyle(style.fill, mapStyle.id)
8
+ const stroke = getValueForStyle(style.stroke, mapStyle.id)
16
9
 
17
10
  return (
18
11
  <svg {...svgProps}>
@@ -1,19 +1,13 @@
1
- import { useEffect, useState } from 'react'
2
1
  import { getSymbolStyleColors, getSymbolViewBox } from '../../../../../src/utils/symbolUtils.js'
3
2
  import { svgSymbolProps } from './svgProperties.js'
4
3
  import { symbolRegistry } from '../../registry/index.js'
5
4
 
6
5
  export const KeySvgSymbol = ({ keyDefinition, mapStyle, symbolDef }) => {
7
- const [resolvedSvg, setResolvedSvg] = useState(null)
8
- const [viewBox, setViewBox] = useState(null)
9
-
10
- useEffect(() => {
11
- const { style } = keyDefinition
12
- const mapColorScheme = mapStyle?.appColorScheme ?? 'light'
13
- const keyMapStyle = { ...mapStyle, mapColorScheme }
14
- setResolvedSvg(symbolRegistry.resolve(symbolDef, getSymbolStyleColors(style), keyMapStyle))
15
- setViewBox(getSymbolViewBox(style, symbolDef))
16
- }, [mapStyle, keyDefinition])
6
+ const { style } = keyDefinition
7
+ const mapColorScheme = mapStyle?.appColorScheme ?? 'light'
8
+ const keyMapStyle = { ...mapStyle, mapColorScheme }
9
+ const resolvedSvg = symbolRegistry.resolve(symbolDef, getSymbolStyleColors(style), keyMapStyle)
10
+ const viewBox = getSymbolViewBox(style, symbolDef)
17
11
 
18
12
  if (!(resolvedSvg && viewBox)) {
19
13
  return null
@@ -76,4 +76,16 @@ describe('KeySvgSymbol', () => {
76
76
  const callArg = mockResolve.mock.calls[0][2]
77
77
  expect(callArg.mapColorScheme).toBe('dark')
78
78
  })
79
+
80
+ it('renders nothing when resolve returns falsy', () => {
81
+ mockResolve.mockReturnValue(null)
82
+ const { container } = render(<KeySvgSymbol {...defaultProps} />)
83
+ expect(container.firstChild).toBeNull()
84
+ })
85
+
86
+ it('renders nothing when getSymbolViewBox returns falsy', () => {
87
+ getSymbolViewBox.mockReturnValue(null)
88
+ const { container } = render(<KeySvgSymbol {...defaultProps} />)
89
+ expect(container.firstChild).toBeNull()
90
+ })
79
91
  })
@@ -8,9 +8,12 @@ export function MapKey ({
8
8
  pluginConfig: { noKeyItemText },
9
9
  services: { eventBus }
10
10
  }) {
11
- const [keyGroups, setKeyGroups] = useState([])
12
- const [hasGroups, setHasGroups] = useState(false)
13
11
  const [datasetRegistry, setDatasetRegistry] = useState(getDatasetRegistry())
12
+ // Lazily seed from the registry (already populated whenever the key is opened after
13
+ // datasets:ready) so the first paint shows real content instead of flashing the empty
14
+ // state while the effect below catches up on the next tick.
15
+ const [keyGroups, setKeyGroups] = useState(() => datasetRegistry?.keyItems().items ?? [])
16
+ const [hasGroups, setHasGroups] = useState(() => datasetRegistry?.keyItems().hasGroups ?? false)
14
17
 
15
18
  const getKeyItems = () => {
16
19
  const { items, hasGroups: _hasGroups } = datasetRegistry.keyItems()
@@ -6,16 +6,21 @@ jest.mock('../../registry/index.js', () => ({
6
6
  getDatasetRegistry: jest.fn()
7
7
  }))
8
8
 
9
+ // eslint-disable-next-line no-var -- jest.mock factories may only reference vars prefixed "mock"
10
+ var mockKeyRenderCount = 0
9
11
  jest.mock('./Key.jsx', () => ({
10
- Key: ({ noKeyItemText, keyGroups, hasGroups, mapStyle }) => (
11
- <div
12
- data-testid='key'
13
- data-no-key-item-text={noKeyItemText}
14
- data-has-groups={String(hasGroups)}
15
- data-map-style-id={mapStyle.id}
16
- data-key-groups-length={keyGroups.length}
17
- />
18
- )
12
+ Key: ({ noKeyItemText, keyGroups, hasGroups, mapStyle }) => {
13
+ mockKeyRenderCount++
14
+ return (
15
+ <div
16
+ data-testid='key'
17
+ data-no-key-item-text={noKeyItemText}
18
+ data-has-groups={String(hasGroups)}
19
+ data-map-style-id={mapStyle.id}
20
+ data-key-groups-length={keyGroups.length}
21
+ />
22
+ )
23
+ }
19
24
  }))
20
25
 
21
26
  const makeEventBus = () => {
@@ -44,6 +49,7 @@ const baseProps = () => ({
44
49
  beforeEach(() => {
45
50
  jest.clearAllMocks()
46
51
  eventBus = makeEventBus()
52
+ mockKeyRenderCount = 0
47
53
  })
48
54
 
49
55
  describe('MapKey', () => {
@@ -84,6 +90,15 @@ describe('MapKey', () => {
84
90
  expect(getByTestId('key').dataset.hasGroups).toBe('true')
85
91
  })
86
92
 
93
+ // Regression: when the registry is already populated at mount (e.g. reopening the key
94
+ // panel after datasets:ready), Key must render with real data straight away rather than
95
+ // painting the empty state first and flashing to the real content once the mount effect
96
+ // catches up — see MapKey.jsx's lazy useState initializers.
97
+ it('renders Key only once, already carrying the real items, instead of flashing empty first', () => {
98
+ render(<MapKey {...baseProps()} />)
99
+ expect(mockKeyRenderCount).toBe(1)
100
+ })
101
+
87
102
  it('registers menu:changed and datasets:changed listeners', () => {
88
103
  render(<MapKey {...baseProps()} />)
89
104
  expect(eventBus.on).toHaveBeenCalledWith('menu:changed', expect.any(Function))
@@ -99,18 +114,20 @@ describe('MapKey', () => {
99
114
 
100
115
  it('refreshes key items when datasets:changed fires', () => {
101
116
  render(<MapKey {...baseProps()} />)
117
+ const callsAtMount = registry.keyItems.mock.calls.length
102
118
  const onDatasetsChanged = eventBus.on.mock.calls.find(([e]) => e === 'datasets:changed')[1]
103
119
  act(() => { onDatasetsChanged() })
104
- expect(registry.keyItems).toHaveBeenCalledTimes(2)
120
+ expect(registry.keyItems).toHaveBeenCalledTimes(callsAtMount + 1)
105
121
  })
106
122
 
107
123
  it('invalidates and refreshes key items when menu:changed fires', () => {
108
124
  render(<MapKey {...baseProps()} />)
125
+ const callsAtMount = registry.keyItems.mock.calls.length
109
126
  const onMenuChanged = eventBus.on.mock.calls.find(([e]) => e === 'menu:changed')[1]
110
127
  const menuState = { someKey: 'value' }
111
128
  act(() => { onMenuChanged(menuState) })
112
129
  expect(registry.invalidateKeyItemsOnMenuStateChange).toHaveBeenCalledWith(menuState)
113
- expect(registry.keyItems).toHaveBeenCalledTimes(2)
130
+ expect(registry.keyItems).toHaveBeenCalledTimes(callsAtMount + 1)
114
131
  })
115
132
  })
116
133
 
@@ -7,7 +7,7 @@ export const svgProps = {
7
7
  width: SVG_SIZE,
8
8
  height: SVG_SIZE,
9
9
  viewBox: `0 0 ${SVG_SIZE} ${SVG_SIZE}`,
10
- className: 'am-c-map-key-symbol',
10
+ className: 'im-c-map-key-symbol',
11
11
  'aria-hidden': 'true',
12
12
  focusable: 'false'
13
13
  }
@@ -16,5 +16,5 @@ export const svgSymbolProps = {
16
16
  ...svgProps,
17
17
  width: SVG_SYMBOL_SIZE,
18
18
  height: SVG_SYMBOL_SIZE,
19
- className: 'am-c-map-key-symbol am-c-map-key-symbol--point'
19
+ className: 'im-c-map-key-symbol im-c-map-key-symbol--point'
20
20
  }
@@ -25,7 +25,7 @@ describe('svgProps', () => {
25
25
  })
26
26
 
27
27
  it('has the correct className', () => {
28
- expect(svgProps.className).toBe('am-c-map-key-symbol')
28
+ expect(svgProps.className).toBe('im-c-map-key-symbol')
29
29
  })
30
30
 
31
31
  it('is aria-hidden', () => {
@@ -55,7 +55,7 @@ describe('svgSymbolProps', () => {
55
55
  })
56
56
 
57
57
  it('className includes the base class and a modifier', () => {
58
- expect(svgSymbolProps.className).toContain('am-c-map-key-symbol')
58
+ expect(svgSymbolProps.className).toContain('im-c-map-key-symbol')
59
59
  expect(svgSymbolProps.className).toContain('--point')
60
60
  })
61
61
  })
@@ -3,6 +3,9 @@ import React from 'react'
3
3
  import { useConfig } from '../../store/configContext'
4
4
  import { useApp } from '../../store/appContext.js'
5
5
  import { Tabs } from '../Tabs/Tabs.jsx'
6
+ import { groupIntoTabs } from '../../renderer/groupIntoTabs.js'
7
+
8
+ const DEFAULT_GROUP = 'Navigate'
6
9
 
7
10
  const ShortcutList = ({ items }) => (
8
11
  <dl className='im-c-keyboard-help__list'>
@@ -15,26 +18,22 @@ const ShortcutList = ({ items }) => (
15
18
  </dl>
16
19
  )
17
20
 
18
- const buildGroupMap = (shortcuts) => shortcuts.reduce((acc, shortcut) => {
19
- const group = shortcut.group || 'Navigate'
20
- if (!acc[group]) {
21
- acc[group] = []
22
- }
23
- acc[group].push(shortcut)
24
- return acc
25
- }, {})
26
-
27
- const getDefaultTab = (groupEntries, context) => {
28
- const exactMatch = groupEntries.find(([, items]) =>
29
- items.some(s => (s.context ?? 'viewport') === context)
21
+ /**
22
+ * Picks which tab should be active on open: the first tab containing a shortcut whose own
23
+ * context matches exactly, else the first tab containing a global-context shortcut, else
24
+ * just the first tab. Domain-specific to keyboard shortcuts — not part of groupIntoTabs.
25
+ */
26
+ const getDefaultTab = (tabs, context) => {
27
+ const exactMatch = tabs.find(tab =>
28
+ tab.items.some(({ shortcut }) => (shortcut.context ?? 'viewport') === context)
30
29
  )
31
30
  if (exactMatch) {
32
- return exactMatch[0]
31
+ return exactMatch.name
33
32
  }
34
- const globalMatch = groupEntries.find(([, items]) =>
35
- items.some(s => s.context === 'global')
33
+ const globalMatch = tabs.find(tab =>
34
+ tab.items.some(({ shortcut }) => shortcut.context === 'global')
36
35
  )
37
- return globalMatch?.[0] ?? groupEntries[0][0]
36
+ return globalMatch?.name ?? tabs[0].name
38
37
  }
39
38
 
40
39
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
@@ -46,10 +45,13 @@ export const KeyboardHelp = ({ context = 'viewport' }) => { // NOSONAR: project
46
45
  const shortcuts = listboxIsActive
47
46
  ? allShortcuts
48
47
  : allShortcuts.filter(s => s.context !== 'listbox')
49
- const groupMap = buildGroupMap(shortcuts)
50
- const groupEntries = Object.entries(groupMap)
51
48
 
52
- if (groupEntries.length <= 1) {
49
+ const tabs = groupIntoTabs({
50
+ items: shortcuts.map(shortcut => ({ id: shortcut.id, order: 0, tab: shortcut.group, shortcut })),
51
+ fallbackLabel: DEFAULT_GROUP
52
+ })
53
+
54
+ if (!tabs) {
53
55
  return (
54
56
  <div className='im-c-keyboard-help'>
55
57
  <ShortcutList items={shortcuts} />
@@ -57,14 +59,14 @@ export const KeyboardHelp = ({ context = 'viewport' }) => { // NOSONAR: project
57
59
  )
58
60
  }
59
61
 
60
- const tabs = groupEntries.map(([name, items]) => ({
61
- name,
62
- content: <ShortcutList items={items} />
62
+ const tabProps = tabs.map(tab => ({
63
+ name: tab.name,
64
+ content: <ShortcutList items={tab.items.map(({ shortcut }) => shortcut)} />
63
65
  }))
64
66
 
65
67
  return (
66
68
  <div className='im-c-keyboard-help'>
67
- <Tabs tabs={tabs} defaultTab={getDefaultTab(groupEntries, context)} />
69
+ <Tabs tabs={tabProps} defaultTab={getDefaultTab(tabs, context)} />
68
70
  </div>
69
71
  )
70
72
  }
@@ -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')).toBeInTheDocument()
123
- expect(screen.queryByText('Select')).not.toBeInTheDocument()
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')).toBeInTheDocument()
132
- expect(screen.queryByText('Move')).not.toBeInTheDocument()
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
- {innerHtmlProp
145
- ? <div {...bodyProps} dangerouslySetInnerHTML={innerHtmlProp} /> // nosonar
146
- : (
147
- <div {...bodyProps} data-panel-slot={panelBodySlot}> {/* nosonar */}
148
- <BodyContent items={items} WrappedChild={WrappedChild} props={props}>{children}</BodyContent>
149
- </div>
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
- &[data-focus-visible="true"] {
64
- outline: var(--scrollable-outline);
65
- outline-offset: var(--scrollable-outline-offset);
66
- box-shadow: var(--scrollable-box-shadow);
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
- @media (-ms-high-contrast:active), screen and (forced-colors: active) {
69
- outline-width: 4px;
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('.im-c-panel__body')
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('.im-c-panel__body')
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
  })