@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.
- package/dist/components.js +706 -658
- package/dist/components.js.map +1 -1
- package/dist/legend/stores.js +20 -0
- package/dist/legend/stores.js.map +1 -0
- package/dist/legend.js +1536 -0
- package/dist/legend.js.map +1 -0
- package/dist/selectors-DwHj6mE2.js +388 -0
- package/dist/selectors-DwHj6mE2.js.map +1 -0
- package/dist/types/components/index.d.ts +1 -0
- package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
- package/dist/types/legend/components/contexts.d.ts +20 -0
- package/dist/types/legend/components/icons.d.ts +12 -0
- package/dist/types/legend/components/index.d.ts +26 -0
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
- package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
- package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
- package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
- package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
- package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
- package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
- package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
- package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
- package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
- package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
- package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
- package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
- package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
- package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
- package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
- package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
- package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
- package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
- package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
- package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
- package/dist/types/legend/index.d.ts +88 -0
- package/dist/types/legend/provider/index.d.ts +6 -0
- package/dist/types/legend/provider/labels.d.ts +27 -0
- package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
- package/dist/types/legend/provider/legend-provider.d.ts +38 -0
- package/dist/types/legend/stores/index.d.ts +5 -0
- package/dist/types/legend/stores/legend-context.d.ts +4 -0
- package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
- package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
- package/dist/types/legend/stores/selectors.d.ts +33 -0
- package/dist/types/legend/stores/types.d.ts +275 -0
- package/dist/widgets-v2/bar.js +7 -3
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/histogram.js +9 -4
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/package.json +11 -3
- package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
- package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
- package/src/components/index.ts +2 -0
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
- package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
- package/src/legend/components/contexts.ts +32 -0
- package/src/legend/components/icons.tsx +22 -0
- package/src/legend/components/index.ts +39 -0
- package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
- package/src/legend/components/legend-actions/styles.ts +9 -0
- package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
- package/src/legend/components/legend-category/styles.ts +61 -0
- package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
- package/src/legend/components/legend-config-select/styles.ts +48 -0
- package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
- package/src/legend/components/legend-group/legend-group.tsx +88 -0
- package/src/legend/components/legend-group/styles.ts +82 -0
- package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
- package/src/legend/components/legend-icon/styles.ts +56 -0
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
- package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
- package/src/legend/components/legend-item/legend-item.tsx +34 -0
- package/src/legend/components/legend-item/styles.ts +11 -0
- package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
- package/src/legend/components/legend-opacity/styles.ts +28 -0
- package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
- package/src/legend/components/legend-proportion/styles.ts +43 -0
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
- package/src/legend/components/legend-ramp/styles.ts +62 -0
- package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
- package/src/legend/components/legend-row/legend-row.tsx +129 -0
- package/src/legend/components/legend-row/styles.ts +111 -0
- package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
- package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
- package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
- package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
- package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
- package/src/legend/index.ts +121 -0
- package/src/legend/provider/index.ts +6 -0
- package/src/legend/provider/labels.ts +59 -0
- package/src/legend/provider/legend-config-context.ts +15 -0
- package/src/legend/provider/legend-provider.tsx +102 -0
- package/src/legend/stores/index.ts +46 -0
- package/src/legend/stores/legend-context.ts +17 -0
- package/src/legend/stores/legend-store-registry.ts +639 -0
- package/src/legend/stores/legend-store.test.ts +457 -0
- package/src/legend/stores/selectors.ts +65 -0
- package/src/legend/stores/types.ts +347 -0
- package/src/widgets-v2/bar/options.ts +4 -0
- 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={
|
|
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={
|
|
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={
|
|
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={
|
|
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.
|
|
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={
|
|
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('
|
|
114
|
-
|
|
132
|
+
test('modal (default): renders an (invisible) backdrop', () => {
|
|
133
|
+
render(
|
|
115
134
|
<ThemeProvider theme={theme}>
|
|
116
|
-
<ResponsiveDrawer ref={
|
|
135
|
+
<ResponsiveDrawer ref={anchor} {...defaultProps} />
|
|
117
136
|
</ThemeProvider>,
|
|
118
137
|
)
|
|
119
|
-
// The
|
|
120
|
-
|
|
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={
|
|
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={
|
|
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 {
|
|
2
|
-
|
|
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:
|
|
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
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|