@carto/ps-react-ui 4.15.0 → 4.17.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 (82) hide show
  1. package/dist/components.js +786 -749
  2. package/dist/components.js.map +1 -1
  3. package/dist/legend/stores.js +20 -16
  4. package/dist/legend-store-registry-Bo8U_qWM.js +468 -0
  5. package/dist/legend-store-registry-Bo8U_qWM.js.map +1 -0
  6. package/dist/legend.js +1485 -798
  7. package/dist/legend.js.map +1 -1
  8. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
  9. package/dist/types/legend/components/a11y.d.ts +6 -0
  10. package/dist/types/legend/components/contexts.d.ts +22 -4
  11. package/dist/types/legend/components/index.d.ts +2 -2
  12. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
  13. package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
  14. package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
  15. package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
  16. package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
  17. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
  18. package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
  19. package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
  20. package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
  21. package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
  22. package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
  23. package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
  24. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
  25. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
  26. package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
  27. package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
  28. package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
  29. package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
  30. package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
  31. package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
  32. package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
  33. package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
  34. package/dist/types/legend/index.d.ts +53 -6
  35. package/dist/types/legend/provider/labels.d.ts +21 -0
  36. package/dist/types/legend/stores/index.d.ts +1 -1
  37. package/dist/types/legend/stores/selectors.d.ts +31 -0
  38. package/dist/types/legend/stores/types.d.ts +19 -0
  39. package/package.json +3 -3
  40. package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
  41. package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
  42. package/src/legend/components/a11y.ts +14 -0
  43. package/src/legend/components/contexts.ts +29 -4
  44. package/src/legend/components/index.ts +3 -4
  45. package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
  46. package/src/legend/components/legend-group/legend-group.test.tsx +191 -9
  47. package/src/legend/components/legend-group/legend-group.tsx +88 -52
  48. package/src/legend/components/legend-group/styles.ts +59 -2
  49. package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
  50. package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
  51. package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
  52. package/src/legend/components/legend-opacity/styles.ts +14 -9
  53. package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
  54. package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
  55. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
  56. package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
  57. package/src/legend/components/legend-row/legend-row.tsx +73 -50
  58. package/src/legend/components/legend-row/styles.ts +54 -1
  59. package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
  60. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
  61. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
  62. package/src/legend/components/legend-sortable/contexts.ts +12 -0
  63. package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
  64. package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
  65. package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
  66. package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
  67. package/src/legend/components/legend-sortable/styles.ts +56 -0
  68. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
  69. package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
  70. package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
  71. package/src/legend/components/legend-visible-layers/styles.ts +60 -0
  72. package/src/legend/components/run-async-menu-action.ts +20 -0
  73. package/src/legend/index.ts +59 -4
  74. package/src/legend/provider/labels.ts +43 -1
  75. package/src/legend/stores/index.ts +4 -0
  76. package/src/legend/stores/legend-store-registry.ts +82 -2
  77. package/src/legend/stores/legend-store.test.ts +168 -0
  78. package/src/legend/stores/selectors.ts +64 -2
  79. package/src/legend/stores/types.ts +19 -0
  80. package/src/widgets/actions/download/exports.test.tsx +14 -1
  81. package/dist/selectors-DwHj6mE2.js +0 -388
  82. package/dist/selectors-DwHj6mE2.js.map +0 -1
@@ -0,0 +1,20 @@
1
+ import type { LegendMenuController } from './contexts'
2
+
3
+ /**
4
+ * Run an async menu action with busy gating. Uses `isBusy()` (ref-backed) so a
5
+ * second click before React re-renders cannot fire the handler twice. Keeps
6
+ * the menu open while pending, then clears busy and closes when it settles.
7
+ */
8
+ export async function runAsyncMenuAction(
9
+ menu: LegendMenuController,
10
+ action: () => void | Promise<void>,
11
+ ): Promise<void> {
12
+ if (menu.isBusy()) return
13
+ menu.setBusy(true)
14
+ try {
15
+ await action()
16
+ } finally {
17
+ menu.setBusy(false)
18
+ menu.close()
19
+ }
20
+ }
@@ -6,6 +6,20 @@ import { LegendActions } from './components/legend-actions/legend-actions'
6
6
  import { LegendVisibilityToggle } from './components/legend-visibility-toggle/legend-visibility-toggle'
7
7
  import { LegendRowMenu } from './components/legend-row-menu/legend-row-menu'
8
8
  import { LegendZoomTo } from './components/legend-row-menu/legend-zoom-to'
9
+ import {
10
+ LegendShowOnlyLayer,
11
+ LegendShowAllLayers,
12
+ } from './components/legend-row-menu/legend-row-menu-items'
13
+ import { LegendGroupMenu } from './components/legend-group-menu/legend-group-menu'
14
+ import {
15
+ LegendShowOnlyGroup,
16
+ LegendShowAllGroups,
17
+ LegendCollapseAllGroups,
18
+ LegendZoomToGroup,
19
+ } from './components/legend-group-menu/legend-group-menu-items'
20
+ import { LegendPanelMenu } from './components/legend-panel-menu/legend-panel-menu'
21
+ import { LegendVisibleLayers } from './components/legend-visible-layers/legend-visible-layers'
22
+ import { LegendSortable } from './components/legend-sortable/legend-sortable'
9
23
  import { LegendOpacity } from './components/legend-opacity/legend-opacity'
10
24
  import { LegendConfigSelect } from './components/legend-config-select/legend-config-select'
11
25
  import { LegendItem } from './components/legend-item/legend-item'
@@ -25,31 +39,58 @@ import { LegendIconUI } from './components/legend-icon/legend-icon-ui'
25
39
  * children — `<Legend.Actions>` renders in the sticky header (hover-fade),
26
40
  * everything else in the collapsible body.
27
41
  *
42
+ * Collapse is the title row itself (whole header minus the actions), with an
43
+ * informative chevron that touch devices don't show. The ⋮ menus are
44
+ * extensible shells: compose the built-in items and append your own
45
+ * `MenuItem`s. The built-in items are **self-contained** — their effect is
46
+ * implemented inside the component through the store's primitive setters, so
47
+ * they mount with zero wiring; only `ZoomTo`/`ZoomToGroup` take an `onZoomTo`
48
+ * handler (zooming is the map's concern). Pass an async handler to keep the
49
+ * menu open with in-item loading feedback until it settles.
50
+ *
28
51
  * ```tsx
29
52
  * import { Legend } from '@carto/ps-react-ui/legend'
30
53
  *
31
54
  * <Legend.Provider id='map-legend' layers={layers} groups={groups}>
55
+ * // Panel-wide actions (Show all groups / Collapse all) live once here,
56
+ * // not repeated per group.
57
+ * <Legend.PanelMenu>
58
+ * <Legend.ShowAllGroups />
59
+ * <Legend.CollapseAllGroups />
60
+ * </Legend.PanelMenu>
61
+ *
32
62
  * <Legend.Group groupId='demographics'>
33
63
  * <Legend.Actions>
34
64
  * <Legend.VisibilityToggle /> // tri-state group eye
65
+ * <Legend.GroupMenu> // ⋮ (rightmost)
66
+ * <Legend.ShowOnlyGroup />
67
+ * <Legend.ShowAllLayers /> // this group's members
68
+ * <Legend.ZoomToGroup onZoomTo={zoomToGroup} />
69
+ * </Legend.GroupMenu>
35
70
  * </Legend.Actions>
36
71
  * <Legend.Row layerId='income'>
37
- * // Actions read L→R: options · opacity · visibility
72
+ * // Actions read L→R: eye · options
38
73
  * <Legend.Actions>
74
+ * <Legend.VisibilityToggle /> // layer eye
39
75
  * <Legend.RowMenu>
76
+ * <Legend.ShowOnlyLayer />
77
+ * <Legend.Opacity.Item />
40
78
  * <Legend.ZoomTo onZoomTo={zoomTo} />
41
79
  * </Legend.RowMenu>
42
- * <Legend.Opacity.Trigger />
43
- * <Legend.VisibilityToggle /> // layer eye
44
80
  * </Legend.Actions>
81
+ * <Legend.Opacity.Inline /> // bar between header and body
45
82
  * <Legend.ConfigSelect />
46
83
  * <Legend.Item />
47
- * <Legend.Opacity.Popover /> // anchors to the row header
48
84
  * </Legend.Row>
49
85
  * </Legend.Group>
50
86
  * </Legend.Provider>
51
87
  * ```
52
88
  *
89
+ * `Legend.ShowAllLayers` is composed at whichever site it targets: directly
90
+ * in a `Legend.GroupMenu` for that group's members (as above), or in an
91
+ * ungrouped row's `Legend.RowMenu` for the loose-layer bucket — it resolves
92
+ * its own scope from context either way.
93
+ *
53
94
  * @experimental This API is new and may change in a future release.
54
95
  */
55
96
  export const Legend = {
@@ -58,11 +99,21 @@ export const Legend = {
58
99
  Group: LegendGroup,
59
100
  Row: LegendRow,
60
101
  Actions: LegendActions,
102
+ Sortable: LegendSortable,
61
103
 
62
104
  // Store-connected actions & controls (context-driven)
63
105
  VisibilityToggle: LegendVisibilityToggle,
64
106
  RowMenu: LegendRowMenu,
65
107
  ZoomTo: LegendZoomTo,
108
+ ShowOnlyLayer: LegendShowOnlyLayer,
109
+ ShowAllLayers: LegendShowAllLayers,
110
+ GroupMenu: LegendGroupMenu,
111
+ ShowOnlyGroup: LegendShowOnlyGroup,
112
+ ShowAllGroups: LegendShowAllGroups,
113
+ CollapseAllGroups: LegendCollapseAllGroups,
114
+ ZoomToGroup: LegendZoomToGroup,
115
+ PanelMenu: LegendPanelMenu,
116
+ VisibleLayers: LegendVisibleLayers,
66
117
  Opacity: LegendOpacity,
67
118
  ConfigSelect: LegendConfigSelect,
68
119
  Item: LegendItem,
@@ -85,9 +136,13 @@ export type { LegendProviderProps, LegendLabels } from './provider'
85
136
  export type { LegendGroupProps } from './components/legend-group/legend-group'
86
137
  export type { LegendRowProps } from './components/legend-row/legend-row'
87
138
  export type { LegendActionsProps } from './components/legend-actions/legend-actions'
139
+ export type { LegendSortableProps } from './components/legend-sortable/legend-sortable'
88
140
  export type { LegendVisibilityToggleProps } from './components/legend-visibility-toggle/legend-visibility-toggle'
89
141
  export type { LegendRowMenuProps } from './components/legend-row-menu/legend-row-menu'
90
142
  export type { LegendZoomToProps } from './components/legend-row-menu/legend-zoom-to'
143
+ export type { LegendGroupMenuProps } from './components/legend-group-menu/legend-group-menu'
144
+ export type { LegendZoomToGroupProps } from './components/legend-group-menu/legend-group-menu-items'
145
+ export type { LegendPanelMenuProps } from './components/legend-panel-menu/legend-panel-menu'
91
146
  export type { LegendRowContextValue } from './components/contexts'
92
147
  export type { LegendItemUIProps } from './components/legend-item/legend-item-ui'
93
148
  export type { LegendSwatchProps } from './components/legend-swatch/legend-swatch'
@@ -13,6 +13,10 @@ export interface LegendLabels {
13
13
  // Row overflow menu
14
14
  layerOptions: string
15
15
  zoomTo: string
16
+ /** In-flight label for Zoom to layer / Zoom to group. */
17
+ loading: string
18
+ showOnlyLayer: string
19
+ showAllLayers: string
16
20
 
17
21
  // Group
18
22
  hideGroup: string
@@ -20,9 +24,30 @@ export interface LegendLabels {
20
24
  expandGroup: string
21
25
  collapseGroup: string
22
26
 
27
+ // Group overflow menu
28
+ groupOptions: string
29
+ showOnlyGroup: string
30
+ showAllGroups: string
31
+ collapseAllGroups: string
32
+ expandAllGroups: string
33
+ zoomToGroup: string
34
+
35
+ // Panel
36
+ panelOptions: string
37
+ /** Sticky header of the visible-layers summary; the count is appended. */
38
+ enabledLayers: string
39
+ /** Tooltip of the visible-layers summary trigger. */
40
+ toggleEnabledLayers: string
41
+ /** Tooltip shared by the ⋮ menu triggers (row, group and panel). */
42
+ moreOptions: string
43
+ /** Drag handle of sortable groups/rows (aria + tooltip). */
44
+ reorder: string
45
+
23
46
  // Row controls
24
47
  opacity: string
25
48
  attribute: string
49
+ /** Generic close affordance (e.g. the inline opacity bar's ✕). */
50
+ close: string
26
51
 
27
52
  // Item renderers
28
53
  radiusRangeBy: string
@@ -41,15 +66,32 @@ export const DEFAULT_LEGEND_LABELS: LegendLabels = {
41
66
  showLayer: 'Show layer',
42
67
 
43
68
  layerOptions: 'Layer options',
44
- zoomTo: 'Zoom to',
69
+ zoomTo: 'Zoom to layer',
70
+ loading: 'Loading…',
71
+ showOnlyLayer: 'Show only this layer',
72
+ showAllLayers: 'Show all layers',
45
73
 
46
74
  hideGroup: 'Hide group',
47
75
  showGroup: 'Show group',
48
76
  expandGroup: 'Expand group',
49
77
  collapseGroup: 'Collapse group',
50
78
 
79
+ groupOptions: 'Group options',
80
+ showOnlyGroup: 'Show only this group',
81
+ showAllGroups: 'Show all groups',
82
+ collapseAllGroups: 'Collapse all groups',
83
+ expandAllGroups: 'Expand all groups',
84
+ zoomToGroup: 'Zoom to group',
85
+
86
+ panelOptions: 'Legend options',
87
+ enabledLayers: 'Enabled layers',
88
+ toggleEnabledLayers: 'Quickly toggle enabled layers',
89
+ moreOptions: 'More options',
90
+ reorder: 'Reorder',
91
+
51
92
  opacity: 'Opacity',
52
93
  attribute: 'Attribute',
94
+ close: 'Close',
53
95
 
54
96
  radiusRangeBy: 'Radius range by',
55
97
  colorBasedOn: 'Color based on',
@@ -43,4 +43,8 @@ export {
43
43
  selectLayersByGroup,
44
44
  selectOrderedLayers,
45
45
  selectGroupVisibility,
46
+ selectLayerCollapsible,
47
+ selectAllGroupsCollapsed,
48
+ selectVisibleLayersGrouped,
49
+ selectHasGroups,
46
50
  } from './selectors'
@@ -1,3 +1,4 @@
1
+ import { isValidElement } from 'react'
1
2
  import { useStore, type StoreApi } from 'zustand'
2
3
  import { createStore } from 'zustand/vanilla'
3
4
  import { devtools } from 'zustand/middleware'
@@ -12,6 +13,7 @@ import type {
12
13
  LegendState,
13
14
  LegendStoreApi,
14
15
  } from './types'
16
+ import { selectLayersByGroup, selectOrderedGroups } from './selectors'
15
17
 
16
18
  // Per-panel legend store registry. One store per `<Legend.Provider id>`.
17
19
  // Mirrors the widgets-v2 registry pattern (keyed Map + refcounted mounts),
@@ -81,6 +83,31 @@ function collapsePatch(
81
83
  : { collapsed, collapsedBeforeHide: collapsed }
82
84
  }
83
85
 
86
+ /**
87
+ * Reorder one entity within its **ordered** bucket: splice it out, insert at
88
+ * the clamped index, and return the dense `order` rewrites (only the entries
89
+ * whose order actually changes). `null` when the position doesn't change or
90
+ * the id isn't in the bucket. Powers `moveLayer` / `moveGroup`.
91
+ */
92
+ function movePatch<T extends { id: string; order: number }>(
93
+ bucket: T[],
94
+ id: string,
95
+ toIndex: number,
96
+ ): Map<string, number> | null {
97
+ const fromIndex = bucket.findIndex((e) => e.id === id)
98
+ if (fromIndex === -1) return null
99
+ const clamped = Math.max(0, Math.min(bucket.length - 1, toIndex))
100
+ if (clamped === fromIndex) return null
101
+ const next = [...bucket]
102
+ const [moved] = next.splice(fromIndex, 1)
103
+ next.splice(clamped, 0, moved!)
104
+ const orders = new Map<string, number>()
105
+ next.forEach((entity, index) => {
106
+ if (entity.order !== index) orders.set(entity.id, index)
107
+ })
108
+ return orders.size > 0 ? orders : null
109
+ }
110
+
84
111
  function updateLayer(
85
112
  state: LegendState,
86
113
  id: string,
@@ -135,11 +162,30 @@ function mergeSections(
135
162
  return sameAsPrev ? prev : merged
136
163
  }
137
164
 
138
- /** Shallow equality over the union of both objects' keys (`Object.is` per value). */
165
+ /**
166
+ * Same-type, same-key React elements with shallow-equal props count as
167
+ * equal, so a ReactNode field (e.g. `LegendGroup.icon`) re-created inline on
168
+ * every render (`icon={<MyIcon />}`) doesn't look "changed" on every sync. A
169
+ * different `key` is treated as a real change, same as a different type.
170
+ */
171
+ function reactElementEqual(a: unknown, b: unknown): boolean {
172
+ if (!isValidElement(a) || !isValidElement(b)) return false
173
+ if (a.type !== b.type || a.key !== b.key) return false
174
+ return shallowEqualEntity(
175
+ a.props as Record<string, unknown>,
176
+ b.props as Record<string, unknown>,
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Shallow equality over the union of both objects' keys — `Object.is` per
182
+ * value, except React elements, which fall back to {@link reactElementEqual}.
183
+ */
139
184
  function shallowEqualEntity<T extends object>(a: T, b: T): boolean {
140
185
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof T>
141
186
  for (const key of keys) {
142
- if (!Object.is(a[key], b[key])) return false
187
+ if (Object.is(a[key], b[key])) continue
188
+ if (!reactElementEqual(a[key], b[key])) return false
143
189
  }
144
190
  return true
145
191
  }
@@ -446,6 +492,24 @@ export function createLegendStore(
446
492
  'setSectionValue',
447
493
  ),
448
494
 
495
+ moveLayer: (lid, toIndex) =>
496
+ set(
497
+ (s) => {
498
+ const layer = s.layers[lid]
499
+ if (!layer) return {}
500
+ const bucket = selectLayersByGroup(s, layer.groupId)
501
+ const orders = movePatch(bucket, lid, toIndex)
502
+ if (!orders) return {}
503
+ const layers = { ...s.layers }
504
+ for (const [id, order] of orders) {
505
+ layers[id] = { ...layers[id]!, order }
506
+ }
507
+ return { layers }
508
+ },
509
+ false,
510
+ 'moveLayer',
511
+ ),
512
+
449
513
  setGroupVisibility: (gid, visible) =>
450
514
  set(
451
515
  (s) => {
@@ -528,6 +592,22 @@ export function createLegendStore(
528
592
  'setGroupCollapsed',
529
593
  ),
530
594
 
595
+ moveGroup: (gid, toIndex) =>
596
+ set(
597
+ (s) => {
598
+ if (!s.groups[gid]) return {}
599
+ const orders = movePatch(selectOrderedGroups(s), gid, toIndex)
600
+ if (!orders) return {}
601
+ const groups = { ...s.groups }
602
+ for (const [id, order] of orders) {
603
+ groups[id] = { ...groups[id]!, order }
604
+ }
605
+ return { groups }
606
+ },
607
+ false,
608
+ 'moveGroup',
609
+ ),
610
+
531
611
  setLegendLayer: (input) =>
532
612
  set((s) => upsertLayerState(s, input), false, 'setLegendLayer'),
533
613
  removeLegendLayer: (lid) =>
@@ -1,12 +1,15 @@
1
+ import { createElement } from 'react'
1
2
  import { afterEach, describe, expect, it } from 'vitest'
2
3
  import {
3
4
  createLegendStore,
4
5
  clearAllLegendStores,
5
6
  } from './legend-store-registry'
6
7
  import {
8
+ selectAllGroupsCollapsed,
7
9
  selectGroupVisibility,
8
10
  selectLayersByGroup,
9
11
  selectOrderedGroups,
12
+ selectVisibleLayersGrouped,
10
13
  } from './selectors'
11
14
  import type { LegendLayerInput, LegendVariable } from './types'
12
15
 
@@ -322,6 +325,57 @@ describe('legend store', () => {
322
325
  expect(store.getState().groups.g1!.collapsed).toBe(true) // persisted
323
326
  })
324
327
 
328
+ it('_sync treats a re-created group icon of the same type/props as unchanged', () => {
329
+ const store = createLegendStore('s', {
330
+ layers,
331
+ groups: [
332
+ { id: 'g1', label: 'One', icon: createElement('span', null, 'icon') },
333
+ ],
334
+ })
335
+ const before = store.getState().groups
336
+
337
+ // New element instance each call (as a consumer re-rendering inline
338
+ // would produce), but same type and shallow-equal props.
339
+ store
340
+ .getState()
341
+ ._sync(layers, [
342
+ { id: 'g1', label: 'One', icon: createElement('span', null, 'icon') },
343
+ ])
344
+
345
+ expect(store.getState().groups).toBe(before) // no sync at all
346
+ })
347
+
348
+ it('_sync detects a real icon change (different type/props)', () => {
349
+ const store = createLegendStore('s', {
350
+ layers,
351
+ groups: [
352
+ { id: 'g1', label: 'One', icon: createElement('span', null, 'icon') },
353
+ ],
354
+ })
355
+
356
+ const newIcon = createElement('div', null, 'icon')
357
+ store.getState()._sync(layers, [{ id: 'g1', label: 'One', icon: newIcon }])
358
+
359
+ expect(store.getState().groups.g1!.icon).toBe(newIcon)
360
+ })
361
+
362
+ it('_sync detects a same-type/same-props icon with a different key as changed', () => {
363
+ const store = createLegendStore('s', {
364
+ groups: [
365
+ {
366
+ id: 'g1',
367
+ label: 'One',
368
+ icon: createElement('span', { key: 'a' }, 'icon'),
369
+ },
370
+ ],
371
+ })
372
+
373
+ const newIcon = createElement('span', { key: 'b' }, 'icon')
374
+ store.getState()._sync([], [{ id: 'g1', label: 'One', icon: newIcon }])
375
+
376
+ expect(store.getState().groups.g1!.icon).toBe(newIcon)
377
+ })
378
+
325
379
  it('_sync preserves the collapse snapshot of a hidden layer', () => {
326
380
  const store = createLegendStore('s', { layers })
327
381
  store.getState().setCollapsed('a', true) // manual collapse
@@ -455,3 +509,117 @@ describe('legend store', () => {
455
509
  ).toContain('c')
456
510
  })
457
511
  })
512
+
513
+ describe('moveLayer / moveGroup', () => {
514
+ // a, b ungrouped; c, d, e in g1; f in g2.
515
+ const moveLayers: LegendLayerInput[] = [
516
+ { id: 'a', name: 'A', variables: [cat] },
517
+ { id: 'b', name: 'B', variables: [cat] },
518
+ { id: 'c', name: 'C', variables: [cat], groupId: 'g1' },
519
+ { id: 'd', name: 'D', variables: [cat], groupId: 'g1' },
520
+ { id: 'e', name: 'E', variables: [cat], groupId: 'g1' },
521
+ { id: 'f', name: 'F', variables: [cat], groupId: 'g2' },
522
+ ]
523
+ const moveGroups = [
524
+ { id: 'g1', label: 'G1' },
525
+ { id: 'g2', label: 'G2' },
526
+ { id: 'g3', label: 'G3' },
527
+ ]
528
+ const orderedIds = (
529
+ s: ReturnType<ReturnType<typeof createLegendStore>['getState']>,
530
+ groupId: string | undefined,
531
+ ) => selectLayersByGroup(s, groupId).map((l) => l.id)
532
+
533
+ it('reorders within the bucket with dense orders', () => {
534
+ const store = createLegendStore('s', {
535
+ layers: moveLayers,
536
+ groups: moveGroups,
537
+ })
538
+ store.getState().moveLayer('e', 0)
539
+ expect(orderedIds(store.getState(), 'g1')).toEqual(['e', 'c', 'd'])
540
+ expect(
541
+ selectLayersByGroup(store.getState(), 'g1').map((l) => l.order),
542
+ ).toEqual([0, 1, 2])
543
+ // Other buckets untouched.
544
+ expect(orderedIds(store.getState(), undefined)).toEqual(['a', 'b'])
545
+ expect(orderedIds(store.getState(), 'g2')).toEqual(['f'])
546
+ })
547
+
548
+ it('moves ungrouped layers within their own bucket', () => {
549
+ const store = createLegendStore('s', {
550
+ layers: moveLayers,
551
+ groups: moveGroups,
552
+ })
553
+ store.getState().moveLayer('b', 0)
554
+ expect(orderedIds(store.getState(), undefined)).toEqual(['b', 'a'])
555
+ })
556
+
557
+ it('clamps the target index and no-ops on unknown ids / same position', () => {
558
+ const store = createLegendStore('s', {
559
+ layers: moveLayers,
560
+ groups: moveGroups,
561
+ })
562
+ store.getState().moveLayer('c', 99) // clamped to the end of g1
563
+ expect(orderedIds(store.getState(), 'g1')).toEqual(['d', 'e', 'c'])
564
+
565
+ const before = store.getState().layers
566
+ store.getState().moveLayer('nope', 0)
567
+ store.getState().moveLayer('c', 2) // already there
568
+ expect(store.getState().layers).toBe(before)
569
+ })
570
+
571
+ it('moveGroup reorders the group list and survives a resync', () => {
572
+ const store = createLegendStore('s', {
573
+ layers: moveLayers,
574
+ groups: moveGroups,
575
+ })
576
+ store.getState().moveGroup('g3', 0)
577
+ expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([
578
+ 'g3',
579
+ 'g1',
580
+ 'g2',
581
+ ])
582
+ // A provider resync with the original inputs preserves the user's order.
583
+ store.getState()._sync(moveLayers, moveGroups)
584
+ expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([
585
+ 'g3',
586
+ 'g1',
587
+ 'g2',
588
+ ])
589
+
590
+ const before = store.getState().groups
591
+ store.getState().moveGroup('nope', 0)
592
+ expect(store.getState().groups).toBe(before)
593
+ })
594
+ })
595
+
596
+ describe('summary selectors', () => {
597
+ // a, b ungrouped; c, d in g1 — plus a second group for cross-group checks.
598
+ const groupedLayers: LegendLayerInput[] = [
599
+ ...layers,
600
+ { id: 'e', name: 'E', variables: [cat], groupId: 'g2' },
601
+ ]
602
+ const groups = [
603
+ { id: 'g1', label: 'G1' },
604
+ { id: 'g2', label: 'G2' },
605
+ ]
606
+
607
+ it('selectVisibleLayersGrouped lists only visible layers, ungrouped last', () => {
608
+ const store = createLegendStore('s', { layers: groupedLayers, groups })
609
+ store.getState().setVisibility('d', false)
610
+ store.getState().setVisibility('e', false) // g2 fully hidden → dropped
611
+ const sections = selectVisibleLayersGrouped(store.getState())
612
+ expect(sections.map((x) => x.group?.id)).toEqual(['g1', undefined])
613
+ expect(sections[0]!.layers.map((l) => l.id)).toEqual(['c'])
614
+ expect(sections[1]!.layers.map((l) => l.id)).toEqual(['a', 'b'])
615
+ })
616
+
617
+ it('selectAllGroupsCollapsed flips only when every group is collapsed', () => {
618
+ const store = createLegendStore('s', { layers: groupedLayers, groups })
619
+ expect(selectAllGroupsCollapsed(store.getState())).toBe(false)
620
+ store.getState().setGroupCollapsed('g1', true)
621
+ expect(selectAllGroupsCollapsed(store.getState())).toBe(false)
622
+ store.getState().setGroupCollapsed('g2', true)
623
+ expect(selectAllGroupsCollapsed(store.getState())).toBe(true)
624
+ })
625
+ })
@@ -36,6 +36,69 @@ export function selectOrderedLayers(state: LegendState): LegendLayer[] {
36
36
  return Object.values(state.layers).sort(byOrder)
37
37
  }
38
38
 
39
+ /**
40
+ * `true` when every group is collapsed (`false` with no groups) — drives the
41
+ * "Collapse all groups" / "Expand all groups" menu label flip.
42
+ *
43
+ * @experimental This API is new and may change in a future release.
44
+ */
45
+ export function selectAllGroupsCollapsed(state: LegendState): boolean {
46
+ const groups = Object.values(state.groups)
47
+ return groups.length > 0 && groups.every((g) => g.collapsed)
48
+ }
49
+
50
+ /**
51
+ * Whether the legend has groups — gates panel-level group actions.
52
+ *
53
+ * @experimental This API is new and may change in a future release.
54
+ */
55
+ export function selectHasGroups(state: LegendState): boolean {
56
+ return Object.keys(state.groups).length > 0
57
+ }
58
+
59
+ /**
60
+ * Visible layers grouped for the enabled-layers menu.
61
+ *
62
+ * @experimental This API is new and may change in a future release.
63
+ */
64
+ export function selectVisibleLayersGrouped(
65
+ state: LegendState,
66
+ ): { group?: LegendGroup; layers: LegendLayer[] }[] {
67
+ const visible = (groupId: string | undefined) =>
68
+ selectLayersByGroup(state, groupId).filter((l) => l.visible)
69
+ const sections: { group?: LegendGroup; layers: LegendLayer[] }[] = []
70
+ for (const group of selectOrderedGroups(state)) {
71
+ const layers = visible(group.id)
72
+ if (layers.length > 0) sections.push({ group, layers })
73
+ }
74
+ const ungrouped = visible(undefined)
75
+ if (ungrouped.length > 0) sections.push({ layers: ungrouped })
76
+ return sections
77
+ }
78
+
79
+ /**
80
+ * Whether a layer has any collapsible body — something the row's `Collapse`
81
+ * can actually fold. A layer with no variables, sections, or helper note
82
+ * renders an empty body, so there is nothing to collapse. Gates the row's
83
+ * title-area collapse affordance (chevron + click).
84
+ *
85
+ * @experimental This API is new and may change in a future release.
86
+ */
87
+ export function selectLayerCollapsible(
88
+ state: LegendState,
89
+ layerId: string,
90
+ ): boolean {
91
+ const layer = state.layers[layerId]
92
+ if (!layer) return false
93
+ return (
94
+ layer.variables.length > 0 ||
95
+ (layer.sections?.length ?? 0) > 0 ||
96
+ // Truthiness matches the render condition — an empty-string note renders
97
+ // no footer, so it must not enable the collapse affordance.
98
+ Boolean(layer.helperText)
99
+ )
100
+ }
101
+
39
102
  /**
40
103
  * Tri-state visibility — derived for groups from their member layers, and the
41
104
  * state contract of a controlled `Legend.VisibilityToggle` for any custom
@@ -55,8 +118,7 @@ export function selectGroupVisibility(
55
118
  ): LegendVisibility {
56
119
  let anyVisible = false
57
120
  let anyHidden = false
58
- for (const layer of Object.values(state.layers)) {
59
- if (layer.groupId !== groupId) continue
121
+ for (const layer of selectLayersByGroup(state, groupId)) {
60
122
  if (layer.visible) anyVisible = true
61
123
  else anyHidden = true
62
124
  }
@@ -242,6 +242,14 @@ export interface LegendLayer {
242
242
  export interface LegendGroup {
243
243
  id: string
244
244
  label: string
245
+ /**
246
+ * Optional header icon. Rendered only when provided — no icon shows by
247
+ * default. A same-type element with shallow-equal props is treated as
248
+ * unchanged across re-syncs even when re-created inline (e.g.
249
+ * `icon={<MyIcon />}`); a deeply nested custom node should be memoized if
250
+ * you want to avoid unnecessary syncs.
251
+ */
252
+ icon?: ReactNode
245
253
  collapsed: boolean
246
254
  /**
247
255
  * @internal Collapsed state snapshotted when the group was hidden via
@@ -292,6 +300,12 @@ export interface LegendState {
292
300
  * section's `options`.
293
301
  */
294
302
  setSectionValue: (id: string, sectionId: string, value: string) => void
303
+ /**
304
+ * Move a layer to `toIndex` **within its sibling bucket** (its group, or
305
+ * the ungrouped bucket). `toIndex` is clamped; orders stay dense. No-op
306
+ * for unknown ids or when the position doesn't change.
307
+ */
308
+ moveLayer: (id: string, toIndex: number) => void
295
309
 
296
310
  // Group setters
297
311
  /**
@@ -303,6 +317,11 @@ export interface LegendState {
303
317
  */
304
318
  setGroupVisibility: (id: string, visible: boolean) => void
305
319
  setGroupCollapsed: (id: string, collapsed: boolean) => void
320
+ /**
321
+ * Move a group to `toIndex` within the group list. `toIndex` is clamped;
322
+ * orders stay dense. No-op for unknown ids or an unchanged position.
323
+ */
324
+ moveGroup: (id: string, toIndex: number) => void
306
325
 
307
326
  // Imperative add/remove/update — an alternative to the Provider's bulk
308
327
  // `layers`/`groups` props. To use these, keep those props stable (pass the