@carto/ps-react-ui 4.14.1 → 4.16.0

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 (116) hide show
  1. package/dist/chat.js +494 -466
  2. package/dist/chat.js.map +1 -1
  3. package/dist/components.js +837 -774
  4. package/dist/components.js.map +1 -1
  5. package/dist/legend/stores.js +20 -16
  6. package/dist/legend-store-registry-CVYzhR1-.js +464 -0
  7. package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
  8. package/dist/legend.js +1478 -794
  9. package/dist/legend.js.map +1 -1
  10. package/dist/markdown-DdqANCiN.js +102 -0
  11. package/dist/markdown-DdqANCiN.js.map +1 -0
  12. package/dist/markdown-content-Dk2DSgbf.js +10287 -0
  13. package/dist/markdown-content-Dk2DSgbf.js.map +1 -0
  14. package/dist/types/chat/bubbles/chat-agent-message-markdown.d.ts +12 -0
  15. package/dist/types/chat/bubbles/chat-agent-message.d.ts +9 -0
  16. package/dist/types/chat/bubbles/index.d.ts +1 -0
  17. package/dist/types/chat/index.d.ts +2 -1
  18. package/dist/types/chat/types.d.ts +9 -0
  19. package/dist/types/components/index.d.ts +2 -1
  20. package/dist/types/components/lasso-tool/message.d.ts +7 -0
  21. package/dist/types/components/lasso-tool/types.d.ts +17 -2
  22. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
  23. package/dist/types/legend/components/a11y.d.ts +6 -0
  24. package/dist/types/legend/components/contexts.d.ts +22 -4
  25. package/dist/types/legend/components/index.d.ts +2 -2
  26. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
  27. package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
  28. package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
  29. package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
  30. package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
  31. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
  32. package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
  33. package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
  34. package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
  35. package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
  36. package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
  37. package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
  38. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
  39. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
  40. package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
  41. package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
  42. package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
  43. package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
  44. package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
  45. package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
  46. package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
  47. package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
  48. package/dist/types/legend/index.d.ts +53 -6
  49. package/dist/types/legend/provider/labels.d.ts +21 -0
  50. package/dist/types/legend/stores/index.d.ts +1 -1
  51. package/dist/types/legend/stores/selectors.d.ts +31 -0
  52. package/dist/types/legend/stores/types.d.ts +11 -0
  53. package/dist/types/widgets-v2/markdown/markdown-content.d.ts +7 -1
  54. package/dist/widgets-v2/markdown.js +9 -8
  55. package/dist/widgets-v2/markdown.js.map +1 -1
  56. package/dist/widgets-v2.js +23 -22
  57. package/dist/widgets-v2.js.map +1 -1
  58. package/package.json +5 -4
  59. package/src/chat/bubbles/chat-agent-message-markdown.test.tsx +38 -0
  60. package/src/chat/bubbles/chat-agent-message-markdown.tsx +34 -0
  61. package/src/chat/bubbles/chat-agent-message.test.tsx +12 -0
  62. package/src/chat/bubbles/chat-agent-message.tsx +21 -1
  63. package/src/chat/bubbles/index.ts +1 -0
  64. package/src/chat/index.ts +2 -0
  65. package/src/chat/types.ts +10 -0
  66. package/src/components/index.ts +2 -0
  67. package/src/components/lasso-tool/message.tsx +36 -0
  68. package/src/components/lasso-tool/types.ts +20 -1
  69. package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
  70. package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
  71. package/src/legend/components/a11y.ts +14 -0
  72. package/src/legend/components/contexts.ts +29 -4
  73. package/src/legend/components/index.ts +3 -4
  74. package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
  75. package/src/legend/components/legend-group/legend-group.test.tsx +166 -9
  76. package/src/legend/components/legend-group/legend-group.tsx +88 -49
  77. package/src/legend/components/legend-group/styles.ts +59 -2
  78. package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
  79. package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
  80. package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
  81. package/src/legend/components/legend-opacity/styles.ts +14 -9
  82. package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
  83. package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
  84. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
  85. package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
  86. package/src/legend/components/legend-row/legend-row.tsx +73 -50
  87. package/src/legend/components/legend-row/styles.ts +54 -1
  88. package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
  89. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
  90. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
  91. package/src/legend/components/legend-sortable/contexts.ts +12 -0
  92. package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
  93. package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
  94. package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
  95. package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
  96. package/src/legend/components/legend-sortable/styles.ts +56 -0
  97. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
  98. package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
  99. package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
  100. package/src/legend/components/legend-visible-layers/styles.ts +60 -0
  101. package/src/legend/components/run-async-menu-action.ts +20 -0
  102. package/src/legend/index.ts +59 -4
  103. package/src/legend/provider/labels.ts +43 -1
  104. package/src/legend/stores/index.ts +4 -0
  105. package/src/legend/stores/legend-store-registry.ts +60 -0
  106. package/src/legend/stores/legend-store.test.ts +116 -0
  107. package/src/legend/stores/selectors.ts +64 -2
  108. package/src/legend/stores/types.ts +11 -0
  109. package/src/widgets/actions/download/exports.test.tsx +14 -1
  110. package/src/widgets-v2/markdown/markdown-content.test.tsx +10 -0
  111. package/src/widgets-v2/markdown/markdown-content.tsx +9 -0
  112. package/src/widgets-v2/wrapper/widget-wrapper.tsx +4 -1
  113. package/dist/markdown-BD1jcknS.js +0 -8326
  114. package/dist/markdown-BD1jcknS.js.map +0 -1
  115. package/dist/selectors-DwHj6mE2.js +0 -388
  116. package/dist/selectors-DwHj6mE2.js.map +0 -1
@@ -1,10 +1,18 @@
1
- import { afterEach, describe, it, expect } from 'vitest'
1
+ import { afterEach, describe, it, expect, vi } from 'vitest'
2
2
  import { fireEvent, render, screen } from '@testing-library/react'
3
3
  import { LegendProvider } from '../../provider'
4
4
  import { LegendGroup } from './legend-group'
5
5
  import { LegendRow } from '../legend-row/legend-row'
6
6
  import { LegendActions } from '../legend-actions/legend-actions'
7
7
  import { LegendVisibilityToggle } from '../legend-visibility-toggle/legend-visibility-toggle'
8
+ import { LegendGroupMenu } from '../legend-group-menu/legend-group-menu'
9
+ import {
10
+ LegendShowOnlyGroup,
11
+ LegendShowAllGroups,
12
+ LegendCollapseAllGroups,
13
+ LegendZoomToGroup,
14
+ } from '../legend-group-menu/legend-group-menu-items'
15
+ import { LegendShowAllLayers } from '../legend-row-menu/legend-row-menu-items'
8
16
  import {
9
17
  clearAllLegendStores,
10
18
  getLegendStore,
@@ -70,10 +78,16 @@ describe('<LegendGroup> composition', () => {
70
78
  expect(state.layers.b!.visible).toBe(true)
71
79
  })
72
80
 
73
- it('toggles group collapse through the store', () => {
81
+ it('clicking the title row toggles group collapse (accordion header)', () => {
74
82
  renderGroup('g-col')
75
- fireEvent.click(screen.getByLabelText('Collapse group'))
83
+ const header = screen.getByRole('button', { name: 'Demographics' })
84
+ expect(header.getAttribute('aria-expanded')).toBe('true')
85
+ fireEvent.click(header)
76
86
  expect(getLegendStore('g-col').getState().groups.g1!.collapsed).toBe(true)
87
+ expect(header.getAttribute('aria-expanded')).toBe('false')
88
+ // Keyboard parity: Enter re-expands.
89
+ fireEvent.keyDown(header, { key: 'Enter' })
90
+ expect(getLegendStore('g-col').getState().groups.g1!.collapsed).toBe(false)
77
91
  })
78
92
 
79
93
  it('shows the indeterminate icon (mixed) when only some layers are hidden', () => {
@@ -101,15 +115,158 @@ describe('<LegendGroup> composition', () => {
101
115
  expect(screen.getByLabelText('Show group').className).toContain('active')
102
116
  })
103
117
 
104
- it('hiding the group collapses it, but the chevron stays usable', () => {
118
+ it('hiding the group collapses it, but the title row stays usable', () => {
105
119
  renderGroup('g-hide')
106
120
  fireEvent.click(screen.getByLabelText('Hide group'))
107
121
  expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(true)
108
- // The chevron reads "Expand group" and is NOT disabled — a hidden group
109
- // can be peeked into to re-show layers one by one.
110
- const chevron = screen.getByLabelText<HTMLButtonElement>('Expand group')
111
- expect(chevron.disabled).toBe(false)
112
- fireEvent.click(chevron)
122
+ // Groups are never force-collapsed by visibility — a hidden group can be
123
+ // peeked into to re-show layers one by one.
124
+ const header = screen.getByRole('button', { name: 'Demographics' })
125
+ fireEvent.click(header)
113
126
  expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(false)
114
127
  })
115
128
  })
129
+
130
+ describe('<LegendGroupMenu> + built-in items', () => {
131
+ function renderWithMenu(id: string) {
132
+ return render(
133
+ <LegendProvider
134
+ id={id}
135
+ layers={[...layers, { id: 'solo', name: 'Solo', variables: [cat] }]}
136
+ groups={[...groups, { id: 'g2', label: 'Business' }]}
137
+ >
138
+ <LegendGroup groupId='g1'>
139
+ <LegendActions>
140
+ <LegendVisibilityToggle />
141
+ <LegendGroupMenu>
142
+ <LegendShowOnlyGroup />
143
+ <LegendShowAllGroups />
144
+ <LegendCollapseAllGroups />
145
+ <LegendZoomToGroup onZoomTo={zoomSpy} />
146
+ </LegendGroupMenu>
147
+ </LegendActions>
148
+ </LegendGroup>
149
+ </LegendProvider>,
150
+ )
151
+ }
152
+ const zoomSpy = vi.fn()
153
+
154
+ it('Show only this group hides other groups and ungrouped layers', () => {
155
+ renderWithMenu('gm-only')
156
+ fireEvent.click(screen.getByLabelText('Group options'))
157
+ fireEvent.click(screen.getByText('Show only this group'))
158
+ const state = getLegendStore('gm-only').getState()
159
+ expect(state.layers.a!.visible).toBe(true)
160
+ expect(state.layers.solo!.visible).toBe(false)
161
+ // Menu closed after the pick.
162
+ expect(screen.queryByRole('menuitem')).toBeNull()
163
+
164
+ fireEvent.click(screen.getByLabelText('Group options'))
165
+ fireEvent.click(screen.getByText('Show all groups'))
166
+ expect(getLegendStore('gm-only').getState().layers.solo!.visible).toBe(true)
167
+ })
168
+
169
+ it('Collapse all groups toggles and flips its label', () => {
170
+ renderWithMenu('gm-collapse')
171
+ fireEvent.click(screen.getByLabelText('Group options'))
172
+ fireEvent.click(screen.getByText('Collapse all groups'))
173
+ const state = getLegendStore('gm-collapse').getState()
174
+ expect(state.groups.g1!.collapsed).toBe(true)
175
+ expect(state.groups.g2!.collapsed).toBe(true)
176
+ // Reopen: the item now offers Expand.
177
+ fireEvent.click(screen.getByLabelText('Group options'))
178
+ fireEvent.click(screen.getByText('Expand all groups'))
179
+ expect(getLegendStore('gm-collapse').getState().groups.g1!.collapsed).toBe(
180
+ false,
181
+ )
182
+ })
183
+
184
+ it('Zoom to group fires the composed handler with the group id', async () => {
185
+ renderWithMenu('gm-zoom')
186
+ fireEvent.click(screen.getByLabelText('Group options'))
187
+ fireEvent.click(screen.getByText('Zoom to group'))
188
+ expect(zoomSpy).toHaveBeenCalledWith('g1')
189
+ await vi.waitFor(() => {
190
+ expect(screen.queryByRole('menuitem')).toBeNull()
191
+ })
192
+ })
193
+
194
+ it('Zoom to group keeps the menu open with loading feedback while pending', async () => {
195
+ let resolveZoom!: () => void
196
+ const pendingZoom = vi.fn(
197
+ () =>
198
+ new Promise<void>((resolve) => {
199
+ resolveZoom = resolve
200
+ }),
201
+ )
202
+ render(
203
+ <LegendProvider id='gm-zoom-busy' layers={layers} groups={groups}>
204
+ <LegendGroup groupId='g1'>
205
+ <LegendActions>
206
+ <LegendGroupMenu>
207
+ <LegendZoomToGroup onZoomTo={pendingZoom} />
208
+ </LegendGroupMenu>
209
+ </LegendActions>
210
+ </LegendGroup>
211
+ </LegendProvider>,
212
+ )
213
+ fireEvent.click(screen.getByLabelText('Group options'))
214
+ fireEvent.click(screen.getByText('Zoom to group'))
215
+ expect(pendingZoom).toHaveBeenCalledWith('g1')
216
+ const item = await screen.findByRole('menuitem', { name: /Loading/ })
217
+ expect(item.getAttribute('aria-disabled')).toBe('true')
218
+ expect(screen.getByRole('progressbar')).toBeDefined()
219
+
220
+ resolveZoom()
221
+ await vi.waitFor(() => {
222
+ expect(screen.queryByRole('menuitem')).toBeNull()
223
+ })
224
+ })
225
+
226
+ it('Show all layers composed directly in the group menu targets only that group', () => {
227
+ render(
228
+ <LegendProvider
229
+ id='gm-showall-group'
230
+ layers={[...layers, { id: 'solo', name: 'Solo', variables: [cat] }]}
231
+ groups={groups}
232
+ >
233
+ <LegendGroup groupId='g1'>
234
+ <LegendActions>
235
+ <LegendVisibilityToggle />
236
+ <LegendGroupMenu>
237
+ <LegendShowAllLayers />
238
+ </LegendGroupMenu>
239
+ </LegendActions>
240
+ </LegendGroup>
241
+ </LegendProvider>,
242
+ )
243
+ getLegendStore('gm-showall-group').getState().setVisibility('a', false)
244
+ getLegendStore('gm-showall-group').getState().setVisibility('b', false)
245
+ getLegendStore('gm-showall-group').getState().setVisibility('solo', false)
246
+
247
+ fireEvent.click(screen.getByLabelText('Group options'))
248
+ fireEvent.click(screen.getByText('Show all layers'))
249
+ const state = getLegendStore('gm-showall-group').getState()
250
+ expect(state.layers.a!.visible).toBe(true)
251
+ expect(state.layers.b!.visible).toBe(true)
252
+ // Ungrouped sibling untouched — this instance only affects g1.
253
+ expect(state.layers.solo!.visible).toBe(false)
254
+ })
255
+ })
256
+
257
+ describe('panel-wide group actions with no groups', () => {
258
+ it('ShowAllGroups / CollapseAllGroups render null when the legend has no groups', () => {
259
+ render(
260
+ <LegendProvider
261
+ id='no-groups'
262
+ layers={[{ id: 'solo', name: 'Solo', variables: [cat] }]}
263
+ groups={[]}
264
+ >
265
+ <LegendShowAllGroups />
266
+ <LegendCollapseAllGroups />
267
+ </LegendProvider>,
268
+ )
269
+ expect(screen.queryByText('Show all groups')).toBeNull()
270
+ expect(screen.queryByText('Collapse all groups')).toBeNull()
271
+ })
272
+ })
@@ -1,34 +1,37 @@
1
1
  import type { ReactNode } from 'react'
2
- import { Box, Collapse, IconButton, Typography } from '@mui/material'
2
+ import { Box, Collapse, Typography } from '@mui/material'
3
3
  import {
4
4
  LayersOutlined as GroupIcon,
5
5
  ExpandMore as ExpandMoreIcon,
6
- ExpandLess as ExpandLessIcon,
7
6
  } from '@mui/icons-material'
7
+ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
8
8
  import { SmartTooltip } from '../../../components/smart-tooltip/smart-tooltip'
9
- import { useLegendId, useLegendShallow } from '../../stores'
10
- import { useLegendConfig } from '../../provider'
9
+ import {
10
+ selectGroupVisibility,
11
+ selectLayersByGroup,
12
+ useLegendId,
13
+ useLegendShallow,
14
+ useLegendStore,
15
+ } from '../../stores'
16
+ import { accordionKeyDown } from '../a11y'
11
17
  import { LegendGroupContext } from '../contexts'
12
18
  import { partitionLegendChildren } from '../legend-actions/legend-actions'
19
+ import { useLegendSortable } from '../legend-sortable/contexts'
20
+ import {
21
+ SortableEntity,
22
+ type SortableEntityRenderProps,
23
+ } from '../legend-sortable/sortable-entity'
24
+ import { toSortableId } from '../legend-sortable/sortable-ids'
13
25
  import { styles } from './styles'
14
26
 
15
27
  export interface LegendGroupProps {
16
28
  groupId: string
17
- /**
18
- * Composition slots: `<Legend.Actions>` children render in the sticky
19
- * header (hover-fade); everything else is the collapsible body — the
20
- * consumer renders the member `<Legend.Row>`s explicitly (use
21
- * `selectLayersByGroup` to resolve them).
22
- */
29
+ /** `<Legend.Actions>` in the sticky header; member `<Legend.Row>`s in the body. */
23
30
  children?: ReactNode
24
31
  }
25
32
 
26
33
  /**
27
- * Store-connected group wrapper (widgets-v2 `Wrapper` parity). Renders the
28
- * sticky 48px header — group icon + label from the store, the partitioned
29
- * `<Legend.Actions>` slot, and the collapse chevron — over the collapsible
30
- * body. Provides the group composition context so `Legend.VisibilityToggle`
31
- * placed in the header becomes the tri-state group eye.
34
+ * Store-connected group wrapper. Draggable inside `<Legend.Sortable>`.
32
35
  *
33
36
  * @experimental This API is new and may change in a future release.
34
37
  */
@@ -36,53 +39,89 @@ export function LegendGroup({ groupId, children }: LegendGroupProps) {
36
39
  const id = useLegendId()
37
40
  const group = useLegendShallow(id, (s) => s.groups[groupId])
38
41
  const setGroupCollapsed = useLegendShallow(id, (s) => s.setGroupCollapsed)
39
- const { labels } = useLegendConfig()
42
+ const hidden = useLegendStore(
43
+ id,
44
+ (s) => selectGroupVisibility(s, groupId) === 'hidden',
45
+ )
46
+ const sortable = useLegendSortable()
47
+ // Member ids only — useShallow ignores opacity/visibility noise on siblings.
48
+ const memberSortableIds = useLegendShallow(id, (s) =>
49
+ selectLayersByGroup(s, groupId).map((l) => toSortableId('layer', l.id)),
50
+ )
40
51
 
41
52
  if (!group) return null
42
53
 
43
54
  const { actions, body } = partitionLegendChildren(children)
55
+ const toggle = () => setGroupCollapsed(groupId, !group.collapsed)
44
56
 
45
- return (
57
+ const renderGroup = (sortableProps?: SortableEntityRenderProps) => (
46
58
  <LegendGroupContext.Provider value={groupId}>
47
- <Box sx={styles.group}>
59
+ <Box
60
+ ref={sortableProps?.rootRef}
61
+ style={sortableProps?.rootStyle}
62
+ sx={[
63
+ styles.group,
64
+ sortableProps?.dragging === true && styles.groupDragging,
65
+ ]}
66
+ >
48
67
  <Box sx={styles.header}>
49
- <Box sx={styles.groupIcon}>
50
- <GroupIcon fontSize='small' />
51
- </Box>
52
-
53
- <SmartTooltip title={group.label} dependencies={[group.label]}>
54
- {({ ref }) => (
55
- <Typography
56
- ref={ref}
57
- variant='button'
58
- color='text.primary'
59
- sx={styles.label}
60
- >
61
- {group.label}
62
- </Typography>
63
- )}
64
- </SmartTooltip>
68
+ {sortableProps?.handle}
69
+ {/* Whole title area toggles collapse (accordion header); the
70
+ actions box is a sibling outside the click target. */}
71
+ <Box
72
+ sx={styles.titleArea}
73
+ role='button'
74
+ tabIndex={0}
75
+ aria-expanded={!group.collapsed}
76
+ onClick={toggle}
77
+ onKeyDown={accordionKeyDown(toggle)}
78
+ >
79
+ <Box sx={styles.groupIcon}>
80
+ <GroupIcon fontSize='small' />
81
+ </Box>
65
82
 
66
- <Box sx={styles.actions}>
67
- {actions}
68
- <IconButton
69
- size='small'
70
- onClick={() => setGroupCollapsed(groupId, !group.collapsed)}
71
- aria-label={
72
- group.collapsed ? labels.expandGroup : labels.collapseGroup
73
- }
74
- >
75
- {group.collapsed ? (
76
- <ExpandMoreIcon fontSize='small' />
77
- ) : (
78
- <ExpandLessIcon fontSize='small' />
83
+ <SmartTooltip title={group.label} dependencies={[group.label]}>
84
+ {({ ref }) => (
85
+ <Typography
86
+ ref={ref}
87
+ variant='button'
88
+ fontWeight='bold'
89
+ color={hidden ? 'text.disabled' : 'text.primary'}
90
+ sx={styles.label}
91
+ >
92
+ {group.label}
93
+ </Typography>
79
94
  )}
80
- </IconButton>
95
+ </SmartTooltip>
96
+
97
+ {/* Informative only (the row is the button); hidden on touch. */}
98
+ <ExpandMoreIcon
99
+ sx={[styles.chevron, group.collapsed && styles.chevronCollapsed]}
100
+ />
81
101
  </Box>
102
+
103
+ <Box sx={styles.actions}>{actions}</Box>
82
104
  </Box>
83
105
 
84
- <Collapse in={!group.collapsed}>{body}</Collapse>
106
+ <Collapse in={!group.collapsed}>
107
+ {sortable ? (
108
+ <SortableContext
109
+ items={memberSortableIds}
110
+ strategy={verticalListSortingStrategy}
111
+ >
112
+ {body}
113
+ </SortableContext>
114
+ ) : (
115
+ body
116
+ )}
117
+ </Collapse>
85
118
  </Box>
86
119
  </LegendGroupContext.Provider>
87
120
  )
121
+
122
+ return sortable ? (
123
+ <SortableEntity kind='group' id={groupId} render={renderGroup} />
124
+ ) : (
125
+ renderGroup()
126
+ )
88
127
  }
@@ -38,6 +38,28 @@ export const styles = {
38
38
  '&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
39
39
  opacity: 1,
40
40
  },
41
+ // Drag handle (Legend.Sortable): absolutely positioned (no reserved flex
42
+ // slot, so it never shifts the title/icon); the button inside fades in
43
+ // on hover/focus and is always visible on touch devices.
44
+ '& .PsLegend-dragHandle-group > *': {
45
+ opacity: 0,
46
+ transition: ({ transitions }: Theme) =>
47
+ transitions.create('opacity', {
48
+ duration: transitions.duration.standard,
49
+ easing: transitions.easing.easeInOut,
50
+ }),
51
+ '@media (hover: none)': {
52
+ opacity: 1,
53
+ },
54
+ },
55
+ '&:hover .PsLegend-dragHandle-group > *, &:focus-within .PsLegend-dragHandle-group > *':
56
+ {
57
+ opacity: 1,
58
+ },
59
+ },
60
+ // The original stays in place, dimmed, while its overlay clone is dragged.
61
+ groupDragging: {
62
+ opacity: 0.4,
41
63
  },
42
64
  // Figma: 48px tall, icon at 12px from the left, 8px gap to the label,
43
65
  // actions right-aligned with ~8px edge inset (the small icon-button padding
@@ -56,19 +78,54 @@ export const styles = {
56
78
  // Above the sticky row headers (2), which in turn sit above MUI's
57
79
  // z-index-1 form internals in row content.
58
80
  zIndex: 3,
59
- backgroundColor: 'background.paper',
81
+ // Distinct from the row header / panel body (both `background.paper`) so
82
+ // the group header visually separates from the rest.
83
+ backgroundColor: 'background.default',
84
+ },
85
+ // Accordion click target: everything in the header but the actions box.
86
+ // Non-native button (role='button'), so it carries its own focus ring.
87
+ titleArea: {
88
+ flex: 1,
89
+ minWidth: 0,
90
+ display: 'flex',
91
+ alignItems: 'center',
92
+ gap: 1,
93
+ cursor: 'pointer',
94
+ userSelect: 'none',
95
+ '&:focus-visible': {
96
+ outline: '2px solid',
97
+ outlineColor: 'primary.main',
98
+ outlineOffset: -2,
99
+ },
60
100
  },
61
101
  groupIcon: {
62
102
  display: 'flex',
63
103
  color: 'text.secondary',
64
104
  },
65
105
  label: {
66
- flex: 1,
67
106
  minWidth: 0,
68
107
  overflow: 'hidden',
69
108
  textOverflow: 'ellipsis',
70
109
  whiteSpace: 'nowrap',
71
110
  },
111
+ // Informative collapse indicator beside the title (the whole title area is
112
+ // the button). Points down when expanded, right when collapsed. Touch
113
+ // devices don't get it — the title row is the affordance there.
114
+ chevron: {
115
+ fontSize: 16,
116
+ color: 'text.secondary',
117
+ flexShrink: 0,
118
+ transition: ({ transitions }: Theme) =>
119
+ transitions.create('transform', {
120
+ duration: transitions.duration.shorter,
121
+ }),
122
+ '@media (hover: none)': {
123
+ display: 'none',
124
+ },
125
+ },
126
+ chevronCollapsed: {
127
+ transform: 'rotate(-90deg)',
128
+ },
72
129
  // Icon buttons match the row header's 24×24 (2px padding + 20px icon).
73
130
  actions: {
74
131
  display: 'flex',
@@ -0,0 +1,132 @@
1
+ import type { MouseEvent } from 'react'
2
+ import { CircularProgress, MenuItem } from '@mui/material'
3
+ import {
4
+ getLegendStore,
5
+ selectAllGroupsCollapsed,
6
+ selectGroupVisibility,
7
+ selectHasGroups,
8
+ selectLayersByGroup,
9
+ selectOrderedGroups,
10
+ useLegendId,
11
+ useLegendStore,
12
+ } from '../../stores'
13
+ import { useLegendConfig } from '../../provider'
14
+ import { useLegendGroupId, useLegendMenu } from '../contexts'
15
+ import { runAsyncMenuAction } from '../run-async-menu-action'
16
+
17
+ /**
18
+ * Show only this group (hide all other layers).
19
+ *
20
+ * @experimental This API is new and may change in a future release.
21
+ */
22
+ export function LegendShowOnlyGroup() {
23
+ const id = useLegendId()
24
+ const groupId = useLegendGroupId()
25
+ const { labels } = useLegendConfig()
26
+
27
+ if (groupId === null) return null
28
+
29
+ const showOnly = () => {
30
+ const state = getLegendStore(id).getState()
31
+ for (const group of selectOrderedGroups(state)) {
32
+ state.setGroupVisibility(group.id, group.id === groupId)
33
+ }
34
+ // Exclusive: the ungrouped layers hide too.
35
+ for (const layer of selectLayersByGroup(state, undefined)) {
36
+ state.setVisibility(layer.id, false)
37
+ }
38
+ }
39
+
40
+ return <MenuItem onClick={showOnly}>{labels.showOnlyGroup}</MenuItem>
41
+ }
42
+
43
+ /**
44
+ * Show every layer (panel-level `Legend.PanelMenu` only).
45
+ *
46
+ * @experimental This API is new and may change in a future release.
47
+ */
48
+ export function LegendShowAllGroups() {
49
+ const id = useLegendId()
50
+ const hasGroups = useLegendStore(id, selectHasGroups)
51
+ const { labels } = useLegendConfig()
52
+
53
+ if (!hasGroups) return null
54
+
55
+ const showAll = () => {
56
+ const state = getLegendStore(id).getState()
57
+ for (const group of selectOrderedGroups(state)) {
58
+ state.setGroupVisibility(group.id, true)
59
+ }
60
+ for (const layer of selectLayersByGroup(state, undefined)) {
61
+ state.setVisibility(layer.id, true)
62
+ }
63
+ }
64
+
65
+ return <MenuItem onClick={showAll}>{labels.showAllGroups}</MenuItem>
66
+ }
67
+
68
+ /**
69
+ * Collapse / expand all groups (panel-level `Legend.PanelMenu` only).
70
+ *
71
+ * @experimental This API is new and may change in a future release.
72
+ */
73
+ export function LegendCollapseAllGroups() {
74
+ const id = useLegendId()
75
+ const hasGroups = useLegendStore(id, selectHasGroups)
76
+ const allCollapsed = useLegendStore(id, selectAllGroupsCollapsed)
77
+ const { labels } = useLegendConfig()
78
+
79
+ if (!hasGroups) return null
80
+
81
+ const toggleAll = () => {
82
+ const state = getLegendStore(id).getState()
83
+ const collapsed = selectAllGroupsCollapsed(state)
84
+ for (const group of selectOrderedGroups(state)) {
85
+ state.setGroupCollapsed(group.id, !collapsed)
86
+ }
87
+ }
88
+
89
+ return (
90
+ <MenuItem onClick={toggleAll}>
91
+ {allCollapsed ? labels.expandAllGroups : labels.collapseAllGroups}
92
+ </MenuItem>
93
+ )
94
+ }
95
+
96
+ export interface LegendZoomToGroupProps {
97
+ /** Fit viewport to the group; may return a Promise for async menu feedback. */
98
+ onZoomTo: (groupId: string) => void | Promise<void>
99
+ }
100
+
101
+ /**
102
+ * Zoom-to-group menu item for `Legend.GroupMenu`. Self-hides when all members are hidden.
103
+ *
104
+ * @experimental This API is new and may change in a future release.
105
+ */
106
+ export function LegendZoomToGroup({ onZoomTo }: LegendZoomToGroupProps) {
107
+ const id = useLegendId()
108
+ const groupId = useLegendGroupId()
109
+ const menu = useLegendMenu()
110
+ const hidden = useLegendStore(
111
+ id,
112
+ (s) => groupId !== null && selectGroupVisibility(s, groupId) === 'hidden',
113
+ )
114
+ const { labels } = useLegendConfig()
115
+
116
+ if (groupId === null || hidden) return null
117
+
118
+ const busy = menu?.busy ?? false
119
+
120
+ const handleClick = (e: MouseEvent) => {
121
+ e.stopPropagation()
122
+ if (!menu) return
123
+ void runAsyncMenuAction(menu, () => onZoomTo(groupId))
124
+ }
125
+
126
+ return (
127
+ <MenuItem disabled={busy} onClick={handleClick}>
128
+ {busy && <CircularProgress size={16} sx={{ mr: 1 }} />}
129
+ {busy ? labels.loading : labels.zoomToGroup}
130
+ </MenuItem>
131
+ )
132
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useLegendConfig } from '../../provider'
3
+ import { useLegendGroupId } from '../contexts'
4
+ import { LegendOverflowMenu } from '../legend-overflow-menu/legend-overflow-menu'
5
+
6
+ export interface LegendGroupMenuProps {
7
+ /** Menu items; async items use `stopPropagation` + `runAsyncMenuAction`. */
8
+ children?: ReactNode
9
+ }
10
+
11
+ /**
12
+ * Overflow (⋮) menu for a group header. Renders nothing outside a `Legend.Group`.
13
+ *
14
+ * @experimental This API is new and may change in a future release.
15
+ */
16
+ export function LegendGroupMenu({ children }: LegendGroupMenuProps) {
17
+ const groupId = useLegendGroupId()
18
+ const { labels } = useLegendConfig()
19
+
20
+ return (
21
+ <LegendOverflowMenu gate={groupId !== null} ariaLabel={labels.groupOptions}>
22
+ {children}
23
+ </LegendOverflowMenu>
24
+ )
25
+ }