@carto/ps-react-ui 4.13.0 → 4.14.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 (61) hide show
  1. package/dist/{category-Dnd2_j0x.js → category-CGS_eHr4.js} +2 -2
  2. package/dist/{category-Dnd2_j0x.js.map → category-CGS_eHr4.js.map} +1 -1
  3. package/dist/{change-column-DjjwoPt1.js → change-column-CiVAjOUB.js} +2 -2
  4. package/dist/{change-column-DjjwoPt1.js.map → change-column-CiVAjOUB.js.map} +1 -1
  5. package/dist/components.js +1160 -753
  6. package/dist/components.js.map +1 -1
  7. package/dist/{data-zoom-layout-CkVnm6ej.js → data-zoom-layout-D_FYdnap.js} +2 -2
  8. package/dist/{data-zoom-layout-CkVnm6ej.js.map → data-zoom-layout-D_FYdnap.js.map} +1 -1
  9. package/dist/{download-config-oJIFZ2WC.js → download-config-OKlLLSW_.js} +2 -2
  10. package/dist/{download-config-oJIFZ2WC.js.map → download-config-OKlLLSW_.js.map} +1 -1
  11. package/dist/{lasso-tool-CDFj4zKY.js → lasso-tool-YLFRgb-S.js} +182 -154
  12. package/dist/lasso-tool-YLFRgb-S.js.map +1 -0
  13. package/dist/{spread-DYNpzgh_.js → spread-4MYPOntd.js} +2 -2
  14. package/dist/{spread-DYNpzgh_.js.map → spread-4MYPOntd.js.map} +1 -1
  15. package/dist/{table-B3ZWWhJt.js → table-CTcLOycx.js} +2 -2
  16. package/dist/{table-B3ZWWhJt.js.map → table-CTcLOycx.js.map} +1 -1
  17. package/dist/types/components/index.d.ts +4 -2
  18. package/dist/types/components/lasso-tool/geometry-toolbar.d.ts +13 -0
  19. package/dist/types/components/lasso-tool/icons.d.ts +6 -0
  20. package/dist/types/components/lasso-tool/lasso-tool.d.ts +1 -1
  21. package/dist/types/components/lasso-tool/layer-filters.d.ts +10 -0
  22. package/dist/types/components/lasso-tool/styles.d.ts +1 -1
  23. package/dist/types/components/lasso-tool/types.d.ts +62 -1
  24. package/dist/widgets/actions.js +1 -1
  25. package/dist/widgets/bar.js +1 -1
  26. package/dist/widgets/category.js +1 -1
  27. package/dist/widgets/formula.js +1 -1
  28. package/dist/widgets/histogram.js +1 -1
  29. package/dist/widgets/markdown.js +1 -1
  30. package/dist/widgets/pie.js +1 -1
  31. package/dist/widgets/scatterplot.js +1 -1
  32. package/dist/widgets/spread.js +1 -1
  33. package/dist/widgets/table.js +1 -1
  34. package/dist/widgets/timeseries.js +1 -1
  35. package/dist/widgets/utils.js +1 -1
  36. package/dist/widgets/wrapper.js +1 -1
  37. package/dist/widgets-v2/actions.js +1 -1
  38. package/dist/widgets-v2/bar.js +2 -2
  39. package/dist/widgets-v2/category.js +2 -2
  40. package/dist/widgets-v2/formula.js +1 -1
  41. package/dist/widgets-v2/histogram.js +2 -2
  42. package/dist/widgets-v2/markdown.js +1 -1
  43. package/dist/widgets-v2/pie.js +1 -1
  44. package/dist/widgets-v2/scatterplot.js +1 -1
  45. package/dist/widgets-v2/spread.js +2 -2
  46. package/dist/widgets-v2/table.js +3 -3
  47. package/dist/widgets-v2/timeseries.js +2 -2
  48. package/dist/widgets-v2/utils.js +1 -1
  49. package/dist/widgets-v2.js +5 -5
  50. package/package.json +1 -1
  51. package/src/components/index.ts +8 -0
  52. package/src/components/lasso-tool/const.tsx +1 -0
  53. package/src/components/lasso-tool/geometry-toolbar.test.tsx +138 -0
  54. package/src/components/lasso-tool/geometry-toolbar.tsx +258 -0
  55. package/src/components/lasso-tool/icons.tsx +40 -0
  56. package/src/components/lasso-tool/lasso-tool.tsx +25 -1
  57. package/src/components/lasso-tool/layer-filters.test.tsx +90 -0
  58. package/src/components/lasso-tool/layer-filters.tsx +236 -0
  59. package/src/components/lasso-tool/styles.ts +1 -1
  60. package/src/components/lasso-tool/types.ts +66 -1
  61. package/dist/lasso-tool-CDFj4zKY.js.map +0 -1
@@ -0,0 +1,258 @@
1
+ import { DeleteOutlined, LockOutlined } from '@mui/icons-material'
2
+ import {
3
+ Box,
4
+ Divider,
5
+ IconButton,
6
+ Paper,
7
+ Popover,
8
+ SvgIcon,
9
+ TextField,
10
+ type SxProps,
11
+ type Theme,
12
+ } from '@mui/material'
13
+ import {
14
+ useRef,
15
+ useState,
16
+ type JSX,
17
+ type KeyboardEvent,
18
+ type MouseEvent,
19
+ type ReactNode,
20
+ } from 'react'
21
+ import { Tooltip } from '../tooltip/tooltip'
22
+ import { SpatialMaskIcon, TextAaIcon } from './icons'
23
+ import type { LassoGeometryToolbarUIProps } from './types'
24
+
25
+ const ICON_SIZE = 18
26
+
27
+ const containerSx = {
28
+ display: 'flex',
29
+ flexDirection: 'row',
30
+ alignItems: 'center',
31
+ width: 'fit-content',
32
+ gap: ({ spacing }) => spacing(0.5),
33
+ p: ({ spacing }) => spacing(0.5),
34
+ borderRadius: ({ spacing }) => spacing(0.5),
35
+ backgroundColor: ({ palette }) => palette.background.paper,
36
+ } satisfies SxProps<Theme>
37
+
38
+ const baseButtonSx = {
39
+ p: ({ spacing }) => spacing(0.375),
40
+ borderRadius: ({ spacing }) => spacing(0.5),
41
+ '.MuiTouchRipple-ripple .MuiTouchRipple-child': {
42
+ borderRadius: ({ spacing }) => spacing(0.5),
43
+ },
44
+ } satisfies SxProps<Theme>
45
+
46
+ /** Active = Figma toggle "selected" treatment (primary tint); inactive = gray. */
47
+ const toggleButtonSx = (active: boolean): SxProps<Theme> => ({
48
+ ...baseButtonSx,
49
+ color: ({ palette }) =>
50
+ active ? palette.primary.main : palette.text.secondary,
51
+ backgroundColor: active ? 'rgba(3, 111, 226, 0.08)' : 'transparent',
52
+ '&:hover': {
53
+ backgroundColor: ({ palette }) =>
54
+ active ? 'rgba(3, 111, 226, 0.12)' : palette.action.hover,
55
+ },
56
+ })
57
+
58
+ /**
59
+ * Contextual toolbar for a single lasso geometry: toggle mask/draw, rename,
60
+ * lock, and delete. Purely presentational — position it over the map and wire
61
+ * the callbacks to the lasso store (see `LassoTools.GeometryToolbar` in
62
+ * `@carto/ps-react-maps`).
63
+ *
64
+ * Each button keeps a fixed icon and toggles an active highlight: the mask
65
+ * button is active when `type === 'mask'`, the lock button is active when
66
+ * `locked`. When `locked`, every action but the lock toggle is disabled.
67
+ */
68
+ export function LassoGeometryToolbarUI({
69
+ type,
70
+ label,
71
+ locked = false,
72
+ onToggleType,
73
+ onRename,
74
+ onToggleLock,
75
+ onDelete,
76
+ labels,
77
+ PaperProps: { sx, ...PaperProps } = {},
78
+ }: LassoGeometryToolbarUIProps): JSX.Element {
79
+ const paperRef = useRef<HTMLDivElement>(null)
80
+ // Guards `onRename` to once per rename session — the Popover `onClose` and the
81
+ // TextField `onBlur` can both fire for a single close (and Enter blurs on unmount).
82
+ const committedRef = useRef(false)
83
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
84
+ const [barWidth, setBarWidth] = useState<number>()
85
+ const [draft, setDraft] = useState(label)
86
+
87
+ const renaming = Boolean(anchorEl)
88
+
89
+ const toggleTypeLabel =
90
+ type === 'mask'
91
+ ? (labels?.toggleType?.draw ?? 'Switch to drawing')
92
+ : (labels?.toggleType?.mask ?? 'Switch to mask')
93
+ const renameLabel = labels?.rename ?? 'Rename'
94
+ const renameInputLabel = labels?.renameInput ?? renameLabel
95
+ const lockLabel = locked
96
+ ? (labels?.lock?.unlock ?? 'Unlock')
97
+ : (labels?.lock?.lock ?? 'Lock')
98
+ const deleteLabel = labels?.delete ?? 'Delete'
99
+
100
+ const openRename = () => {
101
+ committedRef.current = false
102
+ setDraft(label)
103
+ setBarWidth(paperRef.current?.clientWidth)
104
+ setAnchorEl(paperRef.current)
105
+ }
106
+
107
+ const commitRename = () => {
108
+ if (!committedRef.current) {
109
+ committedRef.current = true
110
+ const next = draft.trim()
111
+ if (next && next !== label) {
112
+ onRename(next)
113
+ }
114
+ }
115
+ setAnchorEl(null)
116
+ }
117
+
118
+ // Escape discards the edit: mark the session done so the unmount blur / onClose
119
+ // don't fire `onRename`.
120
+ const cancelRename = () => {
121
+ committedRef.current = true
122
+ setAnchorEl(null)
123
+ }
124
+
125
+ const handleRenameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
126
+ if (event.key === 'Enter') {
127
+ event.preventDefault()
128
+ commitRename()
129
+ }
130
+ if (event.key === 'Escape') {
131
+ event.preventDefault()
132
+ cancelRename()
133
+ }
134
+ }
135
+
136
+ return (
137
+ <Paper
138
+ ref={paperRef}
139
+ elevation={6}
140
+ sx={{ ...containerSx, ...sx }}
141
+ {...PaperProps}
142
+ >
143
+ <ToolbarButton
144
+ label={toggleTypeLabel}
145
+ active={type === 'mask'}
146
+ pressed={type === 'mask'}
147
+ disabled={locked}
148
+ onClick={() => onToggleType(type === 'mask' ? 'draw' : 'mask')}
149
+ icon={
150
+ <SvgIcon
151
+ component={SpatialMaskIcon}
152
+ inheritViewBox
153
+ sx={{ fontSize: ICON_SIZE }}
154
+ />
155
+ }
156
+ />
157
+
158
+ <Divider orientation='vertical' flexItem />
159
+
160
+ <ToolbarButton
161
+ label={renameLabel}
162
+ active={renaming}
163
+ disabled={locked}
164
+ onClick={openRename}
165
+ icon={
166
+ <SvgIcon
167
+ component={TextAaIcon}
168
+ inheritViewBox
169
+ sx={{ fontSize: ICON_SIZE }}
170
+ />
171
+ }
172
+ />
173
+
174
+ <Divider orientation='vertical' flexItem />
175
+
176
+ <ToolbarButton
177
+ label={lockLabel}
178
+ active={locked}
179
+ pressed={locked}
180
+ onClick={() => onToggleLock(!locked)}
181
+ icon={<LockOutlined sx={{ fontSize: ICON_SIZE }} />}
182
+ />
183
+
184
+ <Divider orientation='vertical' flexItem />
185
+
186
+ <ToolbarButton
187
+ label={deleteLabel}
188
+ disabled={locked}
189
+ onClick={onDelete}
190
+ icon={<DeleteOutlined sx={{ fontSize: ICON_SIZE }} />}
191
+ color='error'
192
+ />
193
+
194
+ <Popover
195
+ open={renaming}
196
+ anchorEl={anchorEl}
197
+ onClose={commitRename}
198
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
199
+ transformOrigin={{ vertical: 'top', horizontal: 'left' }}
200
+ slotProps={{
201
+ paper: {
202
+ sx: { width: barWidth, minWidth: 180 },
203
+ },
204
+ }}
205
+ >
206
+ <Box sx={{ p: 1 }}>
207
+ <TextField
208
+ inputRef={(node: HTMLInputElement | null) => node?.focus()}
209
+ fullWidth
210
+ label={renameInputLabel}
211
+ size='small'
212
+ variant='outlined'
213
+ value={draft}
214
+ onChange={(e) => setDraft(e.target.value)}
215
+ onKeyDown={handleRenameKeyDown}
216
+ onBlur={commitRename}
217
+ />
218
+ </Box>
219
+ </Popover>
220
+ </Paper>
221
+ )
222
+ }
223
+
224
+ function ToolbarButton({
225
+ label,
226
+ icon,
227
+ active = false,
228
+ pressed,
229
+ disabled = false,
230
+ color,
231
+ onClick,
232
+ }: {
233
+ label: string
234
+ icon: ReactNode
235
+ active?: boolean
236
+ /** When set, exposes `aria-pressed` so the button reads as a toggle. */
237
+ pressed?: boolean
238
+ disabled?: boolean
239
+ color?: 'error'
240
+ onClick: (event: MouseEvent<HTMLButtonElement>) => void
241
+ }): ReactNode {
242
+ return (
243
+ <Tooltip title={label} placement='top'>
244
+ <span>
245
+ <IconButton
246
+ aria-label={label}
247
+ aria-pressed={pressed}
248
+ disabled={disabled}
249
+ color={color}
250
+ onClick={onClick}
251
+ sx={color ? baseButtonSx : toggleButtonSx(active)}
252
+ >
253
+ {icon}
254
+ </IconButton>
255
+ </span>
256
+ </Tooltip>
257
+ )
258
+ }
@@ -89,3 +89,43 @@ export function SelectToolIcon(props: SVGProps<SVGSVGElement>) {
89
89
  </svg>
90
90
  )
91
91
  }
92
+
93
+ /** Spatial-mask glyph (circle with horizontal "globe" stripes) for the lasso
94
+ * geometry toolbar's mask/draw toggle. From Figma node 5960:14347. */
95
+ export function SpatialMaskIcon(props: SVGProps<SVGSVGElement>) {
96
+ return (
97
+ <svg
98
+ fill='none'
99
+ xmlns='http://www.w3.org/2000/svg'
100
+ viewBox='0 0 15 15'
101
+ {...props}
102
+ >
103
+ <path
104
+ fillRule='evenodd'
105
+ clipRule='evenodd'
106
+ d='M15 7.5C15 11.6421 11.6421 15 7.5 15C3.35786 15 0 11.6421 0 7.5C0 3.35786 3.35786 0 7.5 0C11.6421 0 15 3.35786 15 7.5ZM13.5 7.5C13.5 10.8137 10.8137 13.5 7.5 13.5C6.44531 13.5 5.45418 13.2279 4.59299 12.75H6.85607C7.29596 12.3013 7.67991 11.7975 7.99663 11.25H2.81597C2.62675 11.014 2.45501 10.7633 2.30269 10.5H8.37593C8.58402 10.0237 8.74407 9.52171 8.84997 9H1.68903C1.62614 8.75568 1.57826 8.50533 1.54642 8.25H8.96297C8.98746 8.00332 9 7.75312 9 7.5C9 7.24688 8.98746 6.99668 8.96297 6.75H1.54642C1.57826 6.49467 1.62614 6.24432 1.68903 6H8.84997C8.74407 5.47829 8.58402 4.97625 8.37593 4.5H2.30269C2.45501 4.2367 2.62675 3.98604 2.81597 3.75H7.99663C7.67991 3.20248 7.29596 2.69872 6.85607 2.25H4.59299C5.45418 1.77213 6.44531 1.5 7.5 1.5C10.8137 1.5 13.5 4.18629 13.5 7.5Z'
107
+ fill='currentColor'
108
+ />
109
+ </svg>
110
+ )
111
+ }
112
+
113
+ /** "Aa" glyph for the lasso geometry toolbar's rename action. From Figma node
114
+ * 5960:14347. */
115
+ export function TextAaIcon(props: SVGProps<SVGSVGElement>) {
116
+ return (
117
+ <svg
118
+ fill='none'
119
+ xmlns='http://www.w3.org/2000/svg'
120
+ viewBox='0 0 15.877 10.6875'
121
+ {...props}
122
+ >
123
+ <path
124
+ fillRule='evenodd'
125
+ clipRule='evenodd'
126
+ d='M5.4 0L9.18 10.5H7.56L6.72 8.085H2.475L1.62 10.5H0L3.795 0H5.4ZM4.605 1.95C4.515 2.31 4.395 2.705 4.245 3.135L2.955 6.705H6.24L4.965 3.135C4.785 2.645 4.665 2.25 4.605 1.95ZM12.9247 2.5875C13.3083 2.5875 13.6514 2.6203 13.9542 2.68591C14.257 2.75152 14.5649 2.87769 14.8778 3.06442C15.1907 3.25114 15.4355 3.55143 15.6121 3.96526C15.7887 4.37909 15.877 4.88881 15.877 5.49442V10.5058H14.363V9.83965C13.818 10.4049 13.1114 10.6875 12.2434 10.6875C11.4258 10.6875 10.7622 10.4629 10.2525 10.0138C9.74275 9.5646 9.48789 8.92619 9.48789 8.09853C9.48789 7.26077 9.77555 6.62488 10.3509 6.19086C10.9262 5.75685 11.6277 5.53984 12.4554 5.53984C13.2124 5.53984 13.8483 5.69124 14.363 5.99404V5.57012C14.363 4.99479 14.2318 4.586 13.9694 4.34376C13.707 4.10152 13.2881 3.9804 12.7127 3.9804C11.8649 3.9804 11.1281 4.21255 10.5023 4.67685L10.0329 3.38993C10.8101 2.85498 11.7741 2.5875 12.9247 2.5875ZM12.7127 6.90245C12.2081 6.90245 11.7968 7.00086 11.4788 7.19769C11.1609 7.39451 11.0019 7.69479 11.0019 8.09853C11.0019 8.47199 11.1331 8.7647 11.3955 8.97666C11.658 9.18862 12.0365 9.2946 12.5311 9.2946C13.2578 9.2946 13.8684 9.01703 14.363 8.46189V7.28096C13.8886 7.02862 13.3385 6.90245 12.7127 6.90245Z'
127
+ fill='currentColor'
128
+ />
129
+ </svg>
130
+ )
131
+ }
@@ -1,4 +1,8 @@
1
- import { ArrowDropDown, MoreVertOutlined } from '@mui/icons-material'
1
+ import {
2
+ ArrowDropDown,
3
+ LayersOutlined,
4
+ MoreVertOutlined,
5
+ } from '@mui/icons-material'
2
6
  import {
3
7
  Box,
4
8
  Divider,
@@ -73,6 +77,7 @@ export function LassoToolsUI({
73
77
  onChangeMode,
74
78
  onAllChipToggle,
75
79
  onAllDelete,
80
+ onLayerFilters,
76
81
  ChipsSlot = LassoToolsUI.Chips,
77
82
  ActionSlot = LassoToolsUI.Action,
78
83
  SecondaryActionsSlot = LassoToolsUI.SecondaryActions,
@@ -94,6 +99,7 @@ export function LassoToolsUI({
94
99
  onChangeMode,
95
100
  onAllChipToggle,
96
101
  onAllDelete,
102
+ onLayerFilters,
97
103
  }
98
104
  return (
99
105
  <Paper
@@ -506,6 +512,24 @@ LassoToolsUI.Options = function OptionsWrapper({
506
512
  />
507
513
  </>
508
514
  )}
515
+ {!!props.onLayerFilters && (
516
+ <>
517
+ <Divider />
518
+ <MenuItem
519
+ onClick={() => {
520
+ props.onLayerFilters?.()
521
+ props.onClose()
522
+ }}
523
+ >
524
+ <ListItemIcon sx={styles.options.icons}>
525
+ <LayersOutlined />
526
+ </ListItemIcon>
527
+ <ListItemText>
528
+ {labels?.options?.layerFilters ?? 'Layer filters'}
529
+ </ListItemText>
530
+ </MenuItem>
531
+ </>
532
+ )}
509
533
  </>
510
534
  )
511
535
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tests for the LassoLayerFiltersUI dialog — renders layer checkboxes from the
3
+ * committed selection, edits a draft, commits only on Apply (Cancel/close
4
+ * discards), supports "All selected" and search.
5
+ */
6
+ import { describe, it, expect, vi } from 'vitest'
7
+ import { render, fireEvent, screen, within } from '@testing-library/react'
8
+ import { LassoLayerFiltersUI } from './layer-filters'
9
+
10
+ const layers = [
11
+ { id: 'a', label: 'Blocks', checked: true },
12
+ { id: 'b', label: 'Roads', checked: false },
13
+ ]
14
+
15
+ function setup(props: Partial<Parameters<typeof LassoLayerFiltersUI>[0]> = {}) {
16
+ const handlers = { onClose: vi.fn(), onApply: vi.fn() }
17
+ render(<LassoLayerFiltersUI open layers={layers} {...handlers} {...props} />)
18
+ return handlers
19
+ }
20
+
21
+ const dialog = () => screen.getByRole('dialog')
22
+
23
+ describe('LassoLayerFiltersUI', () => {
24
+ it('renders the title and a checkbox per layer (+ All selected)', () => {
25
+ setup()
26
+ expect(screen.getByText('Layer Filters')).toBeDefined()
27
+ // 2 layers + the "All selected" master = 3 checkboxes.
28
+ expect(within(dialog()).getAllByRole('checkbox')).toHaveLength(3)
29
+ })
30
+
31
+ it('reflects the committed selection in the draft', () => {
32
+ setup()
33
+ expect(
34
+ within(dialog()).getByRole<HTMLInputElement>('checkbox', {
35
+ name: 'Blocks',
36
+ }).checked,
37
+ ).toBe(true)
38
+ expect(
39
+ within(dialog()).getByRole<HTMLInputElement>('checkbox', {
40
+ name: 'Roads',
41
+ }).checked,
42
+ ).toBe(false)
43
+ })
44
+
45
+ it('commits the edited selection only on Apply', () => {
46
+ const { onApply, onClose } = setup()
47
+ fireEvent.click(within(dialog()).getByRole('checkbox', { name: 'Roads' }))
48
+ // Not committed yet.
49
+ expect(onApply).not.toHaveBeenCalled()
50
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
51
+ expect(onApply).toHaveBeenCalledWith(['a', 'b'])
52
+ expect(onClose).toHaveBeenCalled()
53
+ })
54
+
55
+ it('discards the draft on Cancel', () => {
56
+ const { onApply, onClose } = setup()
57
+ fireEvent.click(within(dialog()).getByRole('checkbox', { name: 'Blocks' }))
58
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
59
+ expect(onApply).not.toHaveBeenCalled()
60
+ expect(onClose).toHaveBeenCalled()
61
+ })
62
+
63
+ it('"All selected" toggles every layer', () => {
64
+ const { onApply } = setup()
65
+ // Currently only "Blocks" checked → master is indeterminate; clicking selects all.
66
+ fireEvent.click(
67
+ within(dialog()).getByRole('checkbox', { name: 'All selected' }),
68
+ )
69
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
70
+ expect(onApply).toHaveBeenCalledWith(['a', 'b'])
71
+ })
72
+
73
+ it('filters the list by search text', () => {
74
+ setup()
75
+ fireEvent.change(within(dialog()).getByRole('textbox'), {
76
+ target: { value: 'road' },
77
+ })
78
+ expect(
79
+ within(dialog()).queryByRole('checkbox', { name: 'Blocks' }),
80
+ ).toBeNull()
81
+ expect(
82
+ within(dialog()).getByRole('checkbox', { name: 'Roads' }),
83
+ ).toBeDefined()
84
+ })
85
+
86
+ it('does not render when closed', () => {
87
+ setup({ open: false })
88
+ expect(screen.queryByRole('dialog')).toBeNull()
89
+ })
90
+ })
@@ -0,0 +1,236 @@
1
+ import { Close, Search } from '@mui/icons-material'
2
+ import {
3
+ Box,
4
+ Button,
5
+ Checkbox,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogTitle,
10
+ Divider,
11
+ IconButton,
12
+ InputBase,
13
+ List,
14
+ ListItemButton,
15
+ ListItemIcon,
16
+ ListItemText,
17
+ Typography,
18
+ type SxProps,
19
+ type Theme,
20
+ } from '@mui/material'
21
+ import { useId, useState, type JSX } from 'react'
22
+ import type { LassoLayerFiltersUIProps } from './types'
23
+
24
+ const titleSx = {
25
+ display: 'flex',
26
+ alignItems: 'center',
27
+ justifyContent: 'space-between',
28
+ gap: 1,
29
+ p: 2,
30
+ } satisfies SxProps<Theme>
31
+
32
+ // One rounded group on the grey "default" background, holding the search row,
33
+ // a divider, and the scrollable list (Figma node 5960-14780).
34
+ const groupSx = {
35
+ borderRadius: ({ spacing }) => spacing(0.5),
36
+ overflow: 'hidden',
37
+ backgroundColor: ({ palette }) => palette.background.default,
38
+ } satisfies SxProps<Theme>
39
+
40
+ const searchSx = {
41
+ display: 'flex',
42
+ alignItems: 'center',
43
+ gap: 1,
44
+ height: 32,
45
+ px: 1.5,
46
+ color: ({ palette }) => palette.text.secondary,
47
+ } satisfies SxProps<Theme>
48
+
49
+ const rowSx = {
50
+ px: 2,
51
+ py: 0.5,
52
+ } satisfies SxProps<Theme>
53
+
54
+ const checkboxSlotSx = {
55
+ minWidth: 0,
56
+ mr: 1,
57
+ } satisfies SxProps<Theme>
58
+
59
+ /**
60
+ * "Layer filters" dialog: choose which map layers the lasso mask clips. The
61
+ * selection is a draft committed on Apply (Cancel/close discards it). Wire it
62
+ * to the lasso store via `LassoTools.LayerFilters` in `@carto/ps-react-maps`,
63
+ * and open it from the drawing-tool menu's "Layer filters" item
64
+ * (`LassoToolsUI` `onLayerFilters`).
65
+ */
66
+ export function LassoLayerFiltersUI({
67
+ open,
68
+ onClose,
69
+ layers,
70
+ onApply,
71
+ labels,
72
+ DialogProps,
73
+ }: LassoLayerFiltersUIProps): JSX.Element {
74
+ const labelId = useId()
75
+ const [draft, setDraft] = useState<Set<string>>(() => new Set())
76
+ const [search, setSearch] = useState('')
77
+
78
+ // Reset the draft from the committed selection when the dialog opens, by
79
+ // adjusting state during render (the React-recommended pattern — no effect,
80
+ // and later `layers` changes don't discard in-progress edits).
81
+ const [wasOpen, setWasOpen] = useState(false)
82
+ if (open !== wasOpen) {
83
+ setWasOpen(open)
84
+ if (open) {
85
+ setDraft(new Set(layers.filter((l) => l.checked).map((l) => l.id)))
86
+ setSearch('')
87
+ }
88
+ }
89
+
90
+ const title = labels?.title ?? 'Layer Filters'
91
+ const description =
92
+ labels?.description ??
93
+ 'Select the layers you want to filter with this polygon'
94
+ const allSelectedLabel = labels?.allSelected ?? 'All selected'
95
+ const empty = labels?.empty ?? 'No layers to filter'
96
+
97
+ const allChecked = layers.length > 0 && layers.every((l) => draft.has(l.id))
98
+ const someChecked = layers.some((l) => draft.has(l.id))
99
+
100
+ const toggle = (id: string) =>
101
+ setDraft((prev) => {
102
+ const next = new Set(prev)
103
+ if (next.has(id)) next.delete(id)
104
+ else next.add(id)
105
+ return next
106
+ })
107
+
108
+ const toggleAll = () =>
109
+ setDraft(allChecked ? new Set() : new Set(layers.map((l) => l.id)))
110
+
111
+ const handleApply = () => {
112
+ // Emit ids in `layers` order (stable, diff-friendly), not Set-insertion order.
113
+ onApply(layers.filter((l) => draft.has(l.id)).map((l) => l.id))
114
+ onClose()
115
+ }
116
+
117
+ const normalized = search.trim().toLowerCase()
118
+ const filtered = normalized
119
+ ? layers.filter((l) => l.label.toLowerCase().includes(normalized))
120
+ : layers
121
+
122
+ return (
123
+ <Dialog
124
+ open={open}
125
+ onClose={onClose}
126
+ maxWidth='xs'
127
+ fullWidth
128
+ {...DialogProps}
129
+ >
130
+ <DialogTitle component='div' sx={titleSx}>
131
+ <Typography variant='subtitle1' fontWeight={500}>
132
+ {title}
133
+ </Typography>
134
+ <IconButton aria-label='close' size='small' onClick={onClose}>
135
+ <Close fontSize='small' />
136
+ </IconButton>
137
+ </DialogTitle>
138
+
139
+ <DialogContent sx={{ pt: 1, px: 2, pb: 3 }}>
140
+ <Typography variant='body2' color='text.secondary' sx={{ mb: 2 }}>
141
+ {description}
142
+ </Typography>
143
+
144
+ <Box sx={groupSx}>
145
+ <Box sx={searchSx}>
146
+ <Search sx={{ fontSize: 18 }} />
147
+ <InputBase
148
+ fullWidth
149
+ placeholder={labels?.search ?? 'Search layers'}
150
+ inputProps={{ 'aria-label': labels?.search ?? 'Search layers' }}
151
+ value={search}
152
+ onChange={(e) => setSearch(e.target.value)}
153
+ sx={{ fontSize: 13, color: 'text.primary' }}
154
+ />
155
+ </Box>
156
+ <Divider />
157
+ <List
158
+ component='div'
159
+ role='list'
160
+ disablePadding
161
+ sx={{
162
+ maxHeight: 240,
163
+ overflowY: 'auto',
164
+ // MUI gives dividers inside a List an 8px vertical margin; the
165
+ // "All selected" separator should sit flush like the rows.
166
+ '& .MuiDivider-root': { my: 0 },
167
+ }}
168
+ >
169
+ <ListItemButton dense sx={rowSx} onClick={toggleAll}>
170
+ <ListItemIcon sx={checkboxSlotSx}>
171
+ <Checkbox
172
+ edge='start'
173
+ tabIndex={-1}
174
+ disableRipple
175
+ sx={{ p: 0.5 }}
176
+ checked={allChecked}
177
+ indeterminate={!allChecked && someChecked}
178
+ inputProps={{ 'aria-labelledby': `${labelId}-all` }}
179
+ />
180
+ </ListItemIcon>
181
+ <ListItemText
182
+ id={`${labelId}-all`}
183
+ primary={allSelectedLabel}
184
+ primaryTypographyProps={{ variant: 'body2' }}
185
+ />
186
+ </ListItemButton>
187
+ <Divider />
188
+ {filtered.length === 0 ? (
189
+ <Typography variant='body2' color='text.secondary' sx={{ p: 2 }}>
190
+ {empty}
191
+ </Typography>
192
+ ) : (
193
+ filtered.map((layer) => (
194
+ <ListItemButton
195
+ key={layer.id}
196
+ dense
197
+ sx={rowSx}
198
+ onClick={() => toggle(layer.id)}
199
+ >
200
+ <ListItemIcon sx={checkboxSlotSx}>
201
+ <Checkbox
202
+ edge='start'
203
+ tabIndex={-1}
204
+ disableRipple
205
+ sx={{ p: 0.5 }}
206
+ checked={draft.has(layer.id)}
207
+ inputProps={{
208
+ 'aria-labelledby': `${labelId}-${layer.id}`,
209
+ }}
210
+ />
211
+ </ListItemIcon>
212
+ <ListItemText
213
+ id={`${labelId}-${layer.id}`}
214
+ primary={layer.label}
215
+ primaryTypographyProps={{
216
+ variant: 'body2',
217
+ noWrap: true,
218
+ title: layer.label,
219
+ }}
220
+ />
221
+ </ListItemButton>
222
+ ))
223
+ )}
224
+ </List>
225
+ </Box>
226
+ </DialogContent>
227
+
228
+ <DialogActions sx={{ p: 2 }}>
229
+ <Button onClick={onClose}>{labels?.cancel ?? 'Cancel'}</Button>
230
+ <Button variant='contained' onClick={handleApply}>
231
+ {labels?.apply ?? 'Apply'}
232
+ </Button>
233
+ </DialogActions>
234
+ </Dialog>
235
+ )
236
+ }
@@ -27,7 +27,7 @@ export const styles = {
27
27
  },
28
28
  options: {
29
29
  menu: {
30
- paddingTop: ({ spacing }) => spacing(1),
30
+ paddingY: ({ spacing }) => spacing(1),
31
31
  '&.inline': {
32
32
  boxShadow: 'none',
33
33
  backgroundColor: 'transparent',