@carto/ps-react-ui 4.14.1 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat.js +494 -466
- package/dist/chat.js.map +1 -1
- package/dist/components.js +837 -774
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -16
- package/dist/legend-store-registry-CVYzhR1-.js +464 -0
- package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
- package/dist/legend.js +1478 -794
- package/dist/legend.js.map +1 -1
- package/dist/markdown-DdqANCiN.js +102 -0
- package/dist/markdown-DdqANCiN.js.map +1 -0
- package/dist/markdown-content-Dk2DSgbf.js +10287 -0
- package/dist/markdown-content-Dk2DSgbf.js.map +1 -0
- package/dist/types/chat/bubbles/chat-agent-message-markdown.d.ts +12 -0
- package/dist/types/chat/bubbles/chat-agent-message.d.ts +9 -0
- package/dist/types/chat/bubbles/index.d.ts +1 -0
- package/dist/types/chat/index.d.ts +2 -1
- package/dist/types/chat/types.d.ts +9 -0
- package/dist/types/components/index.d.ts +2 -1
- package/dist/types/components/lasso-tool/message.d.ts +7 -0
- package/dist/types/components/lasso-tool/types.d.ts +17 -2
- 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 +11 -0
- package/dist/types/widgets-v2/markdown/markdown-content.d.ts +7 -1
- package/dist/widgets-v2/markdown.js +9 -8
- package/dist/widgets-v2/markdown.js.map +1 -1
- package/dist/widgets-v2.js +23 -22
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +5 -4
- package/src/chat/bubbles/chat-agent-message-markdown.test.tsx +38 -0
- package/src/chat/bubbles/chat-agent-message-markdown.tsx +34 -0
- package/src/chat/bubbles/chat-agent-message.test.tsx +12 -0
- package/src/chat/bubbles/chat-agent-message.tsx +21 -1
- package/src/chat/bubbles/index.ts +1 -0
- package/src/chat/index.ts +2 -0
- package/src/chat/types.ts +10 -0
- package/src/components/index.ts +2 -0
- package/src/components/lasso-tool/message.tsx +36 -0
- package/src/components/lasso-tool/types.ts +20 -1
- 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 +166 -9
- package/src/legend/components/legend-group/legend-group.tsx +88 -49
- 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 +60 -0
- package/src/legend/stores/legend-store.test.ts +116 -0
- package/src/legend/stores/selectors.ts +64 -2
- package/src/legend/stores/types.ts +11 -0
- package/src/widgets/actions/download/exports.test.tsx +14 -1
- package/src/widgets-v2/markdown/markdown-content.test.tsx +10 -0
- package/src/widgets-v2/markdown/markdown-content.tsx +9 -0
- package/src/widgets-v2/wrapper/widget-wrapper.tsx +4 -1
- package/dist/markdown-BD1jcknS.js +0 -8326
- package/dist/markdown-BD1jcknS.js.map +0 -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',
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
LegendState,
|
|
13
13
|
LegendStoreApi,
|
|
14
14
|
} from './types'
|
|
15
|
+
import { selectLayersByGroup, selectOrderedGroups } from './selectors'
|
|
15
16
|
|
|
16
17
|
// Per-panel legend store registry. One store per `<Legend.Provider id>`.
|
|
17
18
|
// Mirrors the widgets-v2 registry pattern (keyed Map + refcounted mounts),
|
|
@@ -81,6 +82,31 @@ function collapsePatch(
|
|
|
81
82
|
: { collapsed, collapsedBeforeHide: collapsed }
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Reorder one entity within its **ordered** bucket: splice it out, insert at
|
|
87
|
+
* the clamped index, and return the dense `order` rewrites (only the entries
|
|
88
|
+
* whose order actually changes). `null` when the position doesn't change or
|
|
89
|
+
* the id isn't in the bucket. Powers `moveLayer` / `moveGroup`.
|
|
90
|
+
*/
|
|
91
|
+
function movePatch<T extends { id: string; order: number }>(
|
|
92
|
+
bucket: T[],
|
|
93
|
+
id: string,
|
|
94
|
+
toIndex: number,
|
|
95
|
+
): Map<string, number> | null {
|
|
96
|
+
const fromIndex = bucket.findIndex((e) => e.id === id)
|
|
97
|
+
if (fromIndex === -1) return null
|
|
98
|
+
const clamped = Math.max(0, Math.min(bucket.length - 1, toIndex))
|
|
99
|
+
if (clamped === fromIndex) return null
|
|
100
|
+
const next = [...bucket]
|
|
101
|
+
const [moved] = next.splice(fromIndex, 1)
|
|
102
|
+
next.splice(clamped, 0, moved!)
|
|
103
|
+
const orders = new Map<string, number>()
|
|
104
|
+
next.forEach((entity, index) => {
|
|
105
|
+
if (entity.order !== index) orders.set(entity.id, index)
|
|
106
|
+
})
|
|
107
|
+
return orders.size > 0 ? orders : null
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
function updateLayer(
|
|
85
111
|
state: LegendState,
|
|
86
112
|
id: string,
|
|
@@ -446,6 +472,24 @@ export function createLegendStore(
|
|
|
446
472
|
'setSectionValue',
|
|
447
473
|
),
|
|
448
474
|
|
|
475
|
+
moveLayer: (lid, toIndex) =>
|
|
476
|
+
set(
|
|
477
|
+
(s) => {
|
|
478
|
+
const layer = s.layers[lid]
|
|
479
|
+
if (!layer) return {}
|
|
480
|
+
const bucket = selectLayersByGroup(s, layer.groupId)
|
|
481
|
+
const orders = movePatch(bucket, lid, toIndex)
|
|
482
|
+
if (!orders) return {}
|
|
483
|
+
const layers = { ...s.layers }
|
|
484
|
+
for (const [id, order] of orders) {
|
|
485
|
+
layers[id] = { ...layers[id]!, order }
|
|
486
|
+
}
|
|
487
|
+
return { layers }
|
|
488
|
+
},
|
|
489
|
+
false,
|
|
490
|
+
'moveLayer',
|
|
491
|
+
),
|
|
492
|
+
|
|
449
493
|
setGroupVisibility: (gid, visible) =>
|
|
450
494
|
set(
|
|
451
495
|
(s) => {
|
|
@@ -528,6 +572,22 @@ export function createLegendStore(
|
|
|
528
572
|
'setGroupCollapsed',
|
|
529
573
|
),
|
|
530
574
|
|
|
575
|
+
moveGroup: (gid, toIndex) =>
|
|
576
|
+
set(
|
|
577
|
+
(s) => {
|
|
578
|
+
if (!s.groups[gid]) return {}
|
|
579
|
+
const orders = movePatch(selectOrderedGroups(s), gid, toIndex)
|
|
580
|
+
if (!orders) return {}
|
|
581
|
+
const groups = { ...s.groups }
|
|
582
|
+
for (const [id, order] of orders) {
|
|
583
|
+
groups[id] = { ...groups[id]!, order }
|
|
584
|
+
}
|
|
585
|
+
return { groups }
|
|
586
|
+
},
|
|
587
|
+
false,
|
|
588
|
+
'moveGroup',
|
|
589
|
+
),
|
|
590
|
+
|
|
531
591
|
setLegendLayer: (input) =>
|
|
532
592
|
set((s) => upsertLayerState(s, input), false, 'setLegendLayer'),
|
|
533
593
|
removeLegendLayer: (lid) =>
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
clearAllLegendStores,
|
|
5
5
|
} from './legend-store-registry'
|
|
6
6
|
import {
|
|
7
|
+
selectAllGroupsCollapsed,
|
|
7
8
|
selectGroupVisibility,
|
|
8
9
|
selectLayersByGroup,
|
|
9
10
|
selectOrderedGroups,
|
|
11
|
+
selectVisibleLayersGrouped,
|
|
10
12
|
} from './selectors'
|
|
11
13
|
import type { LegendLayerInput, LegendVariable } from './types'
|
|
12
14
|
|
|
@@ -455,3 +457,117 @@ describe('legend store', () => {
|
|
|
455
457
|
).toContain('c')
|
|
456
458
|
})
|
|
457
459
|
})
|
|
460
|
+
|
|
461
|
+
describe('moveLayer / moveGroup', () => {
|
|
462
|
+
// a, b ungrouped; c, d, e in g1; f in g2.
|
|
463
|
+
const moveLayers: LegendLayerInput[] = [
|
|
464
|
+
{ id: 'a', name: 'A', variables: [cat] },
|
|
465
|
+
{ id: 'b', name: 'B', variables: [cat] },
|
|
466
|
+
{ id: 'c', name: 'C', variables: [cat], groupId: 'g1' },
|
|
467
|
+
{ id: 'd', name: 'D', variables: [cat], groupId: 'g1' },
|
|
468
|
+
{ id: 'e', name: 'E', variables: [cat], groupId: 'g1' },
|
|
469
|
+
{ id: 'f', name: 'F', variables: [cat], groupId: 'g2' },
|
|
470
|
+
]
|
|
471
|
+
const moveGroups = [
|
|
472
|
+
{ id: 'g1', label: 'G1' },
|
|
473
|
+
{ id: 'g2', label: 'G2' },
|
|
474
|
+
{ id: 'g3', label: 'G3' },
|
|
475
|
+
]
|
|
476
|
+
const orderedIds = (
|
|
477
|
+
s: ReturnType<ReturnType<typeof createLegendStore>['getState']>,
|
|
478
|
+
groupId: string | undefined,
|
|
479
|
+
) => selectLayersByGroup(s, groupId).map((l) => l.id)
|
|
480
|
+
|
|
481
|
+
it('reorders within the bucket with dense orders', () => {
|
|
482
|
+
const store = createLegendStore('s', {
|
|
483
|
+
layers: moveLayers,
|
|
484
|
+
groups: moveGroups,
|
|
485
|
+
})
|
|
486
|
+
store.getState().moveLayer('e', 0)
|
|
487
|
+
expect(orderedIds(store.getState(), 'g1')).toEqual(['e', 'c', 'd'])
|
|
488
|
+
expect(
|
|
489
|
+
selectLayersByGroup(store.getState(), 'g1').map((l) => l.order),
|
|
490
|
+
).toEqual([0, 1, 2])
|
|
491
|
+
// Other buckets untouched.
|
|
492
|
+
expect(orderedIds(store.getState(), undefined)).toEqual(['a', 'b'])
|
|
493
|
+
expect(orderedIds(store.getState(), 'g2')).toEqual(['f'])
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('moves ungrouped layers within their own bucket', () => {
|
|
497
|
+
const store = createLegendStore('s', {
|
|
498
|
+
layers: moveLayers,
|
|
499
|
+
groups: moveGroups,
|
|
500
|
+
})
|
|
501
|
+
store.getState().moveLayer('b', 0)
|
|
502
|
+
expect(orderedIds(store.getState(), undefined)).toEqual(['b', 'a'])
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('clamps the target index and no-ops on unknown ids / same position', () => {
|
|
506
|
+
const store = createLegendStore('s', {
|
|
507
|
+
layers: moveLayers,
|
|
508
|
+
groups: moveGroups,
|
|
509
|
+
})
|
|
510
|
+
store.getState().moveLayer('c', 99) // clamped to the end of g1
|
|
511
|
+
expect(orderedIds(store.getState(), 'g1')).toEqual(['d', 'e', 'c'])
|
|
512
|
+
|
|
513
|
+
const before = store.getState().layers
|
|
514
|
+
store.getState().moveLayer('nope', 0)
|
|
515
|
+
store.getState().moveLayer('c', 2) // already there
|
|
516
|
+
expect(store.getState().layers).toBe(before)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('moveGroup reorders the group list and survives a resync', () => {
|
|
520
|
+
const store = createLegendStore('s', {
|
|
521
|
+
layers: moveLayers,
|
|
522
|
+
groups: moveGroups,
|
|
523
|
+
})
|
|
524
|
+
store.getState().moveGroup('g3', 0)
|
|
525
|
+
expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([
|
|
526
|
+
'g3',
|
|
527
|
+
'g1',
|
|
528
|
+
'g2',
|
|
529
|
+
])
|
|
530
|
+
// A provider resync with the original inputs preserves the user's order.
|
|
531
|
+
store.getState()._sync(moveLayers, moveGroups)
|
|
532
|
+
expect(selectOrderedGroups(store.getState()).map((g) => g.id)).toEqual([
|
|
533
|
+
'g3',
|
|
534
|
+
'g1',
|
|
535
|
+
'g2',
|
|
536
|
+
])
|
|
537
|
+
|
|
538
|
+
const before = store.getState().groups
|
|
539
|
+
store.getState().moveGroup('nope', 0)
|
|
540
|
+
expect(store.getState().groups).toBe(before)
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
describe('summary selectors', () => {
|
|
545
|
+
// a, b ungrouped; c, d in g1 — plus a second group for cross-group checks.
|
|
546
|
+
const groupedLayers: LegendLayerInput[] = [
|
|
547
|
+
...layers,
|
|
548
|
+
{ id: 'e', name: 'E', variables: [cat], groupId: 'g2' },
|
|
549
|
+
]
|
|
550
|
+
const groups = [
|
|
551
|
+
{ id: 'g1', label: 'G1' },
|
|
552
|
+
{ id: 'g2', label: 'G2' },
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
it('selectVisibleLayersGrouped lists only visible layers, ungrouped last', () => {
|
|
556
|
+
const store = createLegendStore('s', { layers: groupedLayers, groups })
|
|
557
|
+
store.getState().setVisibility('d', false)
|
|
558
|
+
store.getState().setVisibility('e', false) // g2 fully hidden → dropped
|
|
559
|
+
const sections = selectVisibleLayersGrouped(store.getState())
|
|
560
|
+
expect(sections.map((x) => x.group?.id)).toEqual(['g1', undefined])
|
|
561
|
+
expect(sections[0]!.layers.map((l) => l.id)).toEqual(['c'])
|
|
562
|
+
expect(sections[1]!.layers.map((l) => l.id)).toEqual(['a', 'b'])
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('selectAllGroupsCollapsed flips only when every group is collapsed', () => {
|
|
566
|
+
const store = createLegendStore('s', { layers: groupedLayers, groups })
|
|
567
|
+
expect(selectAllGroupsCollapsed(store.getState())).toBe(false)
|
|
568
|
+
store.getState().setGroupCollapsed('g1', true)
|
|
569
|
+
expect(selectAllGroupsCollapsed(store.getState())).toBe(false)
|
|
570
|
+
store.getState().setGroupCollapsed('g2', true)
|
|
571
|
+
expect(selectAllGroupsCollapsed(store.getState())).toBe(true)
|
|
572
|
+
})
|
|
573
|
+
})
|
|
@@ -36,6 +36,69 @@ export function selectOrderedLayers(state: LegendState): LegendLayer[] {
|
|
|
36
36
|
return Object.values(state.layers).sort(byOrder)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* `true` when every group is collapsed (`false` with no groups) — drives the
|
|
41
|
+
* "Collapse all groups" / "Expand all groups" menu label flip.
|
|
42
|
+
*
|
|
43
|
+
* @experimental This API is new and may change in a future release.
|
|
44
|
+
*/
|
|
45
|
+
export function selectAllGroupsCollapsed(state: LegendState): boolean {
|
|
46
|
+
const groups = Object.values(state.groups)
|
|
47
|
+
return groups.length > 0 && groups.every((g) => g.collapsed)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the legend has groups — gates panel-level group actions.
|
|
52
|
+
*
|
|
53
|
+
* @experimental This API is new and may change in a future release.
|
|
54
|
+
*/
|
|
55
|
+
export function selectHasGroups(state: LegendState): boolean {
|
|
56
|
+
return Object.keys(state.groups).length > 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Visible layers grouped for the enabled-layers menu.
|
|
61
|
+
*
|
|
62
|
+
* @experimental This API is new and may change in a future release.
|
|
63
|
+
*/
|
|
64
|
+
export function selectVisibleLayersGrouped(
|
|
65
|
+
state: LegendState,
|
|
66
|
+
): { group?: LegendGroup; layers: LegendLayer[] }[] {
|
|
67
|
+
const visible = (groupId: string | undefined) =>
|
|
68
|
+
selectLayersByGroup(state, groupId).filter((l) => l.visible)
|
|
69
|
+
const sections: { group?: LegendGroup; layers: LegendLayer[] }[] = []
|
|
70
|
+
for (const group of selectOrderedGroups(state)) {
|
|
71
|
+
const layers = visible(group.id)
|
|
72
|
+
if (layers.length > 0) sections.push({ group, layers })
|
|
73
|
+
}
|
|
74
|
+
const ungrouped = visible(undefined)
|
|
75
|
+
if (ungrouped.length > 0) sections.push({ layers: ungrouped })
|
|
76
|
+
return sections
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a layer has any collapsible body — something the row's `Collapse`
|
|
81
|
+
* can actually fold. A layer with no variables, sections, or helper note
|
|
82
|
+
* renders an empty body, so there is nothing to collapse. Gates the row's
|
|
83
|
+
* title-area collapse affordance (chevron + click).
|
|
84
|
+
*
|
|
85
|
+
* @experimental This API is new and may change in a future release.
|
|
86
|
+
*/
|
|
87
|
+
export function selectLayerCollapsible(
|
|
88
|
+
state: LegendState,
|
|
89
|
+
layerId: string,
|
|
90
|
+
): boolean {
|
|
91
|
+
const layer = state.layers[layerId]
|
|
92
|
+
if (!layer) return false
|
|
93
|
+
return (
|
|
94
|
+
layer.variables.length > 0 ||
|
|
95
|
+
(layer.sections?.length ?? 0) > 0 ||
|
|
96
|
+
// Truthiness matches the render condition — an empty-string note renders
|
|
97
|
+
// no footer, so it must not enable the collapse affordance.
|
|
98
|
+
Boolean(layer.helperText)
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
39
102
|
/**
|
|
40
103
|
* Tri-state visibility — derived for groups from their member layers, and the
|
|
41
104
|
* state contract of a controlled `Legend.VisibilityToggle` for any custom
|
|
@@ -55,8 +118,7 @@ export function selectGroupVisibility(
|
|
|
55
118
|
): LegendVisibility {
|
|
56
119
|
let anyVisible = false
|
|
57
120
|
let anyHidden = false
|
|
58
|
-
for (const layer of
|
|
59
|
-
if (layer.groupId !== groupId) continue
|
|
121
|
+
for (const layer of selectLayersByGroup(state, groupId)) {
|
|
60
122
|
if (layer.visible) anyVisible = true
|
|
61
123
|
else anyHidden = true
|
|
62
124
|
}
|
|
@@ -292,6 +292,12 @@ export interface LegendState {
|
|
|
292
292
|
* section's `options`.
|
|
293
293
|
*/
|
|
294
294
|
setSectionValue: (id: string, sectionId: string, value: string) => void
|
|
295
|
+
/**
|
|
296
|
+
* Move a layer to `toIndex` **within its sibling bucket** (its group, or
|
|
297
|
+
* the ungrouped bucket). `toIndex` is clamped; orders stay dense. No-op
|
|
298
|
+
* for unknown ids or when the position doesn't change.
|
|
299
|
+
*/
|
|
300
|
+
moveLayer: (id: string, toIndex: number) => void
|
|
295
301
|
|
|
296
302
|
// Group setters
|
|
297
303
|
/**
|
|
@@ -303,6 +309,11 @@ export interface LegendState {
|
|
|
303
309
|
*/
|
|
304
310
|
setGroupVisibility: (id: string, visible: boolean) => void
|
|
305
311
|
setGroupCollapsed: (id: string, collapsed: boolean) => void
|
|
312
|
+
/**
|
|
313
|
+
* Move a group to `toIndex` within the group list. `toIndex` is clamped;
|
|
314
|
+
* orders stay dense. No-op for unknown ids or an unchanged position.
|
|
315
|
+
*/
|
|
316
|
+
moveGroup: (id: string, toIndex: number) => void
|
|
306
317
|
|
|
307
318
|
// Imperative add/remove/update — an alternative to the Provider's bulk
|
|
308
319
|
// `layers`/`groups` props. To use these, keep those props stable (pass the
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
test,
|
|
4
|
+
expect,
|
|
5
|
+
vi,
|
|
6
|
+
beforeEach,
|
|
7
|
+
afterEach,
|
|
8
|
+
type MockInstance,
|
|
9
|
+
} from 'vitest'
|
|
2
10
|
import { downloadToCSV, downloadToPNG } from './exports'
|
|
3
11
|
import { createRef } from 'react'
|
|
4
12
|
import type { RefObject } from 'react'
|
|
@@ -86,8 +94,10 @@ describe('downloadToCSV', () => {
|
|
|
86
94
|
describe('downloadToPNG', () => {
|
|
87
95
|
let mockRef: RefObject<HTMLDivElement>
|
|
88
96
|
let mockElement: HTMLDivElement
|
|
97
|
+
let warnSpy: MockInstance<typeof console.warn>
|
|
89
98
|
|
|
90
99
|
beforeEach(() => {
|
|
100
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
91
101
|
// Create a real DOM element
|
|
92
102
|
mockElement = document.createElement('div')
|
|
93
103
|
mockElement.style.width = '800px'
|
|
@@ -104,6 +114,7 @@ describe('downloadToPNG', () => {
|
|
|
104
114
|
|
|
105
115
|
afterEach(() => {
|
|
106
116
|
document.body.removeChild(mockElement)
|
|
117
|
+
warnSpy.mockRestore()
|
|
107
118
|
vi.clearAllMocks()
|
|
108
119
|
})
|
|
109
120
|
|
|
@@ -121,6 +132,7 @@ describe('downloadToPNG', () => {
|
|
|
121
132
|
const result = await downloadToPNG.modifier(nullRef)
|
|
122
133
|
|
|
123
134
|
expect(result).toBeUndefined()
|
|
135
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
124
136
|
})
|
|
125
137
|
|
|
126
138
|
test('modifier returns undefined when ref is a function', async () => {
|
|
@@ -129,6 +141,7 @@ describe('downloadToPNG', () => {
|
|
|
129
141
|
const result = await downloadToPNG.modifier(functionRef)
|
|
130
142
|
|
|
131
143
|
expect(result).toBeUndefined()
|
|
144
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
132
145
|
})
|
|
133
146
|
|
|
134
147
|
test('removes toolbar and actions containers from clone', async () => {
|
|
@@ -31,6 +31,16 @@ describe('<MarkdownContent>', () => {
|
|
|
31
31
|
expect(img?.getAttribute('alt')).toBe('alt')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
+
it('parses GFM tables only when gfm=true', () => {
|
|
35
|
+
const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
|
36
|
+
const { container: plain } = render(<MarkdownContent content={table} />)
|
|
37
|
+
// Without the flag, GFM syntax stays CommonMark text — no table element.
|
|
38
|
+
expect(plain.querySelector('table')).toBeNull()
|
|
39
|
+
const { container } = render(<MarkdownContent content={table} gfm />)
|
|
40
|
+
expect(container.querySelector('table')).not.toBeNull()
|
|
41
|
+
expect(container.querySelector('td')?.textContent).toBe('1')
|
|
42
|
+
})
|
|
43
|
+
|
|
34
44
|
it('parses raw HTML when allowHtml=true', () => {
|
|
35
45
|
const { container } = render(
|
|
36
46
|
<MarkdownContent content={'<span>raw <em>html</em></span>'} allowHtml />,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
2
2
|
import rehypeRaw from 'rehype-raw'
|
|
3
3
|
import rehypeSanitize from 'rehype-sanitize'
|
|
4
|
+
import remarkGfm from 'remark-gfm'
|
|
4
5
|
|
|
5
6
|
export interface MarkdownContentProps {
|
|
6
7
|
/** Markdown source. */
|
|
@@ -21,6 +22,12 @@ export interface MarkdownContentProps {
|
|
|
21
22
|
* Defaults to `false` so caption slots stay text-only by default.
|
|
22
23
|
*/
|
|
23
24
|
allowImages?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* When `true`, enables GitHub Flavored Markdown (tables, strikethrough,
|
|
27
|
+
* task lists, autolinks) via `remark-gfm`. Defaults to `false` so existing
|
|
28
|
+
* consumers keep CommonMark-only parsing.
|
|
29
|
+
*/
|
|
30
|
+
gfm?: boolean
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
// Allowlist of URL schemes considered safe for `<a href>` and `<img src>`.
|
|
@@ -56,6 +63,7 @@ export function MarkdownContent({
|
|
|
56
63
|
components,
|
|
57
64
|
allowHtml = false,
|
|
58
65
|
allowImages = false,
|
|
66
|
+
gfm = false,
|
|
59
67
|
}: MarkdownContentProps) {
|
|
60
68
|
const disallowed = allowImages ? undefined : ['img']
|
|
61
69
|
return (
|
|
@@ -63,6 +71,7 @@ export function MarkdownContent({
|
|
|
63
71
|
components={components}
|
|
64
72
|
disallowedElements={disallowed}
|
|
65
73
|
skipHtml={!allowHtml}
|
|
74
|
+
remarkPlugins={gfm ? [remarkGfm] : undefined}
|
|
66
75
|
rehypePlugins={allowHtml ? [rehypeRaw, rehypeSanitize] : undefined}
|
|
67
76
|
urlTransform={safeUrlTransform}
|
|
68
77
|
>
|
|
@@ -141,7 +141,10 @@ export function Wrapper({
|
|
|
141
141
|
}
|
|
142
142
|
aria-label={ariaLabel}
|
|
143
143
|
aria-disabled={disabled || undefined}
|
|
144
|
-
sx={{
|
|
144
|
+
sx={{
|
|
145
|
+
...styles.summary,
|
|
146
|
+
...(disabled ? styles.summaryDisabled : null),
|
|
147
|
+
}}
|
|
145
148
|
>
|
|
146
149
|
<SmartTooltip title={title}>
|
|
147
150
|
{({ ref }) => (
|