@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.
- package/dist/components.js +786 -749
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -16
- package/dist/legend-store-registry-Bo8U_qWM.js +468 -0
- package/dist/legend-store-registry-Bo8U_qWM.js.map +1 -0
- package/dist/legend.js +1485 -798
- package/dist/legend.js.map +1 -1
- package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
- package/dist/types/legend/components/a11y.d.ts +6 -0
- package/dist/types/legend/components/contexts.d.ts +22 -4
- package/dist/types/legend/components/index.d.ts +2 -2
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
- package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
- package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
- package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
- package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
- package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
- package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
- package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
- package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
- package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
- package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
- package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
- package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
- package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
- package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
- package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
- package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
- package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
- package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
- package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
- package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
- package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
- package/dist/types/legend/index.d.ts +53 -6
- package/dist/types/legend/provider/labels.d.ts +21 -0
- package/dist/types/legend/stores/index.d.ts +1 -1
- package/dist/types/legend/stores/selectors.d.ts +31 -0
- package/dist/types/legend/stores/types.d.ts +19 -0
- package/package.json +3 -3
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
- package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
- package/src/legend/components/a11y.ts +14 -0
- package/src/legend/components/contexts.ts +29 -4
- package/src/legend/components/index.ts +3 -4
- package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
- package/src/legend/components/legend-group/legend-group.test.tsx +191 -9
- package/src/legend/components/legend-group/legend-group.tsx +88 -52
- package/src/legend/components/legend-group/styles.ts +59 -2
- package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
- package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
- package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
- package/src/legend/components/legend-opacity/styles.ts +14 -9
- package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
- package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
- package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
- package/src/legend/components/legend-row/legend-row.tsx +73 -50
- package/src/legend/components/legend-row/styles.ts +54 -1
- package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
- package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
- package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
- package/src/legend/components/legend-sortable/contexts.ts +12 -0
- package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
- package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
- package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
- package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
- package/src/legend/components/legend-sortable/styles.ts +56 -0
- package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
- package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
- package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
- package/src/legend/components/legend-visible-layers/styles.ts +60 -0
- package/src/legend/components/run-async-menu-action.ts +20 -0
- package/src/legend/index.ts +59 -4
- package/src/legend/provider/labels.ts +43 -1
- package/src/legend/stores/index.ts +4 -0
- package/src/legend/stores/legend-store-registry.ts +82 -2
- package/src/legend/stores/legend-store.test.ts +168 -0
- package/src/legend/stores/selectors.ts +64 -2
- package/src/legend/stores/types.ts +19 -0
- package/src/widgets/actions/download/exports.test.tsx +14 -1
- package/dist/selectors-DwHj6mE2.js +0 -388
- 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
|
+
}
|
package/src/legend/index.ts
CHANGED
|
@@ -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:
|
|
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',
|
|
@@ -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
|
-
/**
|
|
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 (
|
|
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
|
|
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
|