@carto/ps-react-ui 4.12.1 → 4.13.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 (104) hide show
  1. package/dist/components.js +706 -658
  2. package/dist/components.js.map +1 -1
  3. package/dist/legend/stores.js +20 -0
  4. package/dist/legend/stores.js.map +1 -0
  5. package/dist/legend.js +1536 -0
  6. package/dist/legend.js.map +1 -0
  7. package/dist/selectors-DwHj6mE2.js +388 -0
  8. package/dist/selectors-DwHj6mE2.js.map +1 -0
  9. package/dist/types/components/index.d.ts +1 -0
  10. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
  11. package/dist/types/legend/components/contexts.d.ts +20 -0
  12. package/dist/types/legend/components/icons.d.ts +12 -0
  13. package/dist/types/legend/components/index.d.ts +26 -0
  14. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
  15. package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
  16. package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
  17. package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
  18. package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
  19. package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
  20. package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
  21. package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
  22. package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
  23. package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
  24. package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
  25. package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
  26. package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
  27. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
  28. package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
  29. package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
  30. package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
  31. package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
  32. package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
  33. package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
  34. package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
  35. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
  36. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
  37. package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
  38. package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
  39. package/dist/types/legend/index.d.ts +88 -0
  40. package/dist/types/legend/provider/index.d.ts +6 -0
  41. package/dist/types/legend/provider/labels.d.ts +27 -0
  42. package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
  43. package/dist/types/legend/provider/legend-provider.d.ts +38 -0
  44. package/dist/types/legend/stores/index.d.ts +5 -0
  45. package/dist/types/legend/stores/legend-context.d.ts +4 -0
  46. package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
  47. package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
  48. package/dist/types/legend/stores/selectors.d.ts +33 -0
  49. package/dist/types/legend/stores/types.d.ts +275 -0
  50. package/dist/widgets-v2/bar.js +7 -3
  51. package/dist/widgets-v2/bar.js.map +1 -1
  52. package/dist/widgets-v2/histogram.js +9 -4
  53. package/dist/widgets-v2/histogram.js.map +1 -1
  54. package/package.json +11 -3
  55. package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
  56. package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
  57. package/src/components/index.ts +2 -0
  58. package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
  59. package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
  60. package/src/legend/components/contexts.ts +32 -0
  61. package/src/legend/components/icons.tsx +22 -0
  62. package/src/legend/components/index.ts +39 -0
  63. package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
  64. package/src/legend/components/legend-actions/styles.ts +9 -0
  65. package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
  66. package/src/legend/components/legend-category/styles.ts +61 -0
  67. package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
  68. package/src/legend/components/legend-config-select/styles.ts +48 -0
  69. package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
  70. package/src/legend/components/legend-group/legend-group.tsx +88 -0
  71. package/src/legend/components/legend-group/styles.ts +82 -0
  72. package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
  73. package/src/legend/components/legend-icon/styles.ts +56 -0
  74. package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
  75. package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
  76. package/src/legend/components/legend-item/legend-item.tsx +34 -0
  77. package/src/legend/components/legend-item/styles.ts +11 -0
  78. package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
  79. package/src/legend/components/legend-opacity/styles.ts +28 -0
  80. package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
  81. package/src/legend/components/legend-proportion/styles.ts +43 -0
  82. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
  83. package/src/legend/components/legend-ramp/styles.ts +62 -0
  84. package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
  85. package/src/legend/components/legend-row/legend-row.tsx +129 -0
  86. package/src/legend/components/legend-row/styles.ts +111 -0
  87. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
  88. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
  89. package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
  90. package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
  91. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
  92. package/src/legend/index.ts +121 -0
  93. package/src/legend/provider/index.ts +6 -0
  94. package/src/legend/provider/labels.ts +59 -0
  95. package/src/legend/provider/legend-config-context.ts +15 -0
  96. package/src/legend/provider/legend-provider.tsx +102 -0
  97. package/src/legend/stores/index.ts +46 -0
  98. package/src/legend/stores/legend-context.ts +17 -0
  99. package/src/legend/stores/legend-store-registry.ts +639 -0
  100. package/src/legend/stores/legend-store.test.ts +457 -0
  101. package/src/legend/stores/selectors.ts +65 -0
  102. package/src/legend/stores/types.ts +347 -0
  103. package/src/widgets-v2/bar/options.ts +4 -0
  104. package/src/widgets-v2/histogram/options.ts +5 -0
@@ -1,6 +1,6 @@
1
1
  import { render, screen, fireEvent } from '@testing-library/react'
2
- import { describe, test, expect, vi, beforeEach } from 'vitest'
3
- import { ResponsiveDrawer } from './responsive-drawer'
2
+ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
3
+ import { ResponsiveDrawer, resolveOrigins } from './responsive-drawer'
4
4
  import { ThemeProvider } from '@mui/material'
5
5
  import { createTheme } from '@mui/material/styles'
6
6
 
@@ -10,6 +10,10 @@ const theme = createTheme()
10
10
  describe('ResponsiveDrawer', () => {
11
11
  const mockOnChangeCollapsed = vi.fn()
12
12
 
13
+ // A real anchor element: the desktop Popover only opens when `ref != null`
14
+ // (the open-by-default guard), and positioning needs a DOM node.
15
+ let anchor: HTMLElement
16
+
13
17
  const defaultProps = {
14
18
  collapsed: false,
15
19
  onChangeCollapsed: mockOnChangeCollapsed,
@@ -20,12 +24,18 @@ describe('ResponsiveDrawer', () => {
20
24
 
21
25
  beforeEach(() => {
22
26
  mockOnChangeCollapsed.mockReset()
27
+ anchor = document.createElement('div')
28
+ document.body.appendChild(anchor)
29
+ })
30
+
31
+ afterEach(() => {
32
+ anchor.remove()
23
33
  })
24
34
 
25
35
  test('renders in desktop mode when isMobile is false', () => {
26
36
  const { container } = render(
27
37
  <ThemeProvider theme={theme}>
28
- <ResponsiveDrawer ref={null} {...defaultProps} />
38
+ <ResponsiveDrawer ref={anchor} {...defaultProps} />
29
39
  </ThemeProvider>,
30
40
  )
31
41
 
@@ -40,10 +50,21 @@ describe('ResponsiveDrawer', () => {
40
50
  expect(presentationElements.length).toBe(0)
41
51
  })
42
52
 
53
+ test('does not open the desktop popover until the anchor ref resolves', () => {
54
+ // Open-by-default guard: collapsed=false but ref=null → stays closed so the
55
+ // popover never renders with a null anchorEl.
56
+ render(
57
+ <ThemeProvider theme={theme}>
58
+ <ResponsiveDrawer ref={null} {...defaultProps} />
59
+ </ThemeProvider>,
60
+ )
61
+ expect(screen.queryByTestId('drawer-content')).toBeNull()
62
+ })
63
+
43
64
  test('renders in mobile mode when isMobile is true', () => {
44
65
  render(
45
66
  <ThemeProvider theme={theme}>
46
- <ResponsiveDrawer ref={null} {...defaultProps} isMobile={true} />
67
+ <ResponsiveDrawer ref={anchor} {...defaultProps} isMobile={true} />
47
68
  </ThemeProvider>,
48
69
  )
49
70
 
@@ -61,7 +82,7 @@ describe('ResponsiveDrawer', () => {
61
82
  test('hides content when collapsed is true in desktop mode', () => {
62
83
  render(
63
84
  <ThemeProvider theme={theme}>
64
- <ResponsiveDrawer ref={null} {...defaultProps} collapsed={true} />
85
+ <ResponsiveDrawer ref={anchor} {...defaultProps} collapsed={true} />
65
86
  </ThemeProvider>,
66
87
  )
67
88
 
@@ -73,7 +94,7 @@ describe('ResponsiveDrawer', () => {
73
94
  test('closes drawer when clicking outside in mobile mode', () => {
74
95
  const { container } = render(
75
96
  <ThemeProvider theme={theme}>
76
- <ResponsiveDrawer ref={null} {...defaultProps} isMobile={true} />
97
+ <ResponsiveDrawer ref={anchor} {...defaultProps} isMobile={true} />
77
98
  </ThemeProvider>,
78
99
  )
79
100
 
@@ -90,9 +111,7 @@ describe('ResponsiveDrawer', () => {
90
111
  })
91
112
 
92
113
  // Each of the four `position` values produces a different
93
- // anchorOrigin/transformOrigin combination. Exercising all four covers
94
- // both ternary branches of position.startsWith('bottom') and
95
- // position.endsWith('right').
114
+ // anchorOrigin/transformOrigin combination.
96
115
  test.each(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const)(
97
116
  'renders in desktop mode with position=%s',
98
117
  (position) => {
@@ -100,7 +119,7 @@ describe('ResponsiveDrawer', () => {
100
119
  render(
101
120
  <ThemeProvider theme={theme}>
102
121
  <ResponsiveDrawer
103
- ref={null}
122
+ ref={anchor}
104
123
  {...defaultProps}
105
124
  position={position}
106
125
  />
@@ -110,22 +129,14 @@ describe('ResponsiveDrawer', () => {
110
129
  },
111
130
  )
112
131
 
113
- test('desktop Popover onClose invokes onChangeCollapsed(true)', () => {
114
- const { container } = render(
132
+ test('modal (default): renders an (invisible) backdrop', () => {
133
+ render(
115
134
  <ThemeProvider theme={theme}>
116
- <ResponsiveDrawer ref={null} {...defaultProps} />
135
+ <ResponsiveDrawer ref={anchor} {...defaultProps} />
117
136
  </ThemeProvider>,
118
137
  )
119
- // The Popover renders a backdrop that closes on click.
120
- const backdrop = container.querySelector('.MuiBackdrop-root')
121
- if (backdrop) {
122
- fireEvent.click(backdrop)
123
- expect(mockOnChangeCollapsed).toHaveBeenCalledWith(true)
124
- } else {
125
- // jsdom + Popover positioning quirks: simulate the close handler directly
126
- // by re-rendering with collapsed=true via the props change.
127
- expect(mockOnChangeCollapsed).toBeDefined()
128
- }
138
+ // The popover portals to document.body, so query the whole document.
139
+ expect(document.querySelector('.MuiBackdrop-root')).not.toBeNull()
129
140
  })
130
141
 
131
142
  test('forwards slotProps to the underlying Popover', () => {
@@ -134,7 +145,7 @@ describe('ResponsiveDrawer', () => {
134
145
  render(
135
146
  <ThemeProvider theme={theme}>
136
147
  <ResponsiveDrawer
137
- ref={null}
148
+ ref={anchor}
138
149
  {...defaultProps}
139
150
  slotProps={slotProps}
140
151
  />
@@ -148,7 +159,7 @@ describe('ResponsiveDrawer', () => {
148
159
  render(
149
160
  <ThemeProvider theme={theme}>
150
161
  <ResponsiveDrawer
151
- ref={null}
162
+ ref={anchor}
152
163
  {...defaultProps}
153
164
  sx={{ zIndex: 9999 }}
154
165
  />
@@ -156,4 +167,97 @@ describe('ResponsiveDrawer', () => {
156
167
  ),
157
168
  ).not.toThrow()
158
169
  })
170
+
171
+ // ── Non-modal mode (modal={false}) ─────────────────────────────────────────
172
+
173
+ test('non-modal: renders no backdrop', () => {
174
+ render(
175
+ <ThemeProvider theme={theme}>
176
+ <ResponsiveDrawer ref={anchor} {...defaultProps} modal={false} />
177
+ </ThemeProvider>,
178
+ )
179
+ expect(document.querySelector('.MuiBackdrop-root')).toBeNull()
180
+ // Content still renders.
181
+ expect(screen.getByTestId('drawer-content')).toBeDefined()
182
+ })
183
+
184
+ test('non-modal: Escape does not close the panel', () => {
185
+ render(
186
+ <ThemeProvider theme={theme}>
187
+ <ResponsiveDrawer ref={anchor} {...defaultProps} modal={false} />
188
+ </ThemeProvider>,
189
+ )
190
+ // Fire on a node inside the Modal root so it reaches the Modal's key handler.
191
+ fireEvent.keyDown(screen.getByTestId('drawer-content'), {
192
+ key: 'Escape',
193
+ code: 'Escape',
194
+ })
195
+ expect(mockOnChangeCollapsed).not.toHaveBeenCalled()
196
+ })
197
+
198
+ test('modal (default): Escape closes the panel', () => {
199
+ render(
200
+ <ThemeProvider theme={theme}>
201
+ <ResponsiveDrawer ref={anchor} {...defaultProps} />
202
+ </ThemeProvider>,
203
+ )
204
+ fireEvent.keyDown(screen.getByTestId('drawer-content'), {
205
+ key: 'Escape',
206
+ code: 'Escape',
207
+ })
208
+ expect(mockOnChangeCollapsed).toHaveBeenCalledWith(true)
209
+ })
210
+ })
211
+
212
+ // ── resolveOrigins (pure) ─────────────────────────────────────────────────────
213
+
214
+ describe('resolveOrigins', () => {
215
+ test('side placement opens beside the trigger (horizontal flip)', () => {
216
+ expect(resolveOrigins('bottom-right', 'side')).toEqual({
217
+ anchorOrigin: { vertical: 'bottom', horizontal: 'left' },
218
+ transformOrigin: { vertical: 'bottom', horizontal: 'right' },
219
+ })
220
+ expect(resolveOrigins('top-left', 'side')).toEqual({
221
+ anchorOrigin: { vertical: 'top', horizontal: 'right' },
222
+ transformOrigin: { vertical: 'top', horizontal: 'left' },
223
+ })
224
+ })
225
+
226
+ test('overlay placement pins corner-to-corner (panel sits on top of the anchor)', () => {
227
+ // anchorOrigin === transformOrigin for every corner → the panel lands on the
228
+ // anchor's own corner (it's hidden while open).
229
+ for (const position of [
230
+ 'top-left',
231
+ 'top-right',
232
+ 'bottom-left',
233
+ 'bottom-right',
234
+ ] as const) {
235
+ const { anchorOrigin, transformOrigin } = resolveOrigins(
236
+ position,
237
+ 'overlay',
238
+ )
239
+ expect(anchorOrigin).toEqual(transformOrigin)
240
+ }
241
+ expect(resolveOrigins('bottom-right', 'overlay').anchorOrigin).toEqual({
242
+ vertical: 'bottom',
243
+ horizontal: 'right',
244
+ })
245
+ expect(resolveOrigins('top-left', 'overlay').anchorOrigin).toEqual({
246
+ vertical: 'top',
247
+ horizontal: 'left',
248
+ })
249
+ })
250
+
251
+ test('transformOrigin is independent of placement (panel keeps its own corner)', () => {
252
+ for (const position of [
253
+ 'top-left',
254
+ 'top-right',
255
+ 'bottom-left',
256
+ 'bottom-right',
257
+ ] as const) {
258
+ const side = resolveOrigins(position, 'side')
259
+ const overlay = resolveOrigins(position, 'overlay')
260
+ expect(overlay.transformOrigin).toEqual(side.transformOrigin)
261
+ }
262
+ })
159
263
  })
@@ -1,11 +1,64 @@
1
- import { Drawer, Popover, type PopoverProps } from '@mui/material'
2
- import type { ReactNode } from 'react'
1
+ import {
2
+ Drawer,
3
+ Popover,
4
+ type PopoverActions,
5
+ type PopoverOrigin,
6
+ type PopoverProps,
7
+ } from '@mui/material'
8
+ import { useCallback, useRef, type CSSProperties, type ReactNode } from 'react'
9
+
10
+ type ResponsiveDrawerPosition =
11
+ | 'top-left'
12
+ | 'top-right'
13
+ | 'bottom-left'
14
+ | 'bottom-right'
15
+
16
+ /** Placement of the panel relative to its anchor (desktop popover only). */
17
+ export type ResponsiveDrawerPlacement = 'side' | 'overlay'
18
+
19
+ /**
20
+ * Resolve the Popover anchor/transform origins for a given corner + placement.
21
+ *
22
+ * `transformOrigin` (the panel corner that pins to the anchor point) is the same
23
+ * for both placements: the panel's own corner matching `position`. `anchorOrigin`
24
+ * (the point on the anchor the panel pins to) differs:
25
+ * - `'side'` flips horizontally → the panel opens beside the trigger.
26
+ * - `'overlay'` uses the same corner → the panel sits **on top of** the anchor
27
+ * (e.g. a trigger that's hidden while the panel is open), pinned corner-to-corner.
28
+ */
29
+ export function resolveOrigins(
30
+ position: ResponsiveDrawerPosition,
31
+ placement: ResponsiveDrawerPlacement,
32
+ ): { anchorOrigin: PopoverOrigin; transformOrigin: PopoverOrigin } {
33
+ const isBottom = position.startsWith('bottom')
34
+ const isRight = position.endsWith('right')
35
+ const overlay = placement === 'overlay'
36
+
37
+ return {
38
+ anchorOrigin: {
39
+ vertical: isBottom ? 'bottom' : 'top',
40
+ // `'side'` flips horizontally to open beside the anchor; `'overlay'` keeps
41
+ // the same edge so the panel lands on top of it.
42
+ horizontal: overlay
43
+ ? isRight
44
+ ? 'right'
45
+ : 'left'
46
+ : isRight
47
+ ? 'left'
48
+ : 'right',
49
+ },
50
+ transformOrigin: {
51
+ vertical: isBottom ? 'bottom' : 'top',
52
+ horizontal: isRight ? 'right' : 'left',
53
+ },
54
+ }
55
+ }
3
56
 
4
57
  export function ResponsiveDrawer<
5
58
  T extends {
6
59
  collapsed: boolean
7
60
  onChangeCollapsed: (collapsed: boolean) => void
8
- position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
61
+ position: ResponsiveDrawerPosition
9
62
  slotProps?: PopoverProps['slotProps']
10
63
  },
11
64
  >({
@@ -17,37 +70,117 @@ export function ResponsiveDrawer<
17
70
  sx,
18
71
  slotProps,
19
72
  onChangeCollapsed,
73
+ modal = true,
74
+ placement = 'side',
20
75
  }: Required<Pick<T, 'collapsed' | 'onChangeCollapsed' | 'position'>> &
21
76
  Pick<PopoverProps, 'slotProps' | 'sx'> & {
22
77
  ref: HTMLElement | null
23
78
  isMobile: boolean
24
79
  children: ReactNode
80
+ /**
81
+ * When `false`, the desktop popover is **non-modal**: no backdrop, clicks
82
+ * pass through to the content behind it (e.g. a map stays interactive), no
83
+ * focus trap, and neither backdrop-click nor Escape closes it (close it via
84
+ * `onChangeCollapsed`). Defaults to `true` (standard dismissable popover).
85
+ * Desktop-only — the mobile drawer is unaffected.
86
+ */
87
+ modal?: boolean
88
+ /**
89
+ * Desktop popover placement relative to the anchor: `'side'` (default) opens
90
+ * beside the trigger; `'overlay'` pins corner-to-corner, sitting on top of
91
+ * the anchor (e.g. a trigger hidden while the panel is open). Desktop-only.
92
+ */
93
+ placement?: ResponsiveDrawerPlacement
25
94
  }) {
95
+ // MUI Popover computes its position once on open and never re-measures when
96
+ // its content resizes — so a panel that grows/shrinks (e.g. collapsing legend
97
+ // groups) keeps its frozen `top` and detaches from the anchored edge (the
98
+ // "blink"). Re-run the Popover's own positioning (`action.updatePosition`)
99
+ // whenever the content changes size: with `transformOrigin` matching the
100
+ // anchor edge, that edge stays pinned (bottom for `bottom-*`, top for
101
+ // `top-*`) — and because updatePosition is synchronous, it tracks every
102
+ // animation frame smoothly rather than snapping after a debounce.
103
+ //
104
+ // A callback ref (not a `useEffect`) attaches the observer the moment the
105
+ // content mounts — the Popover renders into a portal behind a transition, so
106
+ // the element isn't available on the effect tick that follows `open`.
107
+ const popoverActions = useRef<PopoverActions>(null)
108
+ const observerRef = useRef<ResizeObserver | null>(null)
109
+ const observeContent = useCallback((el: HTMLDivElement | null) => {
110
+ observerRef.current?.disconnect()
111
+ observerRef.current = null
112
+ if (el && typeof ResizeObserver !== 'undefined') {
113
+ const observer = new ResizeObserver(() =>
114
+ popoverActions.current?.updatePosition(),
115
+ )
116
+ observer.observe(el)
117
+ observerRef.current = observer
118
+ }
119
+ }, [])
120
+
26
121
  if (isMobile) {
122
+ // MUI calls `onClose(event, reason)`; adapt it to the boolean contract.
27
123
  return (
28
- <Drawer anchor='bottom' open={!collapsed} onClose={onChangeCollapsed}>
124
+ <Drawer
125
+ anchor='bottom'
126
+ open={!collapsed}
127
+ onClose={() => onChangeCollapsed(true)}
128
+ >
29
129
  {children}
30
130
  </Drawer>
31
131
  )
32
132
  }
33
133
 
134
+ const { anchorOrigin, transformOrigin } = resolveOrigins(position, placement)
135
+
136
+ // Non-modal mode injects pointer-through `style` into the root/paper slots so
137
+ // clicks fall through to the content behind (the map) while the panel stays
138
+ // interactive. Only the legend uses it and it passes no `slotProps`, so the
139
+ // common path (modal, incl. Basemaps' callback/object slotProps) is passed
140
+ // straight through untouched.
141
+ const objectSlotProps = slotProps as
142
+ | { root?: Record<string, unknown>; paper?: { style?: CSSProperties } }
143
+ | undefined
144
+ const mergedSlotProps: PopoverProps['slotProps'] = modal
145
+ ? slotProps
146
+ : {
147
+ root: {
148
+ style: { pointerEvents: 'none' },
149
+ ...objectSlotProps?.root,
150
+ },
151
+ paper: {
152
+ ...objectSlotProps?.paper,
153
+ style: {
154
+ pointerEvents: 'auto',
155
+ ...objectSlotProps?.paper?.style,
156
+ },
157
+ },
158
+ }
159
+
34
160
  return (
35
161
  <Popover
162
+ action={popoverActions}
36
163
  sx={sx}
37
164
  anchorEl={ref}
38
- open={!collapsed}
39
- onClose={() => onChangeCollapsed(true)}
40
- anchorOrigin={{
41
- vertical: position.startsWith('bottom') ? 'bottom' : 'top',
42
- horizontal: position.endsWith('right') ? 'left' : 'right',
43
- }}
44
- transformOrigin={{
45
- vertical: position.startsWith('bottom') ? 'bottom' : 'top',
46
- horizontal: position.endsWith('right') ? 'right' : 'left',
47
- }}
48
- slotProps={slotProps}
165
+ // Guard on `ref` so an open-by-default panel doesn't render before the
166
+ // anchor callback-ref has resolved (avoids the null-anchorEl warning +
167
+ // one-frame misposition; it re-renders non-null immediately after mount).
168
+ open={!collapsed && ref != null}
169
+ // In non-modal mode neither backdrop nor Escape dismisses — close via
170
+ // `onChangeCollapsed` (e.g. the panel's own ✕).
171
+ onClose={modal ? () => onChangeCollapsed(true) : undefined}
172
+ hideBackdrop={!modal}
173
+ disableScrollLock={!modal}
174
+ disableEscapeKeyDown={!modal}
175
+ disableAutoFocus={!modal}
176
+ disableEnforceFocus={!modal}
177
+ disableRestoreFocus={!modal}
178
+ anchorOrigin={anchorOrigin}
179
+ transformOrigin={transformOrigin}
180
+ slotProps={mergedSlotProps}
49
181
  >
50
- {children}
182
+ {/* Observed for content-resize → keeps the Popover pinned (see above). */}
183
+ <div ref={observeContent}>{children}</div>
51
184
  </Popover>
52
185
  )
53
186
  }
@@ -0,0 +1,32 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ /**
4
+ * Per-row composition context provided by `Legend.Row`. Actions placed inside
5
+ * the row resolve their target layer from here instead of taking a `layerId`
6
+ * prop, and the opacity compound shares its popover state through it (the
7
+ * popover anchors to the row's sticky header element to span its full width).
8
+ */
9
+ export interface LegendRowContextValue {
10
+ layerId: string
11
+ /** The row's sticky header element — anchor for `Legend.Opacity.Popover`. */
12
+ headerEl: HTMLElement | null
13
+ opacityOpen: boolean
14
+ setOpacityOpen: (open: boolean) => void
15
+ }
16
+
17
+ export const LegendRowContext = createContext<LegendRowContextValue | null>(
18
+ null,
19
+ )
20
+
21
+ /** Nearest `Legend.Row` composition context, or `null` outside a row. */
22
+ export function useLegendRow(): LegendRowContextValue | null {
23
+ return useContext(LegendRowContext)
24
+ }
25
+
26
+ /** Per-group composition context provided by `Legend.Group` (the group id). */
27
+ export const LegendGroupContext = createContext<string | null>(null)
28
+
29
+ /** Nearest `Legend.Group` id, or `null` outside a group. */
30
+ export function useLegendGroupId(): string | null {
31
+ return useContext(LegendGroupContext)
32
+ }
@@ -0,0 +1,22 @@
1
+ import { createSvgIcon } from '@mui/material'
2
+
3
+ /**
4
+ * Indeterminate visibility — an outlined eye with an internal slash, shown on
5
+ * a group whose member layers are partially hidden ("mixed" state). Path data
6
+ * exported from the Figma source (`MUI_visibility_indeterminate`, CARTO PS Lib
7
+ * Documentation node 5877-16982); the glyph is centered at (184, 24) in the
8
+ * header frame and drawn on a 18px grid (75% of the 24px box), so it is
9
+ * re-centered into the standard viewBox and scaled by 4/3 — the resulting
10
+ * 22×15-unit eye matches the MUI `Visibility*` icons exactly.
11
+ */
12
+ export const VisibilityIndeterminateIcon = createSvgIcon(
13
+ <g transform='translate(12 12) scale(1.33333) translate(-184 -24)'>
14
+ <path
15
+ fillRule='evenodd'
16
+ clipRule='evenodd'
17
+ d='M184.901 29.5794L185.217 29.5415C185.803 29.4604 186.372 29.3216 186.918 29.1319L187 29.1007L187 29.1033L187.208 29.0255L187.536 28.8904C187.722 28.8088 187.906 28.721 188.086 28.6272L188.366 28.4747C188.381 28.4663 188.395 28.4578 188.41 28.4493C188.418 28.4451 188.425 28.4408 188.432 28.4365C188.455 28.4234 188.478 28.4102 188.5 28.3969C188.551 28.3668 188.601 28.3362 188.652 28.3052L188.912 28.1374C189.598 27.6788 190.217 27.1271 190.75 26.5002L190.751 26.4954C190.989 26.2164 191.21 25.9226 191.413 25.6152C191.488 25.5007 191.561 25.3843 191.631 25.2662L191.757 25.0445L191.878 24.8189L192.021 24.5306L192.15 24.2443L192.25 24C190.98 20.7776 187.885 18.4747 184.239 18.3782L184 18.375L183.716 18.3795L183.637 18.3823L183.373 18.3967L183.103 18.4198L182.838 18.4506L182.666 18.4749L182.426 18.5146C182.19 18.557 181.957 18.6087 181.728 18.6684L181.55 18.7175L181.271 18.8039L182.508 20.04L182.801 19.9854C183.194 19.9182 183.592 19.875 184 19.875C186.842 19.875 189.378 21.4725 190.615 24C190.172 24.915 189.55 25.7025 188.807 26.34L188.811 26.3446C188.71 26.4323 188.606 26.5172 188.5 26.5991C188.455 26.6337 188.41 26.6679 188.364 26.7015C188.108 26.8897 187.84 27.0611 187.562 27.2145C187.088 27.4755 186.585 27.6844 186.058 27.836L186.062 27.84L185.778 27.9125C185.707 27.9293 185.635 27.9452 185.563 27.9602C185.297 28.0173 185.025 28.06 184.75 28.0876V28.0866C184.503 28.1118 184.253 28.125 184 28.125C181.158 28.125 178.622 26.5275 177.385 24C177.91 22.9275 178.675 22.0425 179.582 21.3525L180.895 22.665L180.826 22.844C180.697 23.2065 180.625 23.595 180.625 24C180.625 25.86 182.14 27.375 184 27.375C184.258 27.375 184.509 27.3459 184.75 27.2915C185.462 27.129 186.09 26.7397 186.553 26.2048L179.79 19.4389C179.339 19.6833 178.912 19.9656 178.512 20.2817L178.517 20.2875L178.271 20.4884C177.704 20.9671 177.197 21.516 176.766 22.1227C176.404 22.6333 176.095 23.1838 175.845 23.7662L175.75 24C177.048 27.2925 180.25 29.625 184 29.625L184.285 29.6205L184.492 29.6116L184.628 29.6025L184.901 29.5794ZM182.133 23.9025L184.09 25.86L184.045 25.8703L184 25.875C182.965 25.875 182.125 25.035 182.125 24L182.133 23.9025Z'
18
+ />
19
+ <path d='M187.368 23.9925C187.375 22.1325 185.86 20.625 184 20.625C183.722 20.625 183.46 20.6625 183.198 20.73L184.75 22.2825L184.889 22.3507C185.252 22.5477 185.543 22.8625 185.71 23.2425L187.263 24.795L187.304 24.6011C187.342 24.4045 187.368 24.2006 187.368 23.9925Z' />
20
+ </g>,
21
+ 'VisibilityIndeterminate',
22
+ )
@@ -0,0 +1,39 @@
1
+ // Composition shell
2
+ export { LegendGroup } from './legend-group/legend-group'
3
+ export type { LegendGroupProps } from './legend-group/legend-group'
4
+ export { LegendRow } from './legend-row/legend-row'
5
+ export type { LegendRowProps } from './legend-row/legend-row'
6
+ export { LegendActions } from './legend-actions/legend-actions'
7
+ export type { LegendActionsProps } from './legend-actions/legend-actions'
8
+ export {
9
+ LegendRowContext,
10
+ LegendGroupContext,
11
+ useLegendRow,
12
+ useLegendGroupId,
13
+ } from './contexts'
14
+ export type { LegendRowContextValue } from './contexts'
15
+
16
+ // Store-connected actions & controls (context-driven)
17
+ export { LegendVisibilityToggle } from './legend-visibility-toggle/legend-visibility-toggle'
18
+ export { LegendRowMenu } from './legend-row-menu/legend-row-menu'
19
+ export { LegendZoomTo } from './legend-row-menu/legend-zoom-to'
20
+ export { LegendOpacity } from './legend-opacity/legend-opacity'
21
+ export { LegendConfigSelect } from './legend-config-select/legend-config-select'
22
+ export { LegendItem } from './legend-item/legend-item'
23
+
24
+ // Item renderers (pure UI)
25
+ export { LegendItemUI } from './legend-item/legend-item-ui'
26
+ export type { LegendItemUIProps } from './legend-item/legend-item-ui'
27
+ export {
28
+ LegendSwatch,
29
+ DEFAULT_SWATCH_SIZE,
30
+ } from './legend-swatch/legend-swatch'
31
+ export type { LegendSwatchProps } from './legend-swatch/legend-swatch'
32
+ export { LegendCategoryUI } from './legend-category/legend-category-ui'
33
+ export type { LegendCategoryUIProps } from './legend-category/legend-category-ui'
34
+ export { LegendRampUI } from './legend-ramp/legend-ramp-ui'
35
+ export type { LegendRampUIProps } from './legend-ramp/legend-ramp-ui'
36
+ export { LegendProportionUI } from './legend-proportion/legend-proportion-ui'
37
+ export type { LegendProportionUIProps } from './legend-proportion/legend-proportion-ui'
38
+ export { LegendIconUI } from './legend-icon/legend-icon-ui'
39
+ export type { LegendIconUIProps } from './legend-icon/legend-icon-ui'
@@ -0,0 +1,57 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ } from 'react'
7
+ import { Box } from '@mui/material'
8
+ import { useLegendRow } from '../contexts'
9
+ import { styles } from './styles'
10
+
11
+ export interface LegendActionsProps {
12
+ children?: ReactNode
13
+ }
14
+
15
+ /**
16
+ * Header actions slot for `Legend.Row` / `Legend.Group` (widgets-v2 `Actions`
17
+ * parity): the wrapper partitions its children by component identity and
18
+ * renders this slot inside the header, next to the collapse chevron, wrapped
19
+ * in the hover-fade group (`PsLegend-rowFade` in rows, `PsLegend-groupFade`
20
+ * in group headers — resolved from the nearest context).
21
+ *
22
+ * @experimental This API is new and may change in a future release.
23
+ */
24
+ export function LegendActions({ children }: LegendActionsProps) {
25
+ const inRow = useLegendRow() !== null
26
+ return (
27
+ <Box
28
+ className={inRow ? 'PsLegend-rowFade' : 'PsLegend-groupFade'}
29
+ sx={styles.fadeGroup}
30
+ >
31
+ {children}
32
+ </Box>
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Splits a wrapper's children by component identity: `<Legend.Actions>`
38
+ * elements belong in the header, everything else in the collapsible body.
39
+ * Identity comparison works because consumers import the slot from the same
40
+ * module (same pattern as widgets-v2 `Wrapper`).
41
+ */
42
+ export function partitionLegendChildren(children: ReactNode): {
43
+ actions: ReactNode[]
44
+ body: ReactNode[]
45
+ } {
46
+ const actions: ReactNode[] = []
47
+ const body: ReactNode[] = []
48
+ for (const child of Children.toArray(children)) {
49
+ if (isSlot(child, LegendActions)) actions.push(child)
50
+ else body.push(child)
51
+ }
52
+ return { actions, body }
53
+ }
54
+
55
+ function isSlot(node: ReactNode, type: unknown): node is ReactElement {
56
+ return isValidElement(node) && node.type === type
57
+ }
@@ -0,0 +1,9 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+
3
+ export const styles = {
4
+ fadeGroup: {
5
+ display: 'flex',
6
+ alignItems: 'center',
7
+ gap: 0.25,
8
+ },
9
+ } satisfies Record<string, SxProps<Theme>>