@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.
Files changed (116) hide show
  1. package/dist/chat.js +494 -466
  2. package/dist/chat.js.map +1 -1
  3. package/dist/components.js +837 -774
  4. package/dist/components.js.map +1 -1
  5. package/dist/legend/stores.js +20 -16
  6. package/dist/legend-store-registry-CVYzhR1-.js +464 -0
  7. package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
  8. package/dist/legend.js +1478 -794
  9. package/dist/legend.js.map +1 -1
  10. package/dist/markdown-DdqANCiN.js +102 -0
  11. package/dist/markdown-DdqANCiN.js.map +1 -0
  12. package/dist/markdown-content-Dk2DSgbf.js +10287 -0
  13. package/dist/markdown-content-Dk2DSgbf.js.map +1 -0
  14. package/dist/types/chat/bubbles/chat-agent-message-markdown.d.ts +12 -0
  15. package/dist/types/chat/bubbles/chat-agent-message.d.ts +9 -0
  16. package/dist/types/chat/bubbles/index.d.ts +1 -0
  17. package/dist/types/chat/index.d.ts +2 -1
  18. package/dist/types/chat/types.d.ts +9 -0
  19. package/dist/types/components/index.d.ts +2 -1
  20. package/dist/types/components/lasso-tool/message.d.ts +7 -0
  21. package/dist/types/components/lasso-tool/types.d.ts +17 -2
  22. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
  23. package/dist/types/legend/components/a11y.d.ts +6 -0
  24. package/dist/types/legend/components/contexts.d.ts +22 -4
  25. package/dist/types/legend/components/index.d.ts +2 -2
  26. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
  27. package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
  28. package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
  29. package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
  30. package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
  31. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
  32. package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
  33. package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
  34. package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
  35. package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
  36. package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
  37. package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
  38. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
  39. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
  40. package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
  41. package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
  42. package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
  43. package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
  44. package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
  45. package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
  46. package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
  47. package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
  48. package/dist/types/legend/index.d.ts +53 -6
  49. package/dist/types/legend/provider/labels.d.ts +21 -0
  50. package/dist/types/legend/stores/index.d.ts +1 -1
  51. package/dist/types/legend/stores/selectors.d.ts +31 -0
  52. package/dist/types/legend/stores/types.d.ts +11 -0
  53. package/dist/types/widgets-v2/markdown/markdown-content.d.ts +7 -1
  54. package/dist/widgets-v2/markdown.js +9 -8
  55. package/dist/widgets-v2/markdown.js.map +1 -1
  56. package/dist/widgets-v2.js +23 -22
  57. package/dist/widgets-v2.js.map +1 -1
  58. package/package.json +5 -4
  59. package/src/chat/bubbles/chat-agent-message-markdown.test.tsx +38 -0
  60. package/src/chat/bubbles/chat-agent-message-markdown.tsx +34 -0
  61. package/src/chat/bubbles/chat-agent-message.test.tsx +12 -0
  62. package/src/chat/bubbles/chat-agent-message.tsx +21 -1
  63. package/src/chat/bubbles/index.ts +1 -0
  64. package/src/chat/index.ts +2 -0
  65. package/src/chat/types.ts +10 -0
  66. package/src/components/index.ts +2 -0
  67. package/src/components/lasso-tool/message.tsx +36 -0
  68. package/src/components/lasso-tool/types.ts +20 -1
  69. package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
  70. package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
  71. package/src/legend/components/a11y.ts +14 -0
  72. package/src/legend/components/contexts.ts +29 -4
  73. package/src/legend/components/index.ts +3 -4
  74. package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
  75. package/src/legend/components/legend-group/legend-group.test.tsx +166 -9
  76. package/src/legend/components/legend-group/legend-group.tsx +88 -49
  77. package/src/legend/components/legend-group/styles.ts +59 -2
  78. package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
  79. package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
  80. package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
  81. package/src/legend/components/legend-opacity/styles.ts +14 -9
  82. package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
  83. package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
  84. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
  85. package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
  86. package/src/legend/components/legend-row/legend-row.tsx +73 -50
  87. package/src/legend/components/legend-row/styles.ts +54 -1
  88. package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
  89. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
  90. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
  91. package/src/legend/components/legend-sortable/contexts.ts +12 -0
  92. package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
  93. package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
  94. package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
  95. package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
  96. package/src/legend/components/legend-sortable/styles.ts +56 -0
  97. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
  98. package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
  99. package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
  100. package/src/legend/components/legend-visible-layers/styles.ts +60 -0
  101. package/src/legend/components/run-async-menu-action.ts +20 -0
  102. package/src/legend/index.ts +59 -4
  103. package/src/legend/provider/labels.ts +43 -1
  104. package/src/legend/stores/index.ts +4 -0
  105. package/src/legend/stores/legend-store-registry.ts +60 -0
  106. package/src/legend/stores/legend-store.test.ts +116 -0
  107. package/src/legend/stores/selectors.ts +64 -2
  108. package/src/legend/stores/types.ts +11 -0
  109. package/src/widgets/actions/download/exports.test.tsx +14 -1
  110. package/src/widgets-v2/markdown/markdown-content.test.tsx +10 -0
  111. package/src/widgets-v2/markdown/markdown-content.tsx +9 -0
  112. package/src/widgets-v2/wrapper/widget-wrapper.tsx +4 -1
  113. package/dist/markdown-BD1jcknS.js +0 -8326
  114. package/dist/markdown-BD1jcknS.js.map +0 -1
  115. package/dist/selectors-DwHj6mE2.js +0 -388
  116. package/dist/selectors-DwHj6mE2.js.map +0 -1
@@ -0,0 +1,195 @@
1
+ import { useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ DndContext,
4
+ DragOverlay,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ closestCenter,
8
+ useSensor,
9
+ useSensors,
10
+ type CollisionDetection,
11
+ type DragEndEvent,
12
+ type DragStartEvent,
13
+ type KeyboardCoordinateGetter,
14
+ } from '@dnd-kit/core'
15
+ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
16
+ import { Box, Typography } from '@mui/material'
17
+ import { LayersOutlined as GroupIcon } from '@mui/icons-material'
18
+ import {
19
+ getLegendStore,
20
+ selectLayersByGroup,
21
+ selectOrderedGroups,
22
+ useLegendId,
23
+ useLegendShallow,
24
+ useLegendStore,
25
+ } from '../../stores'
26
+ import { LegendSortableContext } from './contexts'
27
+ import { parseSortableId, toSortableId } from './sortable-ids'
28
+ import { styles } from './styles'
29
+
30
+ export interface LegendSortableProps {
31
+ /** Panel content (`Legend.Group` / ungrouped `Legend.Row` children). */
32
+ children?: ReactNode
33
+ }
34
+
35
+ /**
36
+ * Opt-in DnD reordering for legend groups and layers (within bucket only).
37
+ *
38
+ * @experimental This API is new and may change in a future release.
39
+ */
40
+ export function LegendSortable({ children }: LegendSortableProps) {
41
+ const id = useLegendId()
42
+ const [activeId, setActiveId] = useState<string | null>(null)
43
+ // Cached on drag start — avoid re-reading groupId on every collision candidate.
44
+ const activeLayerGroupIdRef = useRef<string | undefined>(undefined)
45
+
46
+ const topLevelIds = useLegendShallow(id, (s) => [
47
+ ...selectOrderedGroups(s).map((g) => toSortableId('group', g.id)),
48
+ ...selectLayersByGroup(s, undefined).map((l) =>
49
+ toSortableId('layer', l.id),
50
+ ),
51
+ ])
52
+
53
+ // Same-kind + same-bucket legality — shared by collision detection and keyboard moves.
54
+ const isLegalTarget = (activeId: string, candidateId: string): boolean => {
55
+ const from = parseSortableId(activeId)
56
+ const to = parseSortableId(candidateId)
57
+ if (!from || to?.kind !== from.kind) return false
58
+ if (from.kind === 'layer') {
59
+ const state = getLegendStore(id).getState()
60
+ return state.layers[to.id]?.groupId === activeLayerGroupIdRef.current
61
+ }
62
+ return true
63
+ }
64
+
65
+ // Stock keyboard getter steps through nested member rows inside a group's rect.
66
+ const keyboardCoordinates: KeyboardCoordinateGetter = (
67
+ event,
68
+ { context },
69
+ ) => {
70
+ const { active, collisionRect, droppableRects, droppableContainers } =
71
+ context
72
+ if (!active || !collisionRect) return undefined
73
+ if (event.code !== 'ArrowDown' && event.code !== 'ArrowUp') return undefined
74
+ event.preventDefault()
75
+
76
+ const down = event.code === 'ArrowDown'
77
+ const candidates = droppableContainers
78
+ .getEnabled()
79
+ .filter(
80
+ (container) =>
81
+ container.id !== active.id &&
82
+ isLegalTarget(String(active.id), String(container.id)),
83
+ )
84
+ .flatMap((container) => {
85
+ const rect = droppableRects.get(container.id)
86
+ return rect ? [rect] : []
87
+ })
88
+ .filter((rect) =>
89
+ down ? rect.top > collisionRect.top : rect.top < collisionRect.top,
90
+ )
91
+ .sort((a, b) => (down ? a.top - b.top : b.top - a.top))
92
+
93
+ const target = candidates[0]
94
+ return target ? { x: target.left, y: target.top } : undefined
95
+ }
96
+
97
+ const sensors = useSensors(
98
+ useSensor(PointerSensor),
99
+ useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates }),
100
+ )
101
+
102
+ // Nested droppables: filter to legal targets before closestCenter.
103
+ const collisionDetection: CollisionDetection = (args) => {
104
+ const droppableContainers = args.droppableContainers.filter((container) =>
105
+ isLegalTarget(String(args.active.id), String(container.id)),
106
+ )
107
+ return closestCenter({ ...args, droppableContainers })
108
+ }
109
+
110
+ const handleDragStart = (event: DragStartEvent) => {
111
+ const nextId = String(event.active.id)
112
+ setActiveId(nextId)
113
+ const parsed = parseSortableId(nextId)
114
+ activeLayerGroupIdRef.current =
115
+ parsed?.kind === 'layer'
116
+ ? getLegendStore(id).getState().layers[parsed.id]?.groupId
117
+ : undefined
118
+ }
119
+
120
+ const handleDragEnd = (event: DragEndEvent) => {
121
+ setActiveId(null)
122
+ const { active, over } = event
123
+ if (!over || active.id === over.id) return
124
+ const from = parseSortableId(String(active.id))
125
+ const to = parseSortableId(String(over.id))
126
+ if (!from || from.kind !== to?.kind) return
127
+
128
+ const state = getLegendStore(id).getState()
129
+ if (from.kind === 'group') {
130
+ const index = selectOrderedGroups(state).findIndex((g) => g.id === to.id)
131
+ if (index !== -1) state.moveGroup(from.id, index)
132
+ return
133
+ }
134
+ const fromLayer = state.layers[from.id]
135
+ const toLayer = state.layers[to.id]
136
+ if (!fromLayer || !toLayer || fromLayer.groupId !== toLayer.groupId) return
137
+ const bucket = selectLayersByGroup(state, fromLayer.groupId)
138
+ const index = bucket.findIndex((l) => l.id === to.id)
139
+ if (index !== -1) state.moveLayer(from.id, index)
140
+ }
141
+
142
+ return (
143
+ <LegendSortableContext.Provider value={true}>
144
+ <DndContext
145
+ sensors={sensors}
146
+ collisionDetection={collisionDetection}
147
+ onDragStart={handleDragStart}
148
+ onDragEnd={handleDragEnd}
149
+ onDragCancel={() => setActiveId(null)}
150
+ >
151
+ <SortableContext
152
+ items={topLevelIds}
153
+ strategy={verticalListSortingStrategy}
154
+ >
155
+ {children}
156
+ </SortableContext>
157
+ <DragOverlay>
158
+ {activeId && <OverlayCard activeId={activeId} />}
159
+ </DragOverlay>
160
+ </DndContext>
161
+ </LegendSortableContext.Provider>
162
+ )
163
+ }
164
+
165
+ function OverlayCard({ activeId }: { activeId: string }) {
166
+ const id = useLegendId()
167
+ const parsed = parseSortableId(activeId)
168
+ const label = useLegendStore(id, (s) =>
169
+ parsed
170
+ ? parsed.kind === 'group'
171
+ ? s.groups[parsed.id]?.label
172
+ : s.layers[parsed.id]?.name
173
+ : undefined,
174
+ )
175
+
176
+ if (!parsed || label === undefined) return null
177
+
178
+ return (
179
+ <Box sx={styles.overlay}>
180
+ {parsed.kind === 'group' && (
181
+ <Box sx={styles.overlayIcon}>
182
+ <GroupIcon fontSize='small' />
183
+ </Box>
184
+ )}
185
+ <Typography
186
+ variant='button'
187
+ fontWeight={parsed.kind === 'group' ? 'bold' : undefined}
188
+ color='text.primary'
189
+ sx={styles.overlayLabel}
190
+ >
191
+ {label}
192
+ </Typography>
193
+ </Box>
194
+ )
195
+ }
@@ -0,0 +1,91 @@
1
+ import type { CSSProperties, ReactElement, ReactNode } from 'react'
2
+ import { useSortable } from '@dnd-kit/sortable'
3
+ import { CSS } from '@dnd-kit/utilities'
4
+ import { Box, IconButton } from '@mui/material'
5
+ import { DragIndicator as DragIndicatorIcon } from '@mui/icons-material'
6
+ import { Tooltip } from '../../../components/tooltip/tooltip'
7
+ import { useLegendConfig } from '../../provider'
8
+ import { toSortableId, type SortableKind } from './sortable-ids'
9
+ import { styles } from './styles'
10
+
11
+ /** What `SortableEntity` hands to its render callback. */
12
+ export interface SortableEntityRenderProps {
13
+ /** Attach to the entity's existing root element. */
14
+ rootRef: (el: HTMLElement | null) => void
15
+ /** Sort transform/transition for the root (inline, per-frame values). */
16
+ rootStyle: CSSProperties
17
+ /** The original is dimmed in place while its overlay clone is dragged. */
18
+ dragging: boolean
19
+ /** The drag handle — absolutely positioned, place anywhere in the header. */
20
+ handle: ReactNode
21
+ }
22
+
23
+ export interface SortableEntityProps {
24
+ kind: SortableKind
25
+ id: string
26
+ render: (sortable: SortableEntityRenderProps) => ReactElement
27
+ }
28
+
29
+ /**
30
+ * Sortable shell for a legend group/row. Render-prop (instead of a wrapper
31
+ * element) so the entity keeps its own root — sibling CSS like the `& + &`
32
+ * dividers keeps working. Hosts `useSortable` and builds the drag handle
33
+ * (keyboard-activatable, labelled `labels.reorder`).
34
+ *
35
+ * @experimental This API is new and may change in a future release.
36
+ */
37
+ export function SortableEntity({ kind, id, render }: SortableEntityProps) {
38
+ const { labels } = useLegendConfig()
39
+ const {
40
+ attributes,
41
+ listeners,
42
+ setNodeRef,
43
+ setActivatorNodeRef,
44
+ transform,
45
+ transition,
46
+ isDragging,
47
+ } = useSortable({ id: toSortableId(kind, id) })
48
+
49
+ const rootStyle: CSSProperties = {
50
+ // Vertical list: lock x so items only shift along the axis.
51
+ transform: CSS.Transform.toString(
52
+ transform ? { ...transform, x: 0 } : null,
53
+ ),
54
+ transition,
55
+ }
56
+
57
+ const handle = (
58
+ // Absolutely positioned (see styles.handleSlot) — no reserved flex slot,
59
+ // so it never shifts the title/icon; the fade rules live on the
60
+ // group/row root. Kind-scoped class so a group hover doesn't light up
61
+ // its member rows' handles.
62
+ <Box
63
+ component='span'
64
+ className={`PsLegend-dragHandle-${kind}`}
65
+ sx={[
66
+ styles.handleSlot,
67
+ kind === 'group' ? styles.handleSlotGroup : styles.handleSlotLayer,
68
+ ]}
69
+ >
70
+ <Tooltip title={labels.reorder}>
71
+ <IconButton
72
+ ref={setActivatorNodeRef}
73
+ size='xsmall'
74
+ aria-label={labels.reorder}
75
+ {...attributes}
76
+ {...listeners}
77
+ sx={styles.handle}
78
+ >
79
+ <DragIndicatorIcon />
80
+ </IconButton>
81
+ </Tooltip>
82
+ </Box>
83
+ )
84
+
85
+ return render({
86
+ rootRef: setNodeRef,
87
+ rootStyle,
88
+ dragging: isDragging,
89
+ handle,
90
+ })
91
+ }
@@ -0,0 +1,17 @@
1
+ // dnd-kit ids must be unique per DndContext, and legend group/layer id
2
+ // namespaces may collide — so sortable ids carry a kind prefix.
3
+
4
+ export type SortableKind = 'group' | 'layer'
5
+
6
+ export const toSortableId = (kind: SortableKind, id: string): string =>
7
+ `${kind}:${id}`
8
+
9
+ export function parseSortableId(
10
+ sortableId: string,
11
+ ): { kind: SortableKind; id: string } | null {
12
+ const sep = sortableId.indexOf(':')
13
+ if (sep === -1) return null
14
+ const kind = sortableId.slice(0, sep)
15
+ if (kind !== 'group' && kind !== 'layer') return null
16
+ return { kind, id: sortableId.slice(sep + 1) }
17
+ }
@@ -0,0 +1,56 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+
3
+ export const styles = {
4
+ // Absolute handle — no flex slot; sticky header is the containing block.
5
+ handleSlot: {
6
+ position: 'absolute',
7
+ zIndex: 1,
8
+ display: 'flex',
9
+ alignItems: 'center',
10
+ justifyContent: 'center',
11
+ height: 24,
12
+ },
13
+ handleSlotLayer: {
14
+ right: 2,
15
+ },
16
+ handleSlotGroup: {
17
+ right: 2,
18
+ },
19
+ handle: {
20
+ padding: '2px',
21
+ cursor: 'grab',
22
+ touchAction: 'none',
23
+ color: 'text.secondary',
24
+ '& .MuiSvgIcon-root': {
25
+ fontSize: 18,
26
+ },
27
+ '&:active': {
28
+ cursor: 'grabbing',
29
+ },
30
+ '&:hover': {
31
+ backgroundColor: 'transparent',
32
+ },
33
+ },
34
+ overlay: {
35
+ display: 'flex',
36
+ alignItems: 'center',
37
+ gap: 1,
38
+ padding: ({ spacing }) => spacing(1, 1.5),
39
+ backgroundColor: 'background.paper',
40
+ borderRadius: 1,
41
+ boxShadow: 4,
42
+ height: 'auto',
43
+ maxWidth: '100%',
44
+ overflow: 'hidden',
45
+ },
46
+ overlayIcon: {
47
+ display: 'flex',
48
+ color: 'text.secondary',
49
+ },
50
+ overlayLabel: {
51
+ minWidth: 0,
52
+ overflow: 'hidden',
53
+ textOverflow: 'ellipsis',
54
+ whiteSpace: 'nowrap',
55
+ },
56
+ } satisfies Record<string, SxProps<Theme>>
@@ -4,6 +4,7 @@ import {
4
4
  VisibilityOutlined as VisibilityIcon,
5
5
  VisibilityOffOutlined as VisibilityOffIcon,
6
6
  } from '@mui/icons-material'
7
+ import { Tooltip } from '../../../components/tooltip/tooltip'
7
8
  import {
8
9
  selectGroupVisibility,
9
10
  useLegendId,
@@ -113,15 +114,20 @@ export function LegendVisibilityToggle({
113
114
 
114
115
  const { hideLayer, showLayer } = labelPair ?? configLabels
115
116
  const EyeIcon = ICONS[state]
117
+ const label = state === 'hidden' ? showLayer : hideLayer
116
118
 
117
119
  return (
118
- <IconButton
119
- size='small'
120
- className={state === 'visible' ? undefined : 'active'}
121
- onClick={handleToggle}
122
- aria-label={state === 'hidden' ? showLayer : hideLayer}
123
- >
124
- <EyeIcon fontSize='small' />
125
- </IconButton>
120
+ // Tooltip clones the button (no wrapper element), so the hover-fade
121
+ // group's direct-child rules and the `.active` pin keep working.
122
+ <Tooltip title={label}>
123
+ <IconButton
124
+ size='small'
125
+ className={state === 'visible' ? undefined : 'active'}
126
+ onClick={handleToggle}
127
+ aria-label={label}
128
+ >
129
+ <EyeIcon fontSize='small' />
130
+ </IconButton>
131
+ </Tooltip>
126
132
  )
127
133
  }
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, it, expect } from 'vitest'
2
+ import { act, fireEvent, render, screen } from '@testing-library/react'
3
+ import { LegendProvider } from '../../provider'
4
+ import { LegendVisibleLayers } from './legend-visible-layers'
5
+ import {
6
+ clearAllLegendStores,
7
+ getLegendStore,
8
+ type LegendLayerInput,
9
+ type LegendVariable,
10
+ } from '../../stores'
11
+
12
+ const cat: LegendVariable = {
13
+ type: 'category',
14
+ items: [{ label: 'A', color: '#123456' }],
15
+ }
16
+
17
+ const layers: LegendLayerInput[] = [
18
+ { id: 'a', name: 'Suitability', groupId: 'g1', variables: [cat] },
19
+ { id: 'b', name: 'Population', groupId: 'g2', variables: [cat] },
20
+ { id: 'c', name: 'Facilities', variables: [cat] }, // ungrouped → last
21
+ ]
22
+ const groups = [
23
+ { id: 'g1', label: 'Site selection' },
24
+ { id: 'g2', label: 'Demographics' },
25
+ ]
26
+
27
+ afterEach(() => clearAllLegendStores())
28
+
29
+ function renderSummary(id: string, layerInputs = layers) {
30
+ return render(
31
+ <LegendProvider id={id} layers={layerInputs} groups={groups}>
32
+ <LegendVisibleLayers />
33
+ </LegendProvider>,
34
+ )
35
+ }
36
+
37
+ describe('<LegendVisibleLayers>', () => {
38
+ it('lists only visible layers grouped under their captions, ungrouped last', () => {
39
+ renderSummary('vl1', [
40
+ ...layers.slice(0, 2),
41
+ { ...layers[2]!, visible: false } as LegendLayerInput,
42
+ ])
43
+ fireEvent.click(screen.getByLabelText('Enabled layers'))
44
+ // Count excludes the hidden ungrouped layer.
45
+ expect(screen.getByText('Enabled layers (2)')).toBeTruthy()
46
+ expect(screen.getByText('Site selection')).toBeTruthy()
47
+ expect(screen.getByText('Suitability')).toBeTruthy()
48
+ expect(screen.getByText('Population')).toBeTruthy()
49
+ // Hidden layer (and its would-be section) not listed.
50
+ expect(screen.queryByText('Facilities')).toBeNull()
51
+ })
52
+
53
+ it('clicking an entry hides the layer and removes it without closing the menu', () => {
54
+ renderSummary('vl2')
55
+ fireEvent.click(screen.getByLabelText('Enabled layers'))
56
+ expect(screen.getByText('Enabled layers (3)')).toBeTruthy()
57
+
58
+ // The whole item is the hide action (keyboard-reachable menu item).
59
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Suitability' }))
60
+ expect(getLegendStore('vl2').getState().layers.a!.visible).toBe(false)
61
+ // Menu stayed open, entry gone, count updated live.
62
+ expect(screen.getByText('Enabled layers (2)')).toBeTruthy()
63
+ expect(screen.queryByText('Suitability')).toBeNull()
64
+ })
65
+
66
+ it('hiding the last visible layer closes the menu and disables the trigger', () => {
67
+ renderSummary('vl3', [layers[0]!])
68
+ const trigger = screen.getByLabelText<HTMLButtonElement>('Enabled layers')
69
+ fireEvent.click(trigger)
70
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Suitability' }))
71
+ // Nothing left to list → the menu closed itself (role queries skip the
72
+ // aria-hidden exit transition)…
73
+ expect(screen.queryByRole('menu')).toBeNull()
74
+ // …and the trigger is disabled until a layer is shown again.
75
+ expect(trigger.disabled).toBe(true)
76
+ })
77
+
78
+ it('closes when the last visible layer is hidden from elsewhere', () => {
79
+ renderSummary('vl4', [layers[0]!])
80
+ fireEvent.click(screen.getByLabelText('Enabled layers'))
81
+ expect(screen.getByRole('menu')).toBeTruthy()
82
+ // External change (e.g. a row eye) while the menu is open.
83
+ act(() => getLegendStore('vl4').getState().setVisibility('a', false))
84
+ expect(screen.queryByRole('menu')).toBeNull()
85
+ })
86
+ })
@@ -0,0 +1,135 @@
1
+ import { useState, type MouseEvent } from 'react'
2
+ import {
3
+ Box,
4
+ Divider,
5
+ IconButton,
6
+ Menu,
7
+ MenuItem,
8
+ Typography,
9
+ } from '@mui/material'
10
+ import {
11
+ ChecklistOutlined as ChecklistIcon,
12
+ VisibilityOutlined as VisibilityIcon,
13
+ } from '@mui/icons-material'
14
+ import { Tooltip } from '../../../components/tooltip/tooltip'
15
+ import {
16
+ getLegendStore,
17
+ selectVisibleLayersGrouped,
18
+ useLegendId,
19
+ useLegendShallow,
20
+ useLegendStore,
21
+ } from '../../stores'
22
+ import { useLegendConfig } from '../../provider'
23
+ import { styles } from './styles'
24
+
25
+ /** `key` ties section derivation to the visibility fingerprint subscription. */
26
+ function sectionsForKey(legendId: string, key: string) {
27
+ void key
28
+ return selectVisibleLayersGrouped(getLegendStore(legendId).getState())
29
+ }
30
+
31
+ /**
32
+ * Panel checklist of visible layers — each entry hides its layer; the menu
33
+ * closes when nothing remains to list.
34
+ *
35
+ * @experimental This API is new and may change in a future release.
36
+ */
37
+ export function LegendVisibleLayers() {
38
+ const id = useLegendId()
39
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
40
+ // Fingerprint avoids opacity-driven re-renders (React Compiler would hoist stale useMemo).
41
+ const visibleKey = useLegendStore(id, (s) =>
42
+ selectVisibleLayersGrouped(s)
43
+ .map(
44
+ ({ group, layers }) =>
45
+ `${group?.id ?? ''}\x1f${group?.label ?? ''}:${layers
46
+ .map((l) => `${l.id}\x1f${l.name}`)
47
+ .join(',')}`,
48
+ )
49
+ .join('|'),
50
+ )
51
+ const sections = sectionsForKey(id, visibleKey)
52
+ const setVisibility = useLegendShallow(id, (s) => s.setVisibility)
53
+ const { labels } = useLegendConfig()
54
+
55
+ const count = sections.reduce((n, s) => n + s.layers.length, 0)
56
+ // Derived close when count hits 0; clear anchor on exit so it can't reopen stale.
57
+ const open = Boolean(anchorEl) && count > 0
58
+
59
+ const handleOpen = (e: MouseEvent<HTMLElement>) =>
60
+ setAnchorEl(e.currentTarget)
61
+ const handleClose = () => setAnchorEl(null)
62
+
63
+ return (
64
+ <>
65
+ <Tooltip title={labels.toggleEnabledLayers}>
66
+ {/* Span: MUI Tooltip can't attach listeners to a disabled button. */}
67
+ <Box component='span' sx={styles.triggerWrap}>
68
+ <IconButton
69
+ size='small'
70
+ className={open ? 'active' : undefined}
71
+ disabled={count === 0}
72
+ onClick={handleOpen}
73
+ aria-label={labels.enabledLayers}
74
+ aria-haspopup='menu'
75
+ aria-expanded={open}
76
+ >
77
+ <ChecklistIcon fontSize='small' />
78
+ </IconButton>
79
+ </Box>
80
+ </Tooltip>
81
+ <Menu
82
+ anchorEl={anchorEl}
83
+ open={open}
84
+ onClose={handleClose}
85
+ TransitionProps={{ onExited: handleClose }}
86
+ // No list padding — sticky header sits flush (avoids scroll peek-through).
87
+ MenuListProps={{ disablePadding: true }}
88
+ slotProps={{ paper: { sx: styles.paper } }}
89
+ >
90
+ <Typography
91
+ component='div'
92
+ variant='caption'
93
+ color='text.primary'
94
+ sx={styles.stickyHeader}
95
+ >
96
+ {`${labels.enabledLayers} (${count})`}
97
+ </Typography>
98
+ {sections.flatMap((section, index) => [
99
+ index > 0 ? (
100
+ <Divider
101
+ key={`divider-${section.group?.id ?? 'ungrouped'}`}
102
+ sx={styles.divider}
103
+ />
104
+ ) : null,
105
+ section.group ? (
106
+ <Typography
107
+ key={`caption-${section.group.id}`}
108
+ variant='caption'
109
+ fontWeight='medium'
110
+ color='text.secondary'
111
+ sx={styles.groupCaption}
112
+ >
113
+ {section.group.label}
114
+ </Typography>
115
+ ) : null,
116
+ ...section.layers.map((layer) => (
117
+ <MenuItem
118
+ key={layer.id}
119
+ disableRipple
120
+ sx={styles.item}
121
+ onClick={() => setVisibility(layer.id, false)}
122
+ >
123
+ <Typography variant='body2' sx={styles.itemName}>
124
+ {layer.name}
125
+ </Typography>
126
+ <Tooltip title={labels.hideLayer}>
127
+ <VisibilityIcon sx={styles.eye} />
128
+ </Tooltip>
129
+ </MenuItem>
130
+ )),
131
+ ])}
132
+ </Menu>
133
+ </>
134
+ )
135
+ }
@@ -0,0 +1,60 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+
3
+ // Figma "Menu" (node 6962-12862): 232px paper, sticky caption header with a
4
+ // hairline below, caption-medium group subtitles, 32px items (12px left / 8px
5
+ // right inset) with an 18px eye, and 8px-padded dividers between sections.
6
+ export const styles = {
7
+ // Tooltip wrapper around the (possibly disabled) trigger button. A plain
8
+ // inline span would be sized by the line box and end up taller than the
9
+ // button inside.
10
+ triggerWrap: {
11
+ display: 'inline-flex',
12
+ },
13
+ paper: {
14
+ width: 240,
15
+ maxHeight: 320,
16
+ // List padding is disabled (flush sticky header); keep a small bottom
17
+ // inset so the last 32px item doesn't touch the paper edge.
18
+ paddingBottom: 0.5,
19
+ },
20
+ stickyHeader: {
21
+ position: 'sticky',
22
+ top: 0,
23
+ zIndex: 1,
24
+ backgroundColor: 'background.paper',
25
+ padding: 1,
26
+ marginBlockEnd: 1,
27
+ lineHeight: '16px',
28
+ borderBottom: '1px solid',
29
+ borderColor: 'divider',
30
+ },
31
+ groupCaption: {
32
+ display: 'block',
33
+ padding: ({ spacing }) => spacing(0.5, 1.5),
34
+ },
35
+ // The whole item is the hide action (keyboard-friendly); the eye is its
36
+ // visual affordance.
37
+ item: {
38
+ minHeight: 32,
39
+ display: 'flex',
40
+ alignItems: 'center',
41
+ justifyContent: 'space-between',
42
+ gap: 1,
43
+ padding: ({ spacing }) => spacing(0, 1, 0, 1.5),
44
+ },
45
+ itemName: {
46
+ minWidth: 0,
47
+ overflow: 'hidden',
48
+ textOverflow: 'ellipsis',
49
+ whiteSpace: 'nowrap',
50
+ },
51
+ // Design's 18px glyph — decorative (the item itself is the action).
52
+ eye: {
53
+ fontSize: 18,
54
+ color: 'action.active',
55
+ flexShrink: 0,
56
+ },
57
+ divider: {
58
+ marginY: 1,
59
+ },
60
+ } satisfies Record<string, SxProps<Theme>>