@carto/ps-react-ui 4.15.0 → 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.
Files changed (82) hide show
  1. package/dist/components.js +786 -749
  2. package/dist/components.js.map +1 -1
  3. package/dist/legend/stores.js +20 -16
  4. package/dist/legend-store-registry-CVYzhR1-.js +464 -0
  5. package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
  6. package/dist/legend.js +1478 -794
  7. package/dist/legend.js.map +1 -1
  8. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
  9. package/dist/types/legend/components/a11y.d.ts +6 -0
  10. package/dist/types/legend/components/contexts.d.ts +22 -4
  11. package/dist/types/legend/components/index.d.ts +2 -2
  12. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
  13. package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
  14. package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
  15. package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
  16. package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
  17. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
  18. package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
  19. package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
  20. package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
  21. package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
  22. package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
  23. package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
  24. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
  25. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
  26. package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
  27. package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
  28. package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
  29. package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
  30. package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
  31. package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
  32. package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
  33. package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
  34. package/dist/types/legend/index.d.ts +53 -6
  35. package/dist/types/legend/provider/labels.d.ts +21 -0
  36. package/dist/types/legend/stores/index.d.ts +1 -1
  37. package/dist/types/legend/stores/selectors.d.ts +31 -0
  38. package/dist/types/legend/stores/types.d.ts +11 -0
  39. package/package.json +3 -3
  40. package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
  41. package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
  42. package/src/legend/components/a11y.ts +14 -0
  43. package/src/legend/components/contexts.ts +29 -4
  44. package/src/legend/components/index.ts +3 -4
  45. package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
  46. package/src/legend/components/legend-group/legend-group.test.tsx +166 -9
  47. package/src/legend/components/legend-group/legend-group.tsx +88 -49
  48. package/src/legend/components/legend-group/styles.ts +59 -2
  49. package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
  50. package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
  51. package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
  52. package/src/legend/components/legend-opacity/styles.ts +14 -9
  53. package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
  54. package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
  55. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
  56. package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
  57. package/src/legend/components/legend-row/legend-row.tsx +73 -50
  58. package/src/legend/components/legend-row/styles.ts +54 -1
  59. package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
  60. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
  61. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
  62. package/src/legend/components/legend-sortable/contexts.ts +12 -0
  63. package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
  64. package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
  65. package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
  66. package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
  67. package/src/legend/components/legend-sortable/styles.ts +56 -0
  68. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
  69. package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
  70. package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
  71. package/src/legend/components/legend-visible-layers/styles.ts +60 -0
  72. package/src/legend/components/run-async-menu-action.ts +20 -0
  73. package/src/legend/index.ts +59 -4
  74. package/src/legend/provider/labels.ts +43 -1
  75. package/src/legend/stores/index.ts +4 -0
  76. package/src/legend/stores/legend-store-registry.ts +60 -0
  77. package/src/legend/stores/legend-store.test.ts +116 -0
  78. package/src/legend/stores/selectors.ts +64 -2
  79. package/src/legend/stores/types.ts +11 -0
  80. package/src/widgets/actions/download/exports.test.tsx +14 -1
  81. package/dist/selectors-DwHj6mE2.js +0 -388
  82. 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, IconButton, Typography } from '@mui/material'
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 { useLegendId, useLegendShallow } from '../../stores'
9
- import { useLegendConfig } from '../../provider'
10
- import { LegendRowContext, type LegendRowContextValue } from '../contexts'
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
- * Composition slots: `<Legend.Actions>` children render in the sticky
19
- * header next to the collapse chevron (hover-fade); everything else renders
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-row wrapper (widgets-v2 `Wrapper` parity). Renders
27
- * the sticky header — title + subtitle from the store with a smart-truncating
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 { labels } = useLegendConfig()
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, headerEl, opacityOpen, setOpacityOpen }),
47
- [layerId, headerEl, opacityOpen],
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
- // Grouped rows pin below their group's sticky header.
55
- const stickyTop = layer.groupId ? LEGEND_GROUP_HEADER_HEIGHT : 0
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
- return (
67
+ const renderRow = (sortableProps?: SortableEntityRenderProps) => (
58
68
  <LegendRowContext.Provider value={rowContext}>
59
- <Box sx={styles.row}>
60
- {/* Inline style: per-instance sticky offset (0 or the group-header
61
- height), no emotion class churn. */}
62
- <Box ref={setHeaderEl} sx={styles.header} style={{ top: stickyTop }}>
63
- <Box sx={styles.titleBox}>
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 { useState, type MouseEvent, type ReactNode } from 'react'
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
- * Agnostic overflow (⋮) menu shell for a layer row the items are passed as
19
- * children, like `Legend.Actions`. Renders nothing without children, outside
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
- <IconButton
46
- size='small'
47
- className={open ? 'active' : undefined}
48
- disabled={!visible}
49
- onClick={handleOpen}
50
- aria-label={labels.layerOptions}
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 { ListItemIcon, MenuItem } from '@mui/material'
2
- import { CenterFocusStrong as ZoomIcon } from '@mui/icons-material'
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
- * Fit the map viewport to the layer. The handler lives at the composition
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
- * "Zoom to" menu item for `Legend.RowMenu` resolves its layer from the
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={() => onZoomTo(row.layerId)}>
29
- <ListItemIcon>
30
- <ZoomIcon fontSize='small' />
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
+ })