@carto/ps-react-ui 4.12.1 → 4.13.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 (104) hide show
  1. package/dist/components.js +706 -658
  2. package/dist/components.js.map +1 -1
  3. package/dist/legend/stores.js +20 -0
  4. package/dist/legend/stores.js.map +1 -0
  5. package/dist/legend.js +1536 -0
  6. package/dist/legend.js.map +1 -0
  7. package/dist/selectors-DwHj6mE2.js +388 -0
  8. package/dist/selectors-DwHj6mE2.js.map +1 -0
  9. package/dist/types/components/index.d.ts +1 -0
  10. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
  11. package/dist/types/legend/components/contexts.d.ts +20 -0
  12. package/dist/types/legend/components/icons.d.ts +12 -0
  13. package/dist/types/legend/components/index.d.ts +26 -0
  14. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
  15. package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
  16. package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
  17. package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
  18. package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
  19. package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
  20. package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
  21. package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
  22. package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
  23. package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
  24. package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
  25. package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
  26. package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
  27. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
  28. package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
  29. package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
  30. package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
  31. package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
  32. package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
  33. package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
  34. package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
  35. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
  36. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
  37. package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
  38. package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
  39. package/dist/types/legend/index.d.ts +88 -0
  40. package/dist/types/legend/provider/index.d.ts +6 -0
  41. package/dist/types/legend/provider/labels.d.ts +27 -0
  42. package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
  43. package/dist/types/legend/provider/legend-provider.d.ts +38 -0
  44. package/dist/types/legend/stores/index.d.ts +5 -0
  45. package/dist/types/legend/stores/legend-context.d.ts +4 -0
  46. package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
  47. package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
  48. package/dist/types/legend/stores/selectors.d.ts +33 -0
  49. package/dist/types/legend/stores/types.d.ts +275 -0
  50. package/dist/widgets-v2/bar.js +7 -3
  51. package/dist/widgets-v2/bar.js.map +1 -1
  52. package/dist/widgets-v2/histogram.js +9 -4
  53. package/dist/widgets-v2/histogram.js.map +1 -1
  54. package/package.json +11 -3
  55. package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
  56. package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
  57. package/src/components/index.ts +2 -0
  58. package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
  59. package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
  60. package/src/legend/components/contexts.ts +32 -0
  61. package/src/legend/components/icons.tsx +22 -0
  62. package/src/legend/components/index.ts +39 -0
  63. package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
  64. package/src/legend/components/legend-actions/styles.ts +9 -0
  65. package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
  66. package/src/legend/components/legend-category/styles.ts +61 -0
  67. package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
  68. package/src/legend/components/legend-config-select/styles.ts +48 -0
  69. package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
  70. package/src/legend/components/legend-group/legend-group.tsx +88 -0
  71. package/src/legend/components/legend-group/styles.ts +82 -0
  72. package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
  73. package/src/legend/components/legend-icon/styles.ts +56 -0
  74. package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
  75. package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
  76. package/src/legend/components/legend-item/legend-item.tsx +34 -0
  77. package/src/legend/components/legend-item/styles.ts +11 -0
  78. package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
  79. package/src/legend/components/legend-opacity/styles.ts +28 -0
  80. package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
  81. package/src/legend/components/legend-proportion/styles.ts +43 -0
  82. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
  83. package/src/legend/components/legend-ramp/styles.ts +62 -0
  84. package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
  85. package/src/legend/components/legend-row/legend-row.tsx +129 -0
  86. package/src/legend/components/legend-row/styles.ts +111 -0
  87. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
  88. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
  89. package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
  90. package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
  91. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
  92. package/src/legend/index.ts +121 -0
  93. package/src/legend/provider/index.ts +6 -0
  94. package/src/legend/provider/labels.ts +59 -0
  95. package/src/legend/provider/legend-config-context.ts +15 -0
  96. package/src/legend/provider/legend-provider.tsx +102 -0
  97. package/src/legend/stores/index.ts +46 -0
  98. package/src/legend/stores/legend-context.ts +17 -0
  99. package/src/legend/stores/legend-store-registry.ts +639 -0
  100. package/src/legend/stores/legend-store.test.ts +457 -0
  101. package/src/legend/stores/selectors.ts +65 -0
  102. package/src/legend/stores/types.ts +347 -0
  103. package/src/widgets-v2/bar/options.ts +4 -0
  104. package/src/widgets-v2/histogram/options.ts +5 -0
@@ -0,0 +1,457 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ createLegendStore,
4
+ clearAllLegendStores,
5
+ } from './legend-store-registry'
6
+ import {
7
+ selectGroupVisibility,
8
+ selectLayersByGroup,
9
+ selectOrderedGroups,
10
+ } from './selectors'
11
+ import type { LegendLayerInput, LegendVariable } from './types'
12
+
13
+ const cat: LegendVariable = {
14
+ type: 'category',
15
+ items: [{ label: 'A', color: '#000' }],
16
+ }
17
+
18
+ const layers: LegendLayerInput[] = [
19
+ { id: 'a', name: 'A', variables: [cat] },
20
+ { id: 'b', name: 'B', variables: [cat] },
21
+ { id: 'c', name: 'C', variables: [cat], groupId: 'g1' },
22
+ { id: 'd', name: 'D', variables: [cat], groupId: 'g1' },
23
+ ]
24
+
25
+ afterEach(() => clearAllLegendStores())
26
+
27
+ describe('legend store', () => {
28
+ it('seeds interactive defaults (visible/opacity/collapsed/order)', () => {
29
+ const store = createLegendStore('s', { layers })
30
+ const a = store.getState().layers.a!
31
+ expect(a.visible).toBe(true)
32
+ expect(a.opacity).toBe(1)
33
+ expect(a.collapsed).toBe(false)
34
+ expect(a.order).toBe(0)
35
+ expect(store.getState().layers.b!.order).toBe(1)
36
+ })
37
+
38
+ it('setVisibility / setCollapsed / setSectionValue mutate the layer', () => {
39
+ const store = createLegendStore('s', {
40
+ layers: [
41
+ {
42
+ id: 'a',
43
+ name: 'A',
44
+ variables: [cat],
45
+ sections: [{ id: 's1', options: ['x', 'pop'], active: 'x' }],
46
+ },
47
+ ],
48
+ })
49
+ const s = store.getState()
50
+ s.setVisibility('a', false)
51
+ s.setCollapsed('a', true)
52
+ s.setSectionValue('a', 's1', 'pop')
53
+ const a = store.getState().layers.a!
54
+ expect(a.visible).toBe(false)
55
+ expect(a.collapsed).toBe(true)
56
+ expect(a.sections![0]!.active).toBe('pop')
57
+ })
58
+
59
+ it('setSectionValue validates the section and option', () => {
60
+ const sections = [
61
+ { id: 's1', title: 'Global', options: ['x', 'y'], active: 'x' },
62
+ { id: 's2', title: 'Vars', options: ['m', 'n'], active: 'm' },
63
+ ]
64
+ const store = createLegendStore('s', {
65
+ layers: [{ id: 'a', name: 'A', variables: [cat], sections }],
66
+ })
67
+ const before = store.getState().layers
68
+
69
+ store.getState().setSectionValue('a', 'nope', 'y') // unknown section
70
+ store.getState().setSectionValue('a', 's1', 'zzz') // option not offered
71
+ store.getState().setSectionValue('zz', 's1', 'y') // unknown layer
72
+ expect(store.getState().layers).toBe(before) // all no-ops
73
+
74
+ // Sections keep independent actives.
75
+ store.getState().setSectionValue('a', 's2', 'n')
76
+ const after = store.getState().layers.a!.sections!
77
+ expect(after[0]!.active).toBe('x')
78
+ expect(after[1]!.active).toBe('n')
79
+ })
80
+
81
+ it('_sync preserves user-picked section actives (structure follows props)', () => {
82
+ const sections = [
83
+ { id: 's1', options: ['x', 'y'], active: 'x' },
84
+ { id: 's2', options: ['m', 'n'], active: 'm' },
85
+ ]
86
+ const input = [{ id: 'a', name: 'A', variables: [cat], sections }]
87
+ const store = createLegendStore('s', { layers: input })
88
+ store.getState().setSectionValue('a', 's1', 'y')
89
+
90
+ // Re-sync with a NEW sections reference (renamed layer): the user's pick
91
+ // survives because s1 still exists and 'y' is still offered.
92
+ store.getState()._sync(
93
+ [
94
+ {
95
+ id: 'a',
96
+ name: 'A renamed',
97
+ variables: [cat],
98
+ sections: sections.map((x) => ({ ...x })),
99
+ },
100
+ ],
101
+ [],
102
+ )
103
+ let after = store.getState().layers.a!
104
+ expect(after.name).toBe('A renamed')
105
+ expect(after.sections![0]!.active).toBe('y')
106
+
107
+ // If the pick is no longer among the options, the incoming active wins.
108
+ store.getState()._sync(
109
+ [
110
+ {
111
+ id: 'a',
112
+ name: 'A',
113
+ variables: [cat],
114
+ sections: [{ id: 's1', options: ['x', 'z'], active: 'z' }],
115
+ },
116
+ ],
117
+ [],
118
+ )
119
+ after = store.getState().layers.a!
120
+ expect(after.sections![0]!.active).toBe('z')
121
+ })
122
+
123
+ it('setOpacity clamps to 0–1', () => {
124
+ const store = createLegendStore('s', { layers })
125
+ store.getState().setOpacity('a', 5)
126
+ expect(store.getState().layers.a!.opacity).toBe(1)
127
+ store.getState().setOpacity('a', -2)
128
+ expect(store.getState().layers.a!.opacity).toBe(0)
129
+ })
130
+
131
+ it('group setters cascade visibility/collapse to members', () => {
132
+ const store = createLegendStore('s', {
133
+ layers,
134
+ groups: [
135
+ { id: 'g1', label: 'One' },
136
+ { id: 'g2', label: 'Two' },
137
+ ],
138
+ })
139
+ // Group visibility cascades to the member layers (c, d).
140
+ store.getState().setGroupVisibility('g1', false)
141
+ store.getState().setGroupCollapsed('g1', true)
142
+ expect(store.getState().layers.c!.visible).toBe(false)
143
+ expect(store.getState().layers.d!.visible).toBe(false)
144
+ expect(selectGroupVisibility(store.getState(), 'g1')).toBe('hidden')
145
+ expect(store.getState().groups.g1!.collapsed).toBe(true)
146
+ })
147
+
148
+ it('selectGroupVisibility derives the tri-state from member layers', () => {
149
+ const store = createLegendStore('s', {
150
+ layers,
151
+ groups: [{ id: 'g1', label: 'One' }],
152
+ })
153
+ expect(selectGroupVisibility(store.getState(), 'g1')).toBe('visible')
154
+ store.getState().setVisibility('c', false)
155
+ expect(selectGroupVisibility(store.getState(), 'g1')).toBe('mixed')
156
+ store.getState().setVisibility('d', false)
157
+ expect(selectGroupVisibility(store.getState(), 'g1')).toBe('hidden')
158
+ // Empty / unknown groups read as visible.
159
+ expect(selectGroupVisibility(store.getState(), 'nope')).toBe('visible')
160
+ })
161
+
162
+ it('hiding a layer individually never collapses its group', () => {
163
+ const store = createLegendStore('s', {
164
+ layers,
165
+ groups: [{ id: 'g1', label: 'One' }],
166
+ })
167
+ store.getState().setVisibility('c', false)
168
+ store.getState().setVisibility('d', false) // group now derived-hidden
169
+ expect(store.getState().groups.g1!.collapsed).toBe(false)
170
+ })
171
+
172
+ it('group cascade preserves each member layer collapse snapshot', () => {
173
+ const store = createLegendStore('s', {
174
+ layers,
175
+ groups: [{ id: 'g1', label: 'One' }],
176
+ })
177
+ store.getState().setCollapsed('c', true) // manual collapse on one member
178
+ store.getState().setGroupVisibility('g1', false)
179
+ store.getState().setGroupVisibility('g1', true)
180
+ expect(store.getState().layers.c!.collapsed).toBe(true) // persisted
181
+ expect(store.getState().layers.d!.collapsed).toBe(false) // restored default
182
+ })
183
+
184
+ it('hiding a mixed group leaves already-hidden members untouched', () => {
185
+ const store = createLegendStore('s', {
186
+ layers,
187
+ groups: [{ id: 'g1', label: 'One' }],
188
+ })
189
+ store.getState().setVisibility('c', false) // c hidden, snapshot: expanded
190
+ store.getState().setGroupVisibility('g1', false) // hides d only
191
+ store.getState().setGroupVisibility('g1', true) // shows both
192
+ expect(store.getState().layers.c!.visible).toBe(true)
193
+ expect(store.getState().layers.c!.collapsed).toBe(false) // own snapshot
194
+ expect(store.getState().layers.d!.visible).toBe(true)
195
+ })
196
+
197
+ it('setGroupVisibility no-ops at the target state and on empty groups', () => {
198
+ const store = createLegendStore('s', {
199
+ layers,
200
+ groups: [
201
+ { id: 'g1', label: 'One' },
202
+ { id: 'empty', label: 'Empty' },
203
+ ],
204
+ })
205
+ const before = store.getState()
206
+ store.getState().setGroupVisibility('g1', true) // already all visible
207
+ store.getState().setGroupVisibility('empty', false) // no members
208
+ expect(store.getState().layers).toBe(before.layers)
209
+ expect(store.getState().groups).toBe(before.groups)
210
+
211
+ store.getState().setGroupVisibility('g1', false)
212
+ const hidden = store.getState()
213
+ store.getState().setGroupVisibility('g1', false) // double-hide
214
+ expect(store.getState().layers).toBe(hidden.layers)
215
+ })
216
+
217
+ it('_sync preserves interactive state of known layers and drops vanished ones', () => {
218
+ const store = createLegendStore('s', { layers })
219
+ store.getState().setVisibility('a', false)
220
+ store.getState().setOpacity('a', 0.3)
221
+
222
+ // Re-sync with updated name for "a" and without "b".
223
+ store.getState()._sync(
224
+ [
225
+ { id: 'a', name: 'A renamed', variables: [cat] },
226
+ { id: 'c', name: 'C', variables: [cat], groupId: 'g1' },
227
+ ],
228
+ [],
229
+ )
230
+
231
+ const a = store.getState().layers.a!
232
+ expect(a.name).toBe('A renamed') // static field follows props
233
+ expect(a.visible).toBe(false) // interactive state preserved
234
+ expect(a.opacity).toBe(0.3)
235
+ expect(store.getState().layers.b).toBeUndefined() // vanished layer dropped
236
+ })
237
+
238
+ it('normalizes duplicate sibling orders to dense indexes', () => {
239
+ const store = createLegendStore('s', {
240
+ layers: [
241
+ { id: 'x', name: 'X', variables: [cat], order: 5 },
242
+ { id: 'y', name: 'Y', variables: [cat], order: 5 },
243
+ { id: 'z', name: 'Z', variables: [cat], order: 6 },
244
+ ],
245
+ })
246
+ // Ties broken by input position → dense 0,1,2.
247
+ expect(store.getState().layers.x!.order).toBe(0)
248
+ expect(store.getState().layers.y!.order).toBe(1)
249
+ expect(store.getState().layers.z!.order).toBe(2)
250
+ expect(
251
+ selectLayersByGroup(store.getState(), undefined).map((l) => l.id),
252
+ ).toEqual(['x', 'y', 'z'])
253
+ })
254
+
255
+ it('_sync with identical input is a no-op preserving object identity', () => {
256
+ const store = createLegendStore('s', { layers })
257
+ const before = store.getState()
258
+
259
+ store.getState()._sync(layers, [])
260
+
261
+ const after = store.getState()
262
+ expect(after.layers).toBe(before.layers) // record identity kept
263
+ expect(after.layers.a).toBe(before.layers.a) // entity identity kept
264
+ })
265
+
266
+ it('visibility drives collapse: hide → collapsed, show → expanded', () => {
267
+ const store = createLegendStore('s', { layers })
268
+ store.getState().setVisibility('a', false)
269
+ expect(store.getState().layers.a!.collapsed).toBe(true)
270
+ store.getState().setVisibility('a', true)
271
+ expect(store.getState().layers.a!.collapsed).toBe(false)
272
+ })
273
+
274
+ it('manual collapse persists across hide/show', () => {
275
+ const store = createLegendStore('s', { layers })
276
+ store.getState().setCollapsed('a', true) // manual collapse
277
+ store.getState().setVisibility('a', false)
278
+ expect(store.getState().layers.a!.collapsed).toBe(true)
279
+ store.getState().setVisibility('a', true)
280
+ expect(store.getState().layers.a!.collapsed).toBe(true) // persisted
281
+ })
282
+
283
+ it('manual expand persists: hide collapses, show restores expanded', () => {
284
+ const store = createLegendStore('s', { layers })
285
+ store.getState().setCollapsed('a', true)
286
+ store.getState().setCollapsed('a', false) // manual expand
287
+ store.getState().setVisibility('a', false)
288
+ expect(store.getState().layers.a!.collapsed).toBe(true) // hidden = collapsed
289
+ store.getState().setVisibility('a', true)
290
+ expect(store.getState().layers.a!.collapsed).toBe(false) // restored
291
+ })
292
+
293
+ it('manual toggle while hidden wins on the next show', () => {
294
+ const store = createLegendStore('s', { layers })
295
+ store.getState().setVisibility('a', false) // snapshot: expanded
296
+ store.getState().setCollapsed('a', true) // manual choice while hidden
297
+ store.getState().setVisibility('a', true)
298
+ expect(store.getState().layers.a!.collapsed).toBe(true)
299
+ })
300
+
301
+ it('double-hide is a no-op and does not corrupt the snapshot', () => {
302
+ const store = createLegendStore('s', { layers })
303
+ store.getState().setVisibility('a', false)
304
+ store.getState().setVisibility('a', false) // idempotent
305
+ store.getState().setVisibility('a', true)
306
+ expect(store.getState().layers.a!.collapsed).toBe(false) // pre-hide state
307
+ })
308
+
309
+ it('group visibility drives group collapse with the same persistence', () => {
310
+ const store = createLegendStore('s', {
311
+ layers,
312
+ groups: [{ id: 'g1', label: 'One' }],
313
+ })
314
+ store.getState().setGroupVisibility('g1', false)
315
+ expect(store.getState().groups.g1!.collapsed).toBe(true)
316
+ store.getState().setGroupVisibility('g1', true)
317
+ expect(store.getState().groups.g1!.collapsed).toBe(false)
318
+
319
+ store.getState().setGroupCollapsed('g1', true) // manual
320
+ store.getState().setGroupVisibility('g1', false)
321
+ store.getState().setGroupVisibility('g1', true)
322
+ expect(store.getState().groups.g1!.collapsed).toBe(true) // persisted
323
+ })
324
+
325
+ it('_sync preserves the collapse snapshot of a hidden layer', () => {
326
+ const store = createLegendStore('s', { layers })
327
+ store.getState().setCollapsed('a', true) // manual collapse
328
+ store.getState().setVisibility('a', false)
329
+
330
+ store.getState()._sync(layers, []) // Provider re-sync while hidden
331
+
332
+ store.getState().setVisibility('a', true)
333
+ expect(store.getState().layers.a!.collapsed).toBe(true) // snapshot survived
334
+ })
335
+
336
+ it('_sync replaces only the entities that changed', () => {
337
+ const store = createLegendStore('s', { layers })
338
+ const before = store.getState()
339
+
340
+ // Unchanged layers reuse the fixture inputs (stable `variables` array
341
+ // references — the documented contract for identity-preserving syncs).
342
+ store
343
+ .getState()
344
+ ._sync(
345
+ [
346
+ { ...layers[0]!, name: 'A renamed' },
347
+ layers[1]!,
348
+ layers[2]!,
349
+ layers[3]!,
350
+ ],
351
+ [],
352
+ )
353
+
354
+ const after = store.getState()
355
+ expect(after.layers).not.toBe(before.layers)
356
+ expect(after.layers.a).not.toBe(before.layers.a) // renamed → new object
357
+ expect(after.layers.b).toBe(before.layers.b) // untouched → same object
358
+ })
359
+
360
+ // ─── Imperative add/remove/update ──────────────────────────────────────────
361
+
362
+ it('setLegendLayer adds a new layer with seeded defaults + appended order', () => {
363
+ const store = createLegendStore('s', { layers })
364
+ store.getState().setLegendLayer({ id: 'e', name: 'E', variables: [cat] }) // ungrouped
365
+
366
+ const e = store.getState().layers.e!
367
+ expect(e.visible).toBe(true)
368
+ expect(e.opacity).toBe(1)
369
+ expect(e.collapsed).toBe(false)
370
+ // Ungrouped bucket was a,b (orders 0,1) → new layer appends at 2.
371
+ expect(e.order).toBe(2)
372
+ // Untouched layers keep their identity.
373
+ expect(store.getState().layers.a).toBeDefined()
374
+ })
375
+
376
+ it('setLegendLayer appends within the target group bucket', () => {
377
+ const store = createLegendStore('s', { layers })
378
+ store
379
+ .getState()
380
+ .setLegendLayer({ id: 'e', name: 'E', variables: [cat], groupId: 'g1' })
381
+ expect(
382
+ selectLayersByGroup(store.getState(), 'g1').map((l) => l.id),
383
+ ).toEqual(['c', 'd', 'e'])
384
+ })
385
+
386
+ it('setLegendLayer updates an existing layer, preserving interactive state', () => {
387
+ const store = createLegendStore('s', {
388
+ layers: [
389
+ {
390
+ id: 'a',
391
+ name: 'A',
392
+ variables: [cat],
393
+ sections: [
394
+ { id: 's1', title: 'By', options: ['x', 'y'], active: 'x' },
395
+ ],
396
+ },
397
+ ],
398
+ })
399
+ store.getState().setVisibility('a', false)
400
+ store.getState().setSectionValue('a', 's1', 'y')
401
+
402
+ store.getState().setLegendLayer({
403
+ id: 'a',
404
+ name: 'A renamed',
405
+ variables: [cat],
406
+ sections: [{ id: 's1', title: 'By', options: ['x', 'y'], active: 'x' }],
407
+ })
408
+
409
+ const a = store.getState().layers.a!
410
+ expect(a.name).toBe('A renamed') // static field follows input
411
+ expect(a.visible).toBe(false) // interactive state preserved
412
+ expect(a.sections![0]!.active).toBe('y') // user pick preserved (mergeSections)
413
+ })
414
+
415
+ it('setLegendLayer with identical input is a no-op (identity preserved)', () => {
416
+ const store = createLegendStore('s', { layers })
417
+ const before = store.getState()
418
+ store.getState().setLegendLayer(layers[0]!) // same id, same content
419
+ expect(store.getState().layers).toBe(before.layers)
420
+ })
421
+
422
+ it('removeLegendLayer drops the layer and re-densifies sibling order', () => {
423
+ const store = createLegendStore('s', { layers })
424
+ store.getState().removeLegendLayer('a')
425
+ expect(store.getState().layers.a).toBeUndefined()
426
+ // b was order 1 → re-densified to 0.
427
+ expect(store.getState().layers.b!.order).toBe(0)
428
+
429
+ const before = store.getState()
430
+ store.getState().removeLegendLayer('nope') // unknown → no-op
431
+ expect(store.getState().layers).toBe(before.layers)
432
+ })
433
+
434
+ it('setLegendGroup adds/updates a group; removeLegendGroup un-groups members', () => {
435
+ const store = createLegendStore('s', {
436
+ layers,
437
+ groups: [{ id: 'g1', label: 'One' }],
438
+ })
439
+ store.getState().setLegendGroup({ id: 'g2', label: 'Two' })
440
+ expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([
441
+ 'g1',
442
+ 'g2',
443
+ ])
444
+
445
+ store.getState().setLegendGroup({ id: 'g1', label: 'One renamed' })
446
+ expect(store.getState().groups.g1!.label).toBe('One renamed')
447
+
448
+ // Removing g1 keeps its members (c, d) as ungrouped rows.
449
+ store.getState().removeLegendGroup('g1')
450
+ expect(store.getState().groups.g1).toBeUndefined()
451
+ expect(store.getState().layers.c!.groupId).toBeUndefined()
452
+ expect(store.getState().layers.d!.groupId).toBeUndefined()
453
+ expect(
454
+ selectLayersByGroup(store.getState(), undefined).map((l) => l.id),
455
+ ).toContain('c')
456
+ })
457
+ })
@@ -0,0 +1,65 @@
1
+ import type { LegendGroup, LegendLayer, LegendState } from './types'
2
+
3
+ const byOrder = <T extends { order: number }>(a: T, b: T): number =>
4
+ a.order - b.order
5
+
6
+ /**
7
+ * Groups sorted by `order`.
8
+ *
9
+ * @experimental This API is new and may change in a future release.
10
+ */
11
+ export function selectOrderedGroups(state: LegendState): LegendGroup[] {
12
+ return Object.values(state.groups).sort(byOrder)
13
+ }
14
+
15
+ /**
16
+ * Layers belonging to `groupId` (pass `undefined` for ungrouped, top-level
17
+ * layers), sorted by `order`.
18
+ *
19
+ * @experimental This API is new and may change in a future release.
20
+ */
21
+ export function selectLayersByGroup(
22
+ state: LegendState,
23
+ groupId: string | undefined,
24
+ ): LegendLayer[] {
25
+ return Object.values(state.layers)
26
+ .filter((l) => l.groupId === groupId)
27
+ .sort(byOrder)
28
+ }
29
+
30
+ /**
31
+ * All layers sorted by `order`, regardless of group.
32
+ *
33
+ * @experimental This API is new and may change in a future release.
34
+ */
35
+ export function selectOrderedLayers(state: LegendState): LegendLayer[] {
36
+ return Object.values(state.layers).sort(byOrder)
37
+ }
38
+
39
+ /**
40
+ * Tri-state visibility — derived for groups from their member layers, and the
41
+ * state contract of a controlled `Legend.VisibilityToggle` for any custom
42
+ * element kind.
43
+ */
44
+ export type LegendVisibility = 'visible' | 'hidden' | 'mixed'
45
+
46
+ /**
47
+ * Tri-state group visibility: `'visible'` when every member layer is visible
48
+ * (also for empty groups), `'hidden'` when none is, `'mixed'` otherwise.
49
+ *
50
+ * @experimental This API is new and may change in a future release.
51
+ */
52
+ export function selectGroupVisibility(
53
+ state: LegendState,
54
+ groupId: string,
55
+ ): LegendVisibility {
56
+ let anyVisible = false
57
+ let anyHidden = false
58
+ for (const layer of Object.values(state.layers)) {
59
+ if (layer.groupId !== groupId) continue
60
+ if (layer.visible) anyVisible = true
61
+ else anyHidden = true
62
+ }
63
+ if (anyVisible && anyHidden) return 'mixed'
64
+ return anyHidden ? 'hidden' : 'visible'
65
+ }