@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.
- package/dist/components.js +706 -658
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -0
- package/dist/legend/stores.js.map +1 -0
- package/dist/legend.js +1536 -0
- package/dist/legend.js.map +1 -0
- package/dist/selectors-DwHj6mE2.js +388 -0
- package/dist/selectors-DwHj6mE2.js.map +1 -0
- package/dist/types/components/index.d.ts +1 -0
- package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
- package/dist/types/legend/components/contexts.d.ts +20 -0
- package/dist/types/legend/components/icons.d.ts +12 -0
- package/dist/types/legend/components/index.d.ts +26 -0
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
- package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
- package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
- package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
- package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
- package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
- package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
- package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
- package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
- package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
- package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
- package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
- package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
- package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
- package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
- package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
- package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
- package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
- package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
- package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
- package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
- package/dist/types/legend/index.d.ts +88 -0
- package/dist/types/legend/provider/index.d.ts +6 -0
- package/dist/types/legend/provider/labels.d.ts +27 -0
- package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
- package/dist/types/legend/provider/legend-provider.d.ts +38 -0
- package/dist/types/legend/stores/index.d.ts +5 -0
- package/dist/types/legend/stores/legend-context.d.ts +4 -0
- package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
- package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
- package/dist/types/legend/stores/selectors.d.ts +33 -0
- package/dist/types/legend/stores/types.d.ts +275 -0
- package/dist/widgets-v2/bar.js +7 -3
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/histogram.js +9 -4
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/package.json +11 -3
- package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
- package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
- package/src/components/index.ts +2 -0
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
- package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
- package/src/legend/components/contexts.ts +32 -0
- package/src/legend/components/icons.tsx +22 -0
- package/src/legend/components/index.ts +39 -0
- package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
- package/src/legend/components/legend-actions/styles.ts +9 -0
- package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
- package/src/legend/components/legend-category/styles.ts +61 -0
- package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
- package/src/legend/components/legend-config-select/styles.ts +48 -0
- package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
- package/src/legend/components/legend-group/legend-group.tsx +88 -0
- package/src/legend/components/legend-group/styles.ts +82 -0
- package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
- package/src/legend/components/legend-icon/styles.ts +56 -0
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
- package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
- package/src/legend/components/legend-item/legend-item.tsx +34 -0
- package/src/legend/components/legend-item/styles.ts +11 -0
- package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
- package/src/legend/components/legend-opacity/styles.ts +28 -0
- package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
- package/src/legend/components/legend-proportion/styles.ts +43 -0
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
- package/src/legend/components/legend-ramp/styles.ts +62 -0
- package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
- package/src/legend/components/legend-row/legend-row.tsx +129 -0
- package/src/legend/components/legend-row/styles.ts +111 -0
- package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
- package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
- package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
- package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
- package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
- package/src/legend/index.ts +121 -0
- package/src/legend/provider/index.ts +6 -0
- package/src/legend/provider/labels.ts +59 -0
- package/src/legend/provider/legend-config-context.ts +15 -0
- package/src/legend/provider/legend-provider.tsx +102 -0
- package/src/legend/stores/index.ts +46 -0
- package/src/legend/stores/legend-context.ts +17 -0
- package/src/legend/stores/legend-store-registry.ts +639 -0
- package/src/legend/stores/legend-store.test.ts +457 -0
- package/src/legend/stores/selectors.ts +65 -0
- package/src/legend/stores/types.ts +347 -0
- package/src/widgets-v2/bar/options.ts +4 -0
- 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
|
+
}
|