@carto/ps-react-ui 4.15.0 → 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 (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-CVYzhR1-.js +464 -0
  5. package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
  6. package/dist/legend.js +1478 -794
  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 +11 -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 +166 -9
  47. package/src/legend/components/legend-group/legend-group.tsx +88 -49
  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 +60 -0
  77. package/src/legend/stores/legend-store.test.ts +116 -0
  78. package/src/legend/stores/selectors.ts +64 -2
  79. package/src/legend/stores/types.ts +11 -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'
@@ -12,6 +12,7 @@ import type {
12
12
  LegendState,
13
13
  LegendStoreApi,
14
14
  } from './types'
15
+ import { selectLayersByGroup, selectOrderedGroups } from './selectors'
15
16
 
16
17
  // Per-panel legend store registry. One store per `<Legend.Provider id>`.
17
18
  // Mirrors the widgets-v2 registry pattern (keyed Map + refcounted mounts),
@@ -81,6 +82,31 @@ function collapsePatch(
81
82
  : { collapsed, collapsedBeforeHide: collapsed }
82
83
  }
83
84
 
85
+ /**
86
+ * Reorder one entity within its **ordered** bucket: splice it out, insert at
87
+ * the clamped index, and return the dense `order` rewrites (only the entries
88
+ * whose order actually changes). `null` when the position doesn't change or
89
+ * the id isn't in the bucket. Powers `moveLayer` / `moveGroup`.
90
+ */
91
+ function movePatch<T extends { id: string; order: number }>(
92
+ bucket: T[],
93
+ id: string,
94
+ toIndex: number,
95
+ ): Map<string, number> | null {
96
+ const fromIndex = bucket.findIndex((e) => e.id === id)
97
+ if (fromIndex === -1) return null
98
+ const clamped = Math.max(0, Math.min(bucket.length - 1, toIndex))
99
+ if (clamped === fromIndex) return null
100
+ const next = [...bucket]
101
+ const [moved] = next.splice(fromIndex, 1)
102
+ next.splice(clamped, 0, moved!)
103
+ const orders = new Map<string, number>()
104
+ next.forEach((entity, index) => {
105
+ if (entity.order !== index) orders.set(entity.id, index)
106
+ })
107
+ return orders.size > 0 ? orders : null
108
+ }
109
+
84
110
  function updateLayer(
85
111
  state: LegendState,
86
112
  id: string,
@@ -446,6 +472,24 @@ export function createLegendStore(
446
472
  'setSectionValue',
447
473
  ),
448
474
 
475
+ moveLayer: (lid, toIndex) =>
476
+ set(
477
+ (s) => {
478
+ const layer = s.layers[lid]
479
+ if (!layer) return {}
480
+ const bucket = selectLayersByGroup(s, layer.groupId)
481
+ const orders = movePatch(bucket, lid, toIndex)
482
+ if (!orders) return {}
483
+ const layers = { ...s.layers }
484
+ for (const [id, order] of orders) {
485
+ layers[id] = { ...layers[id]!, order }
486
+ }
487
+ return { layers }
488
+ },
489
+ false,
490
+ 'moveLayer',
491
+ ),
492
+
449
493
  setGroupVisibility: (gid, visible) =>
450
494
  set(
451
495
  (s) => {
@@ -528,6 +572,22 @@ export function createLegendStore(
528
572
  'setGroupCollapsed',
529
573
  ),
530
574
 
575
+ moveGroup: (gid, toIndex) =>
576
+ set(
577
+ (s) => {
578
+ if (!s.groups[gid]) return {}
579
+ const orders = movePatch(selectOrderedGroups(s), gid, toIndex)
580
+ if (!orders) return {}
581
+ const groups = { ...s.groups }
582
+ for (const [id, order] of orders) {
583
+ groups[id] = { ...groups[id]!, order }
584
+ }
585
+ return { groups }
586
+ },
587
+ false,
588
+ 'moveGroup',
589
+ ),
590
+
531
591
  setLegendLayer: (input) =>
532
592
  set((s) => upsertLayerState(s, input), false, 'setLegendLayer'),
533
593
  removeLegendLayer: (lid) =>
@@ -4,9 +4,11 @@ import {
4
4
  clearAllLegendStores,
5
5
  } from './legend-store-registry'
6
6
  import {
7
+ selectAllGroupsCollapsed,
7
8
  selectGroupVisibility,
8
9
  selectLayersByGroup,
9
10
  selectOrderedGroups,
11
+ selectVisibleLayersGrouped,
10
12
  } from './selectors'
11
13
  import type { LegendLayerInput, LegendVariable } from './types'
12
14
 
@@ -455,3 +457,117 @@ describe('legend store', () => {
455
457
  ).toContain('c')
456
458
  })
457
459
  })
460
+
461
+ describe('moveLayer / moveGroup', () => {
462
+ // a, b ungrouped; c, d, e in g1; f in g2.
463
+ const moveLayers: LegendLayerInput[] = [
464
+ { id: 'a', name: 'A', variables: [cat] },
465
+ { id: 'b', name: 'B', variables: [cat] },
466
+ { id: 'c', name: 'C', variables: [cat], groupId: 'g1' },
467
+ { id: 'd', name: 'D', variables: [cat], groupId: 'g1' },
468
+ { id: 'e', name: 'E', variables: [cat], groupId: 'g1' },
469
+ { id: 'f', name: 'F', variables: [cat], groupId: 'g2' },
470
+ ]
471
+ const moveGroups = [
472
+ { id: 'g1', label: 'G1' },
473
+ { id: 'g2', label: 'G2' },
474
+ { id: 'g3', label: 'G3' },
475
+ ]
476
+ const orderedIds = (
477
+ s: ReturnType<ReturnType<typeof createLegendStore>['getState']>,
478
+ groupId: string | undefined,
479
+ ) => selectLayersByGroup(s, groupId).map((l) => l.id)
480
+
481
+ it('reorders within the bucket with dense orders', () => {
482
+ const store = createLegendStore('s', {
483
+ layers: moveLayers,
484
+ groups: moveGroups,
485
+ })
486
+ store.getState().moveLayer('e', 0)
487
+ expect(orderedIds(store.getState(), 'g1')).toEqual(['e', 'c', 'd'])
488
+ expect(
489
+ selectLayersByGroup(store.getState(), 'g1').map((l) => l.order),
490
+ ).toEqual([0, 1, 2])
491
+ // Other buckets untouched.
492
+ expect(orderedIds(store.getState(), undefined)).toEqual(['a', 'b'])
493
+ expect(orderedIds(store.getState(), 'g2')).toEqual(['f'])
494
+ })
495
+
496
+ it('moves ungrouped layers within their own bucket', () => {
497
+ const store = createLegendStore('s', {
498
+ layers: moveLayers,
499
+ groups: moveGroups,
500
+ })
501
+ store.getState().moveLayer('b', 0)
502
+ expect(orderedIds(store.getState(), undefined)).toEqual(['b', 'a'])
503
+ })
504
+
505
+ it('clamps the target index and no-ops on unknown ids / same position', () => {
506
+ const store = createLegendStore('s', {
507
+ layers: moveLayers,
508
+ groups: moveGroups,
509
+ })
510
+ store.getState().moveLayer('c', 99) // clamped to the end of g1
511
+ expect(orderedIds(store.getState(), 'g1')).toEqual(['d', 'e', 'c'])
512
+
513
+ const before = store.getState().layers
514
+ store.getState().moveLayer('nope', 0)
515
+ store.getState().moveLayer('c', 2) // already there
516
+ expect(store.getState().layers).toBe(before)
517
+ })
518
+
519
+ it('moveGroup reorders the group list and survives a resync', () => {
520
+ const store = createLegendStore('s', {
521
+ layers: moveLayers,
522
+ groups: moveGroups,
523
+ })
524
+ store.getState().moveGroup('g3', 0)
525
+ expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([
526
+ 'g3',
527
+ 'g1',
528
+ 'g2',
529
+ ])
530
+ // A provider resync with the original inputs preserves the user's order.
531
+ store.getState()._sync(moveLayers, moveGroups)
532
+ expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([
533
+ 'g3',
534
+ 'g1',
535
+ 'g2',
536
+ ])
537
+
538
+ const before = store.getState().groups
539
+ store.getState().moveGroup('nope', 0)
540
+ expect(store.getState().groups).toBe(before)
541
+ })
542
+ })
543
+
544
+ describe('summary selectors', () => {
545
+ // a, b ungrouped; c, d in g1 — plus a second group for cross-group checks.
546
+ const groupedLayers: LegendLayerInput[] = [
547
+ ...layers,
548
+ { id: 'e', name: 'E', variables: [cat], groupId: 'g2' },
549
+ ]
550
+ const groups = [
551
+ { id: 'g1', label: 'G1' },
552
+ { id: 'g2', label: 'G2' },
553
+ ]
554
+
555
+ it('selectVisibleLayersGrouped lists only visible layers, ungrouped last', () => {
556
+ const store = createLegendStore('s', { layers: groupedLayers, groups })
557
+ store.getState().setVisibility('d', false)
558
+ store.getState().setVisibility('e', false) // g2 fully hidden → dropped
559
+ const sections = selectVisibleLayersGrouped(store.getState())
560
+ expect(sections.map((x) => x.group?.id)).toEqual(['g1', undefined])
561
+ expect(sections[0]!.layers.map((l) => l.id)).toEqual(['c'])
562
+ expect(sections[1]!.layers.map((l) => l.id)).toEqual(['a', 'b'])
563
+ })
564
+
565
+ it('selectAllGroupsCollapsed flips only when every group is collapsed', () => {
566
+ const store = createLegendStore('s', { layers: groupedLayers, groups })
567
+ expect(selectAllGroupsCollapsed(store.getState())).toBe(false)
568
+ store.getState().setGroupCollapsed('g1', true)
569
+ expect(selectAllGroupsCollapsed(store.getState())).toBe(false)
570
+ store.getState().setGroupCollapsed('g2', true)
571
+ expect(selectAllGroupsCollapsed(store.getState())).toBe(true)
572
+ })
573
+ })
@@ -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
  }
@@ -292,6 +292,12 @@ export interface LegendState {
292
292
  * section's `options`.
293
293
  */
294
294
  setSectionValue: (id: string, sectionId: string, value: string) => void
295
+ /**
296
+ * Move a layer to `toIndex` **within its sibling bucket** (its group, or
297
+ * the ungrouped bucket). `toIndex` is clamped; orders stay dense. No-op
298
+ * for unknown ids or when the position doesn't change.
299
+ */
300
+ moveLayer: (id: string, toIndex: number) => void
295
301
 
296
302
  // Group setters
297
303
  /**
@@ -303,6 +309,11 @@ export interface LegendState {
303
309
  */
304
310
  setGroupVisibility: (id: string, visible: boolean) => void
305
311
  setGroupCollapsed: (id: string, collapsed: boolean) => void
312
+ /**
313
+ * Move a group to `toIndex` within the group list. `toIndex` is clamped;
314
+ * orders stay dense. No-op for unknown ids or an unchanged position.
315
+ */
316
+ moveGroup: (id: string, toIndex: number) => void
306
317
 
307
318
  // Imperative add/remove/update — an alternative to the Provider's bulk
308
319
  // `layers`/`groups` props. To use these, keep those props stable (pass the
@@ -1,4 +1,12 @@
1
- import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
1
+ import {
2
+ describe,
3
+ test,
4
+ expect,
5
+ vi,
6
+ beforeEach,
7
+ afterEach,
8
+ type MockInstance,
9
+ } from 'vitest'
2
10
  import { downloadToCSV, downloadToPNG } from './exports'
3
11
  import { createRef } from 'react'
4
12
  import type { RefObject } from 'react'
@@ -86,8 +94,10 @@ describe('downloadToCSV', () => {
86
94
  describe('downloadToPNG', () => {
87
95
  let mockRef: RefObject<HTMLDivElement>
88
96
  let mockElement: HTMLDivElement
97
+ let warnSpy: MockInstance<typeof console.warn>
89
98
 
90
99
  beforeEach(() => {
100
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
91
101
  // Create a real DOM element
92
102
  mockElement = document.createElement('div')
93
103
  mockElement.style.width = '800px'
@@ -104,6 +114,7 @@ describe('downloadToPNG', () => {
104
114
 
105
115
  afterEach(() => {
106
116
  document.body.removeChild(mockElement)
117
+ warnSpy.mockRestore()
107
118
  vi.clearAllMocks()
108
119
  })
109
120
 
@@ -121,6 +132,7 @@ describe('downloadToPNG', () => {
121
132
  const result = await downloadToPNG.modifier(nullRef)
122
133
 
123
134
  expect(result).toBeUndefined()
135
+ expect(warnSpy).toHaveBeenCalled()
124
136
  })
125
137
 
126
138
  test('modifier returns undefined when ref is a function', async () => {
@@ -129,6 +141,7 @@ describe('downloadToPNG', () => {
129
141
  const result = await downloadToPNG.modifier(functionRef)
130
142
 
131
143
  expect(result).toBeUndefined()
144
+ expect(warnSpy).toHaveBeenCalled()
132
145
  })
133
146
 
134
147
  test('removes toolbar and actions containers from clone', async () => {