@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.
- package/dist/components.js +786 -749
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -16
- package/dist/legend-store-registry-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/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/package.json +3 -3
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
- package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
- package/src/legend/components/a11y.ts +14 -0
- package/src/legend/components/contexts.ts +29 -4
- package/src/legend/components/index.ts +3 -4
- package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
- package/src/legend/components/legend-group/legend-group.test.tsx +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/dist/selectors-DwHj6mE2.js +0 -388
- 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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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>>
|