@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
|
@@ -1,36 +1,40 @@
|
|
|
1
1
|
import { useMemo, useState, type ReactNode } from 'react'
|
|
2
|
-
import { Box, Collapse,
|
|
3
|
-
import {
|
|
4
|
-
ExpandMore as ExpandMoreIcon,
|
|
5
|
-
ExpandLess as ExpandLessIcon,
|
|
6
|
-
} from '@mui/icons-material'
|
|
2
|
+
import { Box, Collapse, Typography } from '@mui/material'
|
|
3
|
+
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material'
|
|
7
4
|
import { SmartTooltip } from '../../../components/smart-tooltip/smart-tooltip'
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import {
|
|
6
|
+
selectLayerCollapsible,
|
|
7
|
+
useLegendId,
|
|
8
|
+
useLegendShallow,
|
|
9
|
+
useLegendStore,
|
|
10
|
+
} from '../../stores'
|
|
11
|
+
import { accordionKeyDown } from '../a11y'
|
|
12
|
+
import {
|
|
13
|
+
LegendRowContext,
|
|
14
|
+
type LegendRowContextValue,
|
|
15
|
+
useLegendGroupId,
|
|
16
|
+
} from '../contexts'
|
|
11
17
|
import { partitionLegendChildren } from '../legend-actions/legend-actions'
|
|
12
18
|
import { LEGEND_GROUP_HEADER_HEIGHT } from '../legend-group/styles'
|
|
19
|
+
import { useLegendSortable } from '../legend-sortable/contexts'
|
|
20
|
+
import {
|
|
21
|
+
SortableEntity,
|
|
22
|
+
type SortableEntityRenderProps,
|
|
23
|
+
} from '../legend-sortable/sortable-entity'
|
|
13
24
|
import { styles } from './styles'
|
|
14
25
|
|
|
15
26
|
export interface LegendRowProps {
|
|
16
27
|
layerId: string
|
|
17
28
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* in the collapsible body, followed by the layer's `helperText` footer.
|
|
29
|
+
* `<Legend.Actions>` in the sticky header; body children + `helperText` footer.
|
|
30
|
+
* Place `<Legend.Opacity.Inline />` first in the body.
|
|
21
31
|
*/
|
|
22
32
|
children?: ReactNode
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/**
|
|
26
|
-
* Store-connected layer
|
|
27
|
-
*
|
|
28
|
-
* tooltip, the partitioned `<Legend.Actions>` slot, and the collapse chevron —
|
|
29
|
-
* over the collapsible body. Provides the row composition context so actions
|
|
30
|
-
* (`Legend.VisibilityToggle`, `Legend.Opacity.*`, `Legend.RowMenu`,
|
|
31
|
-
* `Legend.ConfigSelect`, `Legend.Item`) resolve the layer without props.
|
|
32
|
-
* When the layer is hidden the body is force-collapsed, the content dims,
|
|
33
|
-
* and the chevron is disabled.
|
|
36
|
+
* Store-connected layer row. Provides row context for actions; hidden layers
|
|
37
|
+
* force-collapse the body.
|
|
34
38
|
*
|
|
35
39
|
* @experimental This API is new and may change in a future release.
|
|
36
40
|
*/
|
|
@@ -38,67 +42,80 @@ export function LegendRow({ layerId, children }: LegendRowProps) {
|
|
|
38
42
|
const id = useLegendId()
|
|
39
43
|
const layer = useLegendShallow(id, (s) => s.layers[layerId])
|
|
40
44
|
const setCollapsed = useLegendShallow(id, (s) => s.setCollapsed)
|
|
41
|
-
const
|
|
45
|
+
const collapsible = useLegendStore(id, (s) =>
|
|
46
|
+
selectLayerCollapsible(s, layerId),
|
|
47
|
+
)
|
|
48
|
+
const inGroup = useLegendGroupId() !== null
|
|
49
|
+
const sortable = useLegendSortable()
|
|
42
50
|
|
|
43
|
-
const [headerEl, setHeaderEl] = useState<HTMLElement | null>(null)
|
|
44
51
|
const [opacityOpen, setOpacityOpen] = useState(false)
|
|
52
|
+
// Hidden rows derive opacityOpen=false so menu `.active` never pins while hidden.
|
|
53
|
+
const visible = layer?.visible ?? true
|
|
45
54
|
const rowContext = useMemo<LegendRowContextValue>(
|
|
46
|
-
() => ({ layerId,
|
|
47
|
-
[layerId,
|
|
55
|
+
() => ({ layerId, opacityOpen: opacityOpen && visible, setOpacityOpen }),
|
|
56
|
+
[layerId, opacityOpen, visible],
|
|
48
57
|
)
|
|
49
58
|
|
|
50
59
|
if (!layer) return null
|
|
51
60
|
|
|
52
61
|
const { actions, body } = partitionLegendChildren(children)
|
|
53
62
|
const effectivelyCollapsed = !layer.visible || layer.collapsed
|
|
54
|
-
|
|
55
|
-
const
|
|
63
|
+
const stickyTop = inGroup ? LEGEND_GROUP_HEADER_HEIGHT : 0
|
|
64
|
+
const canToggle = layer.visible && collapsible
|
|
65
|
+
const toggle = () => setCollapsed(layerId, !layer.collapsed)
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
const renderRow = (sortableProps?: SortableEntityRenderProps) => (
|
|
58
68
|
<LegendRowContext.Provider value={rowContext}>
|
|
59
|
-
<Box
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
<Box
|
|
70
|
+
ref={sortableProps?.rootRef}
|
|
71
|
+
style={sortableProps?.rootStyle}
|
|
72
|
+
sx={[
|
|
73
|
+
styles.row,
|
|
74
|
+
sortableProps?.dragging === true && styles.rowDragging,
|
|
75
|
+
]}
|
|
76
|
+
>
|
|
77
|
+
<Box sx={styles.header} style={{ top: stickyTop }}>
|
|
78
|
+
{sortableProps?.handle}
|
|
79
|
+
<Box
|
|
80
|
+
sx={[styles.titleBox, canToggle && styles.titleBoxInteractive]}
|
|
81
|
+
{...(canToggle && {
|
|
82
|
+
role: 'button',
|
|
83
|
+
tabIndex: 0,
|
|
84
|
+
'aria-expanded': !layer.collapsed,
|
|
85
|
+
onClick: toggle,
|
|
86
|
+
onKeyDown: accordionKeyDown(toggle),
|
|
87
|
+
})}
|
|
88
|
+
>
|
|
64
89
|
<Box sx={styles.titleLine}>
|
|
65
90
|
<SmartTooltip title={layer.name} dependencies={[layer.name]}>
|
|
66
91
|
{({ ref }) => (
|
|
67
92
|
<Typography
|
|
68
93
|
ref={ref}
|
|
69
94
|
variant='button'
|
|
70
|
-
color='text.primary'
|
|
95
|
+
color={layer.visible ? 'text.primary' : 'text.disabled'}
|
|
71
96
|
sx={styles.title}
|
|
72
97
|
>
|
|
73
98
|
{layer.name}
|
|
74
99
|
</Typography>
|
|
75
100
|
)}
|
|
76
101
|
</SmartTooltip>
|
|
102
|
+
{canToggle && (
|
|
103
|
+
<ExpandMoreIcon
|
|
104
|
+
sx={[
|
|
105
|
+
styles.chevron,
|
|
106
|
+
layer.collapsed && styles.chevronCollapsed,
|
|
107
|
+
]}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
77
110
|
</Box>
|
|
78
|
-
{layer.subtitle && (
|
|
111
|
+
{layer.subtitle && !effectivelyCollapsed && (
|
|
79
112
|
<Typography variant='caption' color='text.secondary'>
|
|
80
113
|
{layer.subtitle}
|
|
81
114
|
</Typography>
|
|
82
115
|
)}
|
|
83
116
|
</Box>
|
|
84
117
|
|
|
85
|
-
<Box sx={styles.actions}>
|
|
86
|
-
{actions}
|
|
87
|
-
<IconButton
|
|
88
|
-
size='small'
|
|
89
|
-
onClick={() => setCollapsed(layerId, !layer.collapsed)}
|
|
90
|
-
disabled={!layer.visible}
|
|
91
|
-
aria-label={
|
|
92
|
-
effectivelyCollapsed ? labels.expandLayer : labels.collapseLayer
|
|
93
|
-
}
|
|
94
|
-
>
|
|
95
|
-
{effectivelyCollapsed ? (
|
|
96
|
-
<ExpandMoreIcon fontSize='small' />
|
|
97
|
-
) : (
|
|
98
|
-
<ExpandLessIcon fontSize='small' />
|
|
99
|
-
)}
|
|
100
|
-
</IconButton>
|
|
101
|
-
</Box>
|
|
118
|
+
<Box sx={styles.actions}>{actions}</Box>
|
|
102
119
|
</Box>
|
|
103
120
|
|
|
104
121
|
<Collapse in={!effectivelyCollapsed}>
|
|
@@ -126,4 +143,10 @@ export function LegendRow({ layerId, children }: LegendRowProps) {
|
|
|
126
143
|
</Box>
|
|
127
144
|
</LegendRowContext.Provider>
|
|
128
145
|
)
|
|
146
|
+
|
|
147
|
+
return sortable ? (
|
|
148
|
+
<SortableEntity kind='layer' id={layerId} render={renderRow} />
|
|
149
|
+
) : (
|
|
150
|
+
renderRow()
|
|
151
|
+
)
|
|
129
152
|
}
|
|
@@ -6,6 +6,8 @@ export const styles = {
|
|
|
6
6
|
row: {
|
|
7
7
|
display: 'flex',
|
|
8
8
|
flexDirection: 'column',
|
|
9
|
+
position: 'relative',
|
|
10
|
+
|
|
9
11
|
'& + &': {
|
|
10
12
|
borderTop: '1px solid',
|
|
11
13
|
borderColor: 'divider',
|
|
@@ -38,6 +40,28 @@ export const styles = {
|
|
|
38
40
|
'&:hover .PsLegend-value, &:focus-within .PsLegend-value': {
|
|
39
41
|
opacity: 1,
|
|
40
42
|
},
|
|
43
|
+
// Drag handle (Legend.Sortable): absolutely positioned (no reserved flex
|
|
44
|
+
// slot, so it never shifts the title/icon); the button inside fades in
|
|
45
|
+
// on hover/focus and is always visible on touch devices.
|
|
46
|
+
'& .PsLegend-dragHandle-layer > *': {
|
|
47
|
+
opacity: 0,
|
|
48
|
+
transition: ({ transitions }: Theme) =>
|
|
49
|
+
transitions.create('opacity', {
|
|
50
|
+
duration: transitions.duration.standard,
|
|
51
|
+
easing: transitions.easing.easeInOut,
|
|
52
|
+
}),
|
|
53
|
+
'@media (hover: none)': {
|
|
54
|
+
opacity: 1,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
'&:hover .PsLegend-dragHandle-layer > *, &:focus-within .PsLegend-dragHandle-layer > *':
|
|
58
|
+
{
|
|
59
|
+
opacity: 1,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
// The original stays in place, dimmed, while its overlay clone is dragged.
|
|
63
|
+
rowDragging: {
|
|
64
|
+
opacity: 0.4,
|
|
41
65
|
},
|
|
42
66
|
// Figma `_Legend header`: 16px top/left inset (title glyphs start at
|
|
43
67
|
// y≈17, x=16 in the 68px-tall header), actions share the title's vertical
|
|
@@ -63,6 +87,17 @@ export const styles = {
|
|
|
63
87
|
display: 'flex',
|
|
64
88
|
flexDirection: 'column',
|
|
65
89
|
},
|
|
90
|
+
// Accordion click target (whole title block + spare space). Non-native
|
|
91
|
+
// button, so it carries its own focus ring.
|
|
92
|
+
titleBoxInteractive: {
|
|
93
|
+
cursor: 'pointer',
|
|
94
|
+
userSelect: 'none',
|
|
95
|
+
'&:focus-visible': {
|
|
96
|
+
outline: '2px solid',
|
|
97
|
+
outlineColor: 'primary.main',
|
|
98
|
+
outlineOffset: -2,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
66
101
|
// The title line and the actions box share the same 24px band, centered,
|
|
67
102
|
// so the title and the action icons stay aligned under the header's
|
|
68
103
|
// `flex-start` — no padding magic. The subtitle flows below.
|
|
@@ -70,14 +105,32 @@ export const styles = {
|
|
|
70
105
|
minHeight: 24,
|
|
71
106
|
display: 'flex',
|
|
72
107
|
alignItems: 'center',
|
|
108
|
+
gap: 0.5,
|
|
73
109
|
},
|
|
74
110
|
title: {
|
|
75
|
-
flex: 1,
|
|
76
111
|
minWidth: 0,
|
|
77
112
|
overflow: 'hidden',
|
|
78
113
|
textOverflow: 'ellipsis',
|
|
79
114
|
whiteSpace: 'nowrap',
|
|
80
115
|
},
|
|
116
|
+
// Informative collapse indicator beside the title (the title area is the
|
|
117
|
+
// button). Points down when expanded, right when collapsed; not shown on
|
|
118
|
+
// touch devices — there the title row itself is the affordance.
|
|
119
|
+
chevron: {
|
|
120
|
+
fontSize: 16,
|
|
121
|
+
color: 'text.secondary',
|
|
122
|
+
flexShrink: 0,
|
|
123
|
+
transition: ({ transitions }: Theme) =>
|
|
124
|
+
transitions.create('transform', {
|
|
125
|
+
duration: transitions.duration.shorter,
|
|
126
|
+
}),
|
|
127
|
+
'@media (hover: none)': {
|
|
128
|
+
display: 'none',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
chevronCollapsed: {
|
|
132
|
+
transform: 'rotate(-90deg)',
|
|
133
|
+
},
|
|
81
134
|
// Same 24px band as the title line, so both sides of the header stay
|
|
82
135
|
// aligned even when the composed actions are shorter (or fade out).
|
|
83
136
|
// Icon buttons (actions + collapse chevron) are squeezed to 24×24:
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { MenuItem } from '@mui/material'
|
|
2
|
+
import { getLegendStore, selectLayersByGroup, useLegendId } from '../../stores'
|
|
3
|
+
import { useLegendConfig } from '../../provider'
|
|
4
|
+
import { useLegendGroupId, useLegendRow } from '../contexts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Show only this layer (and hide siblings in the same bucket).
|
|
8
|
+
*
|
|
9
|
+
* @experimental This API is new and may change in a future release.
|
|
10
|
+
*/
|
|
11
|
+
export function LegendShowOnlyLayer() {
|
|
12
|
+
const id = useLegendId()
|
|
13
|
+
const row = useLegendRow()
|
|
14
|
+
const { labels } = useLegendConfig()
|
|
15
|
+
|
|
16
|
+
if (!row) return null
|
|
17
|
+
|
|
18
|
+
const showOnly = () => {
|
|
19
|
+
const state = getLegendStore(id).getState()
|
|
20
|
+
const target = state.layers[row.layerId]
|
|
21
|
+
// No-op if the layer vanished (e.g. a provider resync while the menu was
|
|
22
|
+
// open) — otherwise `groupId: undefined` would act on the ungrouped bucket.
|
|
23
|
+
if (!target) return
|
|
24
|
+
for (const layer of selectLayersByGroup(state, target.groupId)) {
|
|
25
|
+
state.setVisibility(layer.id, layer.id === row.layerId)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return <MenuItem onClick={showOnly}>{labels.showOnlyLayer}</MenuItem>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Show all layers in the enclosing group, or all ungrouped layers at row scope.
|
|
34
|
+
*
|
|
35
|
+
* @experimental This API is new and may change in a future release.
|
|
36
|
+
*/
|
|
37
|
+
export function LegendShowAllLayers() {
|
|
38
|
+
const id = useLegendId()
|
|
39
|
+
const groupId = useLegendGroupId()
|
|
40
|
+
const row = useLegendRow()
|
|
41
|
+
const { labels } = useLegendConfig()
|
|
42
|
+
|
|
43
|
+
if (groupId === null && !row) return null
|
|
44
|
+
|
|
45
|
+
const showAll = () => {
|
|
46
|
+
const state = getLegendStore(id).getState()
|
|
47
|
+
if (groupId !== null) {
|
|
48
|
+
state.setGroupVisibility(groupId, true)
|
|
49
|
+
} else {
|
|
50
|
+
for (const layer of selectLayersByGroup(state, undefined)) {
|
|
51
|
+
state.setVisibility(layer.id, true)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return <MenuItem onClick={showAll}>{labels.showAllLayers}</MenuItem>
|
|
57
|
+
}
|
|
@@ -1,68 +1,30 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { IconButton, Menu } from '@mui/material'
|
|
3
|
-
import { MoreVert as MoreVertIcon } from '@mui/icons-material'
|
|
4
|
-
import { useLegendId, useLegendStore } from '../../stores'
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
5
2
|
import { useLegendConfig } from '../../provider'
|
|
6
3
|
import { useLegendRow } from '../contexts'
|
|
4
|
+
import { LegendOverflowMenu } from '../legend-overflow-menu/legend-overflow-menu'
|
|
7
5
|
|
|
8
6
|
export interface LegendRowMenuProps {
|
|
9
|
-
/**
|
|
10
|
-
* The menu items — composed by the consumer (e.g. `<Legend.ZoomTo>` or any
|
|
11
|
-
* MUI `MenuItem`). A click on any item bubbles to the menu and closes it;
|
|
12
|
-
* an item that must keep the menu open should `event.stopPropagation()`.
|
|
13
|
-
*/
|
|
7
|
+
/** Menu items; async items use `stopPropagation` + `runAsyncMenuAction`. */
|
|
14
8
|
children?: ReactNode
|
|
15
9
|
}
|
|
16
10
|
|
|
17
11
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* a row, or while the layer is hidden (a hidden row keeps only its
|
|
21
|
-
* visibility eye + collapse chevron).
|
|
12
|
+
* Overflow (⋮) menu for a layer row. Pins `.active` while open, busy, or the
|
|
13
|
+
* inline opacity bar is open. Items self-gate when hidden.
|
|
22
14
|
*
|
|
23
15
|
* @experimental This API is new and may change in a future release.
|
|
24
16
|
*/
|
|
25
17
|
export function LegendRowMenu({ children }: LegendRowMenuProps) {
|
|
26
|
-
const id = useLegendId()
|
|
27
|
-
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
|
28
|
-
const open = Boolean(anchorEl)
|
|
29
18
|
const row = useLegendRow()
|
|
30
|
-
const visible = useLegendStore(id, (s) =>
|
|
31
|
-
row ? (s.layers[row.layerId]?.visible ?? true) : true,
|
|
32
|
-
)
|
|
33
19
|
const { labels } = useLegendConfig()
|
|
34
20
|
|
|
35
|
-
if (!row || !children || !visible) return null
|
|
36
|
-
|
|
37
|
-
const handleOpen = (e: MouseEvent<HTMLElement>) => {
|
|
38
|
-
e.stopPropagation()
|
|
39
|
-
setAnchorEl(e.currentTarget)
|
|
40
|
-
}
|
|
41
|
-
const handleClose = () => setAnchorEl(null)
|
|
42
|
-
|
|
43
21
|
return (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
aria-haspopup='menu'
|
|
52
|
-
aria-expanded={open}
|
|
53
|
-
>
|
|
54
|
-
<MoreVertIcon fontSize='small' />
|
|
55
|
-
</IconButton>
|
|
56
|
-
{/* Menu-level onClick: any item click (bubbling) closes the menu, so
|
|
57
|
-
consumer items need no legend wiring. */}
|
|
58
|
-
<Menu
|
|
59
|
-
anchorEl={anchorEl}
|
|
60
|
-
open={open}
|
|
61
|
-
onClose={handleClose}
|
|
62
|
-
onClick={handleClose}
|
|
63
|
-
>
|
|
64
|
-
{children}
|
|
65
|
-
</Menu>
|
|
66
|
-
</>
|
|
22
|
+
<LegendOverflowMenu
|
|
23
|
+
gate={Boolean(row)}
|
|
24
|
+
ariaLabel={labels.layerOptions}
|
|
25
|
+
active={row?.opacityOpen ?? false}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</LegendOverflowMenu>
|
|
67
29
|
)
|
|
68
30
|
}
|
|
@@ -1,35 +1,43 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { MouseEvent } from 'react'
|
|
2
|
+
import { CircularProgress, MenuItem } from '@mui/material'
|
|
3
|
+
import { useLegendId, useLegendStore } from '../../stores'
|
|
3
4
|
import { useLegendConfig } from '../../provider'
|
|
4
|
-
import { useLegendRow } from '../contexts'
|
|
5
|
+
import { useLegendMenu, useLegendRow } from '../contexts'
|
|
6
|
+
import { runAsyncMenuAction } from '../run-async-menu-action'
|
|
5
7
|
|
|
6
8
|
export interface LegendZoomToProps {
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
* site (not the Provider) — each menu can wire its own behavior.
|
|
10
|
-
*/
|
|
11
|
-
onZoomTo: (layerId: string) => void
|
|
9
|
+
/** Fit viewport to the layer; may return a Promise for async menu feedback. */
|
|
10
|
+
onZoomTo: (layerId: string) => void | Promise<void>
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
|
-
*
|
|
16
|
-
* enclosing `Legend.Row` context and calls the `onZoomTo` it was composed
|
|
17
|
-
* with. The click bubbles to the menu, which closes itself.
|
|
14
|
+
* Zoom-to-layer menu item for `Legend.RowMenu`. Self-hides when the layer is hidden.
|
|
18
15
|
*
|
|
19
16
|
* @experimental This API is new and may change in a future release.
|
|
20
17
|
*/
|
|
21
18
|
export function LegendZoomTo({ onZoomTo }: LegendZoomToProps) {
|
|
19
|
+
const id = useLegendId()
|
|
22
20
|
const row = useLegendRow()
|
|
21
|
+
const menu = useLegendMenu()
|
|
22
|
+
const visible = useLegendStore(id, (s) =>
|
|
23
|
+
row ? (s.layers[row.layerId]?.visible ?? false) : false,
|
|
24
|
+
)
|
|
23
25
|
const { labels } = useLegendConfig()
|
|
24
26
|
|
|
25
|
-
if (!row) return null
|
|
27
|
+
if (!row || !visible) return null
|
|
28
|
+
|
|
29
|
+
const busy = menu?.busy ?? false
|
|
30
|
+
|
|
31
|
+
const handleClick = (e: MouseEvent) => {
|
|
32
|
+
e.stopPropagation()
|
|
33
|
+
if (!menu) return
|
|
34
|
+
void runAsyncMenuAction(menu, () => onZoomTo(row.layerId))
|
|
35
|
+
}
|
|
26
36
|
|
|
27
37
|
return (
|
|
28
|
-
<MenuItem onClick={
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
</ListItemIcon>
|
|
32
|
-
{labels.zoomTo}
|
|
38
|
+
<MenuItem disabled={busy} onClick={handleClick}>
|
|
39
|
+
{busy && <CircularProgress size={16} sx={{ mr: 1 }} />}
|
|
40
|
+
{busy ? labels.loading : labels.zoomTo}
|
|
33
41
|
</MenuItem>
|
|
34
42
|
)
|
|
35
43
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Present (true) below a `<Legend.Sortable>` wrapper — `Legend.Group` /
|
|
5
|
+
* `Legend.Row` read it to become draggable (sortable shell + drag handle).
|
|
6
|
+
*/
|
|
7
|
+
export const LegendSortableContext = createContext(false)
|
|
8
|
+
|
|
9
|
+
/** Whether the nearest legend composition is wrapped in `Legend.Sortable`. */
|
|
10
|
+
export function useLegendSortable(): boolean {
|
|
11
|
+
return useContext(LegendSortableContext)
|
|
12
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drives `Legend.Sortable`'s onDragEnd through the shared dnd-kit mock
|
|
3
|
+
* harness (DndContext passthrough exposing its props) against the **real**
|
|
4
|
+
* legend store — asserting the committed `moveGroup` / `moveLayer` effects
|
|
5
|
+
* and the cross-kind/cross-bucket guards.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { render, screen } from '@testing-library/react'
|
|
9
|
+
import {
|
|
10
|
+
dndKitCoreMock,
|
|
11
|
+
dndKitSortableMock,
|
|
12
|
+
lastDndContextProps,
|
|
13
|
+
resetDndKitMocks,
|
|
14
|
+
} from '../../../../tests/mocks/dnd-kit'
|
|
15
|
+
|
|
16
|
+
vi.mock('@dnd-kit/core', () => dndKitCoreMock())
|
|
17
|
+
vi.mock('@dnd-kit/sortable', () => dndKitSortableMock())
|
|
18
|
+
|
|
19
|
+
import { LegendProvider } from '../../provider'
|
|
20
|
+
import { LegendGroup } from '../legend-group/legend-group'
|
|
21
|
+
import { LegendRow } from '../legend-row/legend-row'
|
|
22
|
+
import { LegendItem } from '../legend-item/legend-item'
|
|
23
|
+
import { LegendSortable } from './legend-sortable'
|
|
24
|
+
import {
|
|
25
|
+
clearAllLegendStores,
|
|
26
|
+
getLegendStore,
|
|
27
|
+
selectLayersByGroup,
|
|
28
|
+
selectOrderedGroups,
|
|
29
|
+
type LegendVariable,
|
|
30
|
+
} from '../../stores'
|
|
31
|
+
|
|
32
|
+
const cat: LegendVariable = {
|
|
33
|
+
type: 'category',
|
|
34
|
+
items: [{ label: 'A', color: '#123456' }],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const layers = [
|
|
38
|
+
{ id: 'a', name: 'Alpha', groupId: 'g1', variables: [cat] },
|
|
39
|
+
{ id: 'b', name: 'Beta', groupId: 'g1', variables: [cat] },
|
|
40
|
+
{ id: 'c', name: 'Gamma', groupId: 'g2', variables: [cat] },
|
|
41
|
+
{ id: 'x', name: 'Loose X', variables: [cat] },
|
|
42
|
+
{ id: 'y', name: 'Loose Y', variables: [cat] },
|
|
43
|
+
]
|
|
44
|
+
const groups = [
|
|
45
|
+
{ id: 'g1', label: 'One' },
|
|
46
|
+
{ id: 'g2', label: 'Two' },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
beforeEach(() => resetDndKitMocks())
|
|
50
|
+
afterEach(() => clearAllLegendStores())
|
|
51
|
+
|
|
52
|
+
function renderSortable(id: string, sortable = true) {
|
|
53
|
+
const tree = (
|
|
54
|
+
<>
|
|
55
|
+
{groups.map((g) => (
|
|
56
|
+
<LegendGroup key={g.id} groupId={g.id}>
|
|
57
|
+
{layers
|
|
58
|
+
.filter((l) => l.groupId === g.id)
|
|
59
|
+
.map((l) => (
|
|
60
|
+
<LegendRow key={l.id} layerId={l.id}>
|
|
61
|
+
<LegendItem />
|
|
62
|
+
</LegendRow>
|
|
63
|
+
))}
|
|
64
|
+
</LegendGroup>
|
|
65
|
+
))}
|
|
66
|
+
{layers
|
|
67
|
+
.filter((l) => !l.groupId)
|
|
68
|
+
.map((l) => (
|
|
69
|
+
<LegendRow key={l.id} layerId={l.id}>
|
|
70
|
+
<LegendItem />
|
|
71
|
+
</LegendRow>
|
|
72
|
+
))}
|
|
73
|
+
</>
|
|
74
|
+
)
|
|
75
|
+
return render(
|
|
76
|
+
<LegendProvider id={id} layers={layers} groups={groups}>
|
|
77
|
+
{sortable ? <LegendSortable>{tree}</LegendSortable> : tree}
|
|
78
|
+
</LegendProvider>,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const dragEnd = (activeId: string, overId: string | null) =>
|
|
83
|
+
lastDndContextProps().onDragEnd?.({
|
|
84
|
+
active: { id: activeId },
|
|
85
|
+
over: overId ? { id: overId } : null,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('<LegendSortable> opt-in', () => {
|
|
89
|
+
it('without the wrapper there are no drag handles', () => {
|
|
90
|
+
renderSortable('plain', false)
|
|
91
|
+
expect(screen.queryAllByLabelText('Reorder')).toHaveLength(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('with the wrapper every group and row exposes its handle', () => {
|
|
95
|
+
renderSortable('handles')
|
|
96
|
+
// 2 groups + 5 rows.
|
|
97
|
+
expect(screen.getAllByLabelText('Reorder')).toHaveLength(7)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('<LegendSortable> onDragEnd commits', () => {
|
|
102
|
+
it('group over group → moveGroup', () => {
|
|
103
|
+
renderSortable('dnd-groups')
|
|
104
|
+
dragEnd('group:g2', 'group:g1')
|
|
105
|
+
expect(
|
|
106
|
+
selectOrderedGroups(getLegendStore('dnd-groups').getState()).map(
|
|
107
|
+
(g) => g.id,
|
|
108
|
+
),
|
|
109
|
+
).toEqual(['g2', 'g1'])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('layer over layer in the same bucket → moveLayer', () => {
|
|
113
|
+
renderSortable('dnd-rows')
|
|
114
|
+
dragEnd('layer:b', 'layer:a')
|
|
115
|
+
expect(
|
|
116
|
+
selectLayersByGroup(getLegendStore('dnd-rows').getState(), 'g1').map(
|
|
117
|
+
(l) => l.id,
|
|
118
|
+
),
|
|
119
|
+
).toEqual(['b', 'a'])
|
|
120
|
+
|
|
121
|
+
// Ungrouped bucket too.
|
|
122
|
+
dragEnd('layer:y', 'layer:x')
|
|
123
|
+
expect(
|
|
124
|
+
selectLayersByGroup(getLegendStore('dnd-rows').getState(), undefined).map(
|
|
125
|
+
(l) => l.id,
|
|
126
|
+
),
|
|
127
|
+
).toEqual(['y', 'x'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('cross-kind and cross-bucket drops are ignored (snap back)', () => {
|
|
131
|
+
renderSortable('dnd-guards')
|
|
132
|
+
const before = getLegendStore('dnd-guards').getState().layers
|
|
133
|
+
const beforeGroups = getLegendStore('dnd-guards').getState().groups
|
|
134
|
+
|
|
135
|
+
dragEnd('group:g1', 'layer:x') // group over a row
|
|
136
|
+
dragEnd('layer:a', 'group:g2') // row over a group
|
|
137
|
+
dragEnd('layer:a', 'layer:c') // row over another group's row
|
|
138
|
+
dragEnd('layer:a', 'layer:x') // grouped row over an ungrouped one
|
|
139
|
+
dragEnd('layer:a', null) // dropped outside
|
|
140
|
+
|
|
141
|
+
expect(getLegendStore('dnd-guards').getState().layers).toBe(before)
|
|
142
|
+
expect(getLegendStore('dnd-guards').getState().groups).toBe(beforeGroups)
|
|
143
|
+
})
|
|
144
|
+
})
|