@carto/ps-react-ui 4.14.1 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/chat.js +494 -466
  2. package/dist/chat.js.map +1 -1
  3. package/dist/components.js +837 -774
  4. package/dist/components.js.map +1 -1
  5. package/dist/legend/stores.js +20 -16
  6. package/dist/legend-store-registry-CVYzhR1-.js +464 -0
  7. package/dist/legend-store-registry-CVYzhR1-.js.map +1 -0
  8. package/dist/legend.js +1478 -794
  9. package/dist/legend.js.map +1 -1
  10. package/dist/markdown-DdqANCiN.js +102 -0
  11. package/dist/markdown-DdqANCiN.js.map +1 -0
  12. package/dist/markdown-content-Dk2DSgbf.js +10287 -0
  13. package/dist/markdown-content-Dk2DSgbf.js.map +1 -0
  14. package/dist/types/chat/bubbles/chat-agent-message-markdown.d.ts +12 -0
  15. package/dist/types/chat/bubbles/chat-agent-message.d.ts +9 -0
  16. package/dist/types/chat/bubbles/index.d.ts +1 -0
  17. package/dist/types/chat/index.d.ts +2 -1
  18. package/dist/types/chat/types.d.ts +9 -0
  19. package/dist/types/components/index.d.ts +2 -1
  20. package/dist/types/components/lasso-tool/message.d.ts +7 -0
  21. package/dist/types/components/lasso-tool/types.d.ts +17 -2
  22. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +2 -9
  23. package/dist/types/legend/components/a11y.d.ts +6 -0
  24. package/dist/types/legend/components/contexts.d.ts +22 -4
  25. package/dist/types/legend/components/index.d.ts +2 -2
  26. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +4 -3
  27. package/dist/types/legend/components/legend-group/legend-group.d.ts +2 -11
  28. package/dist/types/legend/components/legend-group/styles.d.ts +40 -2
  29. package/dist/types/legend/components/legend-group-menu/legend-group-menu-items.d.ts +28 -0
  30. package/dist/types/legend/components/legend-group-menu/legend-group-menu.d.ts +11 -0
  31. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +7 -17
  32. package/dist/types/legend/components/legend-opacity/styles.d.ts +11 -6
  33. package/dist/types/legend/components/legend-overflow-menu/legend-overflow-menu.d.ts +12 -0
  34. package/dist/types/legend/components/legend-panel-menu/legend-panel-menu.d.ts +11 -0
  35. package/dist/types/legend/components/legend-row/legend-row.d.ts +4 -11
  36. package/dist/types/legend/components/legend-row/styles.d.ts +36 -1
  37. package/dist/types/legend/components/legend-row-menu/legend-row-menu-items.d.ts +12 -0
  38. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +4 -10
  39. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +3 -8
  40. package/dist/types/legend/components/legend-sortable/contexts.d.ts +7 -0
  41. package/dist/types/legend/components/legend-sortable/legend-sortable.d.ts +11 -0
  42. package/dist/types/legend/components/legend-sortable/sortable-entity.d.ts +27 -0
  43. package/dist/types/legend/components/legend-sortable/sortable-ids.d.ts +6 -0
  44. package/dist/types/legend/components/legend-sortable/styles.d.ts +54 -0
  45. package/dist/types/legend/components/legend-visible-layers/legend-visible-layers.d.ts +7 -0
  46. package/dist/types/legend/components/legend-visible-layers/styles.d.ts +48 -0
  47. package/dist/types/legend/components/run-async-menu-action.d.ts +7 -0
  48. package/dist/types/legend/index.d.ts +53 -6
  49. package/dist/types/legend/provider/labels.d.ts +21 -0
  50. package/dist/types/legend/stores/index.d.ts +1 -1
  51. package/dist/types/legend/stores/selectors.d.ts +31 -0
  52. package/dist/types/legend/stores/types.d.ts +11 -0
  53. package/dist/types/widgets-v2/markdown/markdown-content.d.ts +7 -1
  54. package/dist/widgets-v2/markdown.js +9 -8
  55. package/dist/widgets-v2/markdown.js.map +1 -1
  56. package/dist/widgets-v2.js +23 -22
  57. package/dist/widgets-v2.js.map +1 -1
  58. package/package.json +5 -4
  59. package/src/chat/bubbles/chat-agent-message-markdown.test.tsx +38 -0
  60. package/src/chat/bubbles/chat-agent-message-markdown.tsx +34 -0
  61. package/src/chat/bubbles/chat-agent-message.test.tsx +12 -0
  62. package/src/chat/bubbles/chat-agent-message.tsx +21 -1
  63. package/src/chat/bubbles/index.ts +1 -0
  64. package/src/chat/index.ts +2 -0
  65. package/src/chat/types.ts +10 -0
  66. package/src/components/index.ts +2 -0
  67. package/src/components/lasso-tool/message.tsx +36 -0
  68. package/src/components/lasso-tool/types.ts +20 -1
  69. package/src/components/responsive-drawer/responsive-drawer.test.tsx +138 -0
  70. package/src/components/responsive-drawer/responsive-drawer.tsx +77 -51
  71. package/src/legend/components/a11y.ts +14 -0
  72. package/src/legend/components/contexts.ts +29 -4
  73. package/src/legend/components/index.ts +3 -4
  74. package/src/legend/components/legend-actions/legend-actions.tsx +4 -3
  75. package/src/legend/components/legend-group/legend-group.test.tsx +166 -9
  76. package/src/legend/components/legend-group/legend-group.tsx +88 -49
  77. package/src/legend/components/legend-group/styles.ts +59 -2
  78. package/src/legend/components/legend-group-menu/legend-group-menu-items.tsx +132 -0
  79. package/src/legend/components/legend-group-menu/legend-group-menu.tsx +25 -0
  80. package/src/legend/components/legend-opacity/legend-opacity.tsx +43 -70
  81. package/src/legend/components/legend-opacity/styles.ts +14 -9
  82. package/src/legend/components/legend-overflow-menu/legend-overflow-menu.tsx +84 -0
  83. package/src/legend/components/legend-panel-menu/legend-panel-menu.tsx +23 -0
  84. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +2 -1
  85. package/src/legend/components/legend-row/legend-row.test.tsx +304 -85
  86. package/src/legend/components/legend-row/legend-row.tsx +73 -50
  87. package/src/legend/components/legend-row/styles.ts +54 -1
  88. package/src/legend/components/legend-row-menu/legend-row-menu-items.tsx +57 -0
  89. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +12 -50
  90. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +25 -17
  91. package/src/legend/components/legend-sortable/contexts.ts +12 -0
  92. package/src/legend/components/legend-sortable/legend-sortable.test.tsx +144 -0
  93. package/src/legend/components/legend-sortable/legend-sortable.tsx +195 -0
  94. package/src/legend/components/legend-sortable/sortable-entity.tsx +91 -0
  95. package/src/legend/components/legend-sortable/sortable-ids.ts +17 -0
  96. package/src/legend/components/legend-sortable/styles.ts +56 -0
  97. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +14 -8
  98. package/src/legend/components/legend-visible-layers/legend-visible-layers.test.tsx +86 -0
  99. package/src/legend/components/legend-visible-layers/legend-visible-layers.tsx +135 -0
  100. package/src/legend/components/legend-visible-layers/styles.ts +60 -0
  101. package/src/legend/components/run-async-menu-action.ts +20 -0
  102. package/src/legend/index.ts +59 -4
  103. package/src/legend/provider/labels.ts +43 -1
  104. package/src/legend/stores/index.ts +4 -0
  105. package/src/legend/stores/legend-store-registry.ts +60 -0
  106. package/src/legend/stores/legend-store.test.ts +116 -0
  107. package/src/legend/stores/selectors.ts +64 -2
  108. package/src/legend/stores/types.ts +11 -0
  109. package/src/widgets/actions/download/exports.test.tsx +14 -1
  110. package/src/widgets-v2/markdown/markdown-content.test.tsx +10 -0
  111. package/src/widgets-v2/markdown/markdown-content.tsx +9 -0
  112. package/src/widgets-v2/wrapper/widget-wrapper.tsx +4 -1
  113. package/dist/markdown-BD1jcknS.js +0 -8326
  114. package/dist/markdown-BD1jcknS.js.map +0 -1
  115. package/dist/selectors-DwHj6mE2.js +0 -388
  116. package/dist/selectors-DwHj6mE2.js.map +0 -1
@@ -1,11 +1,31 @@
1
+ import { forwardRef } from 'react'
1
2
  import { Box, styled, type BoxProps } from '@mui/material'
3
+ import type { ChatAgentMessageProps } from '../types'
2
4
  import { styles } from './styles'
3
5
 
4
6
  const InnerBox = ({ className, ...props }: BoxProps) => (
5
7
  <Box className={`PsChat--agent-message ${className ?? ''}`} {...props} />
6
8
  )
7
9
 
8
- export const ChatAgentMessage = styled(InnerBox)(({ theme }) =>
10
+ const StyledAgentMessage = styled(InnerBox)(({ theme }) =>
9
11
  styles.agentMessageContainer(theme),
10
12
  )
13
+
14
+ /**
15
+ * Agent reply bubble. A styles container that positions the reply and its
16
+ * composed pieces (text, loaders, tool traces, …); it renders `children`
17
+ * verbatim and forwards its `ref` to the underlying element. For Markdown
18
+ * replies, compose `ChatAgentMessageMarkdown` instead of teaching this
19
+ * container about the markdown engine.
20
+ */
21
+ export const ChatAgentMessage = forwardRef<
22
+ HTMLDivElement,
23
+ ChatAgentMessageProps
24
+ >(function ChatAgentMessage({ children, ...props }, ref) {
25
+ return (
26
+ <StyledAgentMessage ref={ref} {...props}>
27
+ {children}
28
+ </StyledAgentMessage>
29
+ )
30
+ })
11
31
  ChatAgentMessage.displayName = 'ChatAgentMessage'
@@ -1,4 +1,5 @@
1
1
  export { ChatUserMessage } from './chat-user-message'
2
2
  export { ChatAgentMessage } from './chat-agent-message'
3
+ export { ChatAgentMessageMarkdown } from './chat-agent-message-markdown'
3
4
  export { ChatErrorMessage } from './chat-error-message'
4
5
  export { ChatSuggestionButton } from './chat-suggestion-button'
package/src/chat/index.ts CHANGED
@@ -4,6 +4,7 @@ export type {
4
4
  ChatErrorAction,
5
5
  ChatUserMessageProps,
6
6
  ChatAgentMessageProps,
7
+ ChatAgentMessageMarkdownProps,
7
8
  ChatErrorMessageProps,
8
9
  ChatSuggestionButtonProps,
9
10
  ChatThinkingProps,
@@ -37,6 +38,7 @@ export { useTypewriter } from './use-typewriter'
37
38
  // Messages
38
39
  export { ChatUserMessage } from './bubbles/chat-user-message'
39
40
  export { ChatAgentMessage } from './bubbles/chat-agent-message'
41
+ export { ChatAgentMessageMarkdown } from './bubbles/chat-agent-message-markdown'
40
42
  export { ChatErrorMessage } from './bubbles/chat-error-message'
41
43
  export { ChatSuggestionButton } from './bubbles/chat-suggestion-button'
42
44
  export { ChatMessageOverflow } from './bubbles/styles'
package/src/chat/types.ts CHANGED
@@ -25,6 +25,16 @@ export interface ChatAgentMessageProps extends ChatSxProps {
25
25
  children: ReactNode
26
26
  }
27
27
 
28
+ export interface ChatAgentMessageMarkdownProps extends ChatSxProps {
29
+ /**
30
+ * Markdown source — rendered as GitHub-Flavored Markdown (tables, task lists,
31
+ * strikethrough) through the library's sanitized renderer, inside a
32
+ * `ChatAgentMessage`. Raw HTML is skipped and images are stripped, since the
33
+ * content is model output.
34
+ */
35
+ children: string
36
+ }
37
+
28
38
  export interface ChatErrorMessageProps extends ChatSxProps {
29
39
  errors: string[]
30
40
  icon?: ReactNode
@@ -28,11 +28,13 @@ export type {
28
28
  LassoGeometryType,
29
29
  LassoLayerFiltersUIProps,
30
30
  LassoLayerFilterItem,
31
+ LassoMessageUIProps,
31
32
  } from './lasso-tool/types'
32
33
  export { LassoToolsUI } from './lasso-tool/lasso-tool'
33
34
  export { LassoToolsInlineUI } from './lasso-tool/lasso-tool-inline'
34
35
  export { LassoGeometryToolbarUI } from './lasso-tool/geometry-toolbar'
35
36
  export { LassoLayerFiltersUI } from './lasso-tool/layer-filters'
37
+ export { LassoMessageUI } from './lasso-tool/message'
36
38
  export {
37
39
  DEFAULT_LASSO_TOOLS_MODES_MAPPING,
38
40
  LASSO_TOOLS_LABELS,
@@ -0,0 +1,36 @@
1
+ import { Alert, AlertTitle, Snackbar } from '@mui/material'
2
+ import type { LassoMessageUIProps } from './types'
3
+
4
+ /**
5
+ * Presentational counterpart of `LassoTools.Message` (`@carto/ps-react-maps`),
6
+ * positioned by that render-props component. Auto-hides after 5 seconds via
7
+ * MUI `Snackbar` (calls `onDismiss`, which removes the message from the store).
8
+ */
9
+ export function LassoMessageUI({
10
+ message,
11
+ onDismiss,
12
+ AlertProps,
13
+ SnackbarProps,
14
+ }: LassoMessageUIProps) {
15
+ return (
16
+ <Snackbar
17
+ open
18
+ autoHideDuration={5000}
19
+ // 'clickaway' is ignored so map interactions don't dismiss the message.
20
+ onClose={(_, reason) => reason !== 'clickaway' && onDismiss()}
21
+ // Stay in flow — the maps-side `LassoTools.Message` owns positioning.
22
+ sx={{ position: 'static', transform: 'none' }}
23
+ {...SnackbarProps}
24
+ >
25
+ <Alert
26
+ severity={message.severity ?? 'info'}
27
+ action={message.action}
28
+ onClose={onDismiss}
29
+ {...AlertProps}
30
+ >
31
+ <AlertTitle>{message.title}</AlertTitle>
32
+ {message.content}
33
+ </Alert>
34
+ </Snackbar>
35
+ )
36
+ }
@@ -1,11 +1,14 @@
1
1
  import type {
2
+ AlertColor,
3
+ AlertProps,
2
4
  Box,
3
5
  ChipProps,
4
6
  DialogProps,
5
7
  Paper,
8
+ SnackbarProps,
6
9
  TooltipProps,
7
10
  } from '@mui/material'
8
- import type { ComponentProps, ComponentType } from 'react'
11
+ import type { ComponentProps, ComponentType, ReactNode } from 'react'
9
12
  import type {
10
13
  LassoToolsMode,
11
14
  LassoToolsModes,
@@ -153,6 +156,22 @@ export interface LassoGeometryToolbarUIProps {
153
156
  PaperProps?: ComponentProps<typeof Paper>
154
157
  }
155
158
 
159
+ export interface LassoMessageUIProps {
160
+ /** Message from the lasso tools store (`LassoToolsMessage` in `@carto/ps-react-maps`). */
161
+ message: {
162
+ id: string
163
+ title: string
164
+ /** Body rendered under the title. */
165
+ content?: ReactNode
166
+ /** @default 'info' */
167
+ severity?: AlertColor
168
+ action?: ReactNode
169
+ }
170
+ onDismiss: () => void
171
+ AlertProps?: Omit<AlertProps, 'severity' | 'onClose' | 'children' | 'action'>
172
+ SnackbarProps?: Omit<SnackbarProps, 'open' | 'onClose' | 'children'>
173
+ }
174
+
156
175
  export interface LassoLayerFilterItem {
157
176
  id: string
158
177
  label: string
@@ -154,6 +154,144 @@ describe('ResponsiveDrawer', () => {
154
154
  ).not.toThrow()
155
155
  })
156
156
 
157
+ test('defaults to portaling into document.body when container is omitted', () => {
158
+ render(
159
+ <ThemeProvider theme={theme}>
160
+ <ResponsiveDrawer ref={anchor} {...defaultProps} />
161
+ </ThemeProvider>,
162
+ )
163
+ const content = screen.getByTestId('drawer-content')
164
+ const modalRoot = content.closest('.MuiModal-root')
165
+ expect(modalRoot?.parentElement).toBe(document.body)
166
+ })
167
+
168
+ test('portals into the given container instead of document.body', () => {
169
+ const customContainer = document.createElement('div')
170
+ document.body.appendChild(customContainer)
171
+ try {
172
+ render(
173
+ <ThemeProvider theme={theme}>
174
+ <ResponsiveDrawer
175
+ ref={anchor}
176
+ {...defaultProps}
177
+ container={customContainer}
178
+ />
179
+ </ThemeProvider>,
180
+ )
181
+ const content = screen.getByTestId('drawer-content')
182
+ const modalRoot = content.closest('.MuiModal-root')
183
+ expect(modalRoot?.parentElement).toBe(customContainer)
184
+ } finally {
185
+ customContainer.remove()
186
+ }
187
+ })
188
+
189
+ test('caps the popover paper height at 90% of a measured container element', () => {
190
+ const customContainer = document.createElement('div')
191
+ Object.defineProperty(customContainer, 'clientHeight', {
192
+ value: 500,
193
+ configurable: true,
194
+ })
195
+ document.body.appendChild(customContainer)
196
+ try {
197
+ render(
198
+ <ThemeProvider theme={theme}>
199
+ <ResponsiveDrawer
200
+ ref={anchor}
201
+ {...defaultProps}
202
+ container={customContainer}
203
+ />
204
+ </ThemeProvider>,
205
+ )
206
+ const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
207
+ expect(paper.style.maxHeight).toBe('450px')
208
+ expect(paper.style.overflow).toBe('hidden')
209
+ expect(paper.style.display).toBe('flex')
210
+ expect(paper.style.flexDirection).toBe('column')
211
+
212
+ const content = screen.getByTestId('drawer-content')
213
+ const wrapper = content.parentElement!
214
+ expect(wrapper.style.display).toBe('flex')
215
+ expect(wrapper.style.flexDirection).toBe('column')
216
+ expect(wrapper.style.flex).toBe('1 1 0%')
217
+ expect(wrapper.style.minHeight).toBe('0')
218
+ expect(wrapper.style.maxHeight).toBe('100%')
219
+ expect(wrapper.style.overflow).toBe('hidden')
220
+ } finally {
221
+ customContainer.remove()
222
+ }
223
+ })
224
+
225
+ test('leaves paper maxHeight unset when no container is given', () => {
226
+ render(
227
+ <ThemeProvider theme={theme}>
228
+ <ResponsiveDrawer ref={anchor} {...defaultProps} />
229
+ </ThemeProvider>,
230
+ )
231
+ const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
232
+ expect(paper.style.maxHeight).toBe('')
233
+ expect(paper.style.display).toBe('')
234
+
235
+ const content = screen.getByTestId('drawer-content')
236
+ expect(content.parentElement!.style.display).toBe('')
237
+ })
238
+
239
+ test('leaves paper maxHeight unset when the container reports height 0', () => {
240
+ const customContainer = document.createElement('div')
241
+ Object.defineProperty(customContainer, 'clientHeight', {
242
+ value: 0,
243
+ configurable: true,
244
+ })
245
+ document.body.appendChild(customContainer)
246
+ try {
247
+ render(
248
+ <ThemeProvider theme={theme}>
249
+ <ResponsiveDrawer
250
+ ref={anchor}
251
+ {...defaultProps}
252
+ container={customContainer}
253
+ />
254
+ </ThemeProvider>,
255
+ )
256
+ const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
257
+ expect(paper.style.maxHeight).toBe('')
258
+ } finally {
259
+ customContainer.remove()
260
+ }
261
+ })
262
+
263
+ test('preserves slotProps.root when capping paper height under modal=true', () => {
264
+ const customContainer = document.createElement('div')
265
+ Object.defineProperty(customContainer, 'clientHeight', {
266
+ value: 500,
267
+ configurable: true,
268
+ })
269
+ document.body.appendChild(customContainer)
270
+ try {
271
+ render(
272
+ <ThemeProvider theme={theme}>
273
+ <ResponsiveDrawer
274
+ ref={anchor}
275
+ {...defaultProps}
276
+ container={customContainer}
277
+ slotProps={
278
+ {
279
+ root: { 'data-testid': 'drawer-root-slot' },
280
+ paper: { 'data-testid': 'drawer-paper-slot' },
281
+ } as never
282
+ }
283
+ />
284
+ </ThemeProvider>,
285
+ )
286
+ expect(screen.getByTestId('drawer-root-slot')).toBeDefined()
287
+ expect(screen.getByTestId('drawer-paper-slot')).toBeDefined()
288
+ const paper = document.querySelector<HTMLElement>('.MuiPopover-paper')!
289
+ expect(paper.style.maxHeight).toBe('450px')
290
+ } finally {
291
+ customContainer.remove()
292
+ }
293
+ })
294
+
157
295
  test('respects sx prop on the Popover', () => {
158
296
  expect(() =>
159
297
  render(
@@ -5,7 +5,13 @@ import {
5
5
  type PopoverOrigin,
6
6
  type PopoverProps,
7
7
  } from '@mui/material'
8
- import { useCallback, useRef, type CSSProperties, type ReactNode } from 'react'
8
+ import {
9
+ useCallback,
10
+ useRef,
11
+ useSyncExternalStore,
12
+ type CSSProperties,
13
+ type ReactNode,
14
+ } from 'react'
9
15
 
10
16
  type ResponsiveDrawerPosition =
11
17
  | 'top-left'
@@ -17,14 +23,7 @@ type ResponsiveDrawerPosition =
17
23
  export type ResponsiveDrawerPlacement = 'side' | 'overlay'
18
24
 
19
25
  /**
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.
26
+ * Popover anchor/transform origins. `'overlay'` pins corner-to-corner on the anchor.
28
27
  */
29
28
  export function resolveOrigins(
30
29
  position: ResponsiveDrawerPosition,
@@ -37,8 +36,6 @@ export function resolveOrigins(
37
36
  return {
38
37
  anchorOrigin: {
39
38
  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
39
  horizontal: overlay
43
40
  ? isRight
44
41
  ? 'right'
@@ -69,11 +66,12 @@ export function ResponsiveDrawer<
69
66
  position,
70
67
  sx,
71
68
  slotProps,
69
+ container,
72
70
  onChangeCollapsed,
73
71
  modal = true,
74
72
  placement = 'side',
75
73
  }: Required<Pick<T, 'collapsed' | 'onChangeCollapsed' | 'position'>> &
76
- Pick<PopoverProps, 'slotProps' | 'sx'> & {
74
+ Pick<PopoverProps, 'slotProps' | 'sx' | 'container'> & {
77
75
  ref: HTMLElement | null
78
76
  isMobile: boolean
79
77
  children: ReactNode
@@ -92,18 +90,7 @@ export function ResponsiveDrawer<
92
90
  */
93
91
  placement?: ResponsiveDrawerPlacement
94
92
  }) {
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`.
93
+ // Popover position is frozen on open re-measure on content resize (callback ref: portal mounts after open).
107
94
  const popoverActions = useRef<PopoverActions>(null)
108
95
  const observerRef = useRef<ResizeObserver | null>(null)
109
96
  const observeContent = useCallback((el: HTMLDivElement | null) => {
@@ -118,8 +105,27 @@ export function ResponsiveDrawer<
118
105
  }
119
106
  }, [])
120
107
 
108
+ // Popover Paper is auto-height — `%` max-height needs a measured px cap from `container`.
109
+ const measurableContainer =
110
+ container && typeof container !== 'function' ? container : null
111
+ const subscribe = useCallback(
112
+ (onStoreChange: () => void) => {
113
+ if (!measurableContainer || typeof ResizeObserver === 'undefined') {
114
+ return () => undefined
115
+ }
116
+ const observer = new ResizeObserver(onStoreChange)
117
+ observer.observe(measurableContainer)
118
+ return () => observer.disconnect()
119
+ },
120
+ [measurableContainer],
121
+ )
122
+ const getSnapshot = useCallback(
123
+ () => measurableContainer?.clientHeight,
124
+ [measurableContainer],
125
+ )
126
+ const containerHeight = useSyncExternalStore(subscribe, getSnapshot)
127
+
121
128
  if (isMobile) {
122
- // MUI calls `onClose(event, reason)`; adapt it to the boolean contract.
123
129
  return (
124
130
  <Drawer
125
131
  anchor='bottom'
@@ -133,41 +139,60 @@ export function ResponsiveDrawer<
133
139
 
134
140
  const { anchorOrigin, transformOrigin } = resolveOrigins(position, placement)
135
141
 
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
142
  const objectSlotProps = slotProps as
142
143
  | { root?: Record<string, unknown>; paper?: { style?: CSSProperties } }
143
144
  | 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
- },
145
+ // Capped Paper: disable outer scroll + flex so inner scroll regions can shrink.
146
+ const paperMaxHeightStyle: CSSProperties | undefined =
147
+ containerHeight != null && containerHeight > 0
148
+ ? {
149
+ maxHeight: containerHeight * 0.9,
150
+ overflow: 'hidden',
151
+ display: 'flex',
152
+ flexDirection: 'column',
153
+ }
154
+ : undefined
155
+ const contentWrapperStyle: CSSProperties | undefined = paperMaxHeightStyle
156
+ ? {
157
+ display: 'flex',
158
+ flexDirection: 'column',
159
+ flex: 1,
160
+ minHeight: 0,
161
+ maxHeight: '100%',
162
+ overflow: 'hidden',
163
+ width: '100%',
158
164
  }
165
+ : undefined
166
+ const mergedSlotProps: PopoverProps['slotProps'] =
167
+ modal && !paperMaxHeightStyle
168
+ ? slotProps
169
+ : {
170
+ ...(typeof slotProps === 'object' && slotProps != null
171
+ ? slotProps
172
+ : {}),
173
+ ...(!modal && {
174
+ root: {
175
+ style: { pointerEvents: 'none' },
176
+ ...objectSlotProps?.root,
177
+ },
178
+ }),
179
+ paper: {
180
+ ...objectSlotProps?.paper,
181
+ style: {
182
+ ...(!modal && { pointerEvents: 'auto' }),
183
+ ...paperMaxHeightStyle,
184
+ ...objectSlotProps?.paper?.style,
185
+ },
186
+ },
187
+ }
159
188
 
160
189
  return (
161
190
  <Popover
162
191
  action={popoverActions}
163
192
  sx={sx}
193
+ container={container}
164
194
  anchorEl={ref}
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
195
  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
196
  onClose={modal ? () => onChangeCollapsed(true) : undefined}
172
197
  hideBackdrop={!modal}
173
198
  disableScrollLock={!modal}
@@ -179,8 +204,9 @@ export function ResponsiveDrawer<
179
204
  transformOrigin={transformOrigin}
180
205
  slotProps={mergedSlotProps}
181
206
  >
182
- {/* Observed for content-resize → keeps the Popover pinned (see above). */}
183
- <div ref={observeContent}>{children}</div>
207
+ <div ref={observeContent} style={contentWrapperStyle}>
208
+ {children}
209
+ </div>
184
210
  </Popover>
185
211
  )
186
212
  }
@@ -0,0 +1,14 @@
1
+ import type { KeyboardEvent } from 'react'
2
+
3
+ /**
4
+ * Enter/Space activate a non-native `role='button'` element (the accordion
5
+ * title areas of `Legend.Row` / `Legend.Group`), like a real button.
6
+ */
7
+ export function accordionKeyDown(toggle: () => void) {
8
+ return (event: KeyboardEvent<HTMLElement>) => {
9
+ if (event.key === 'Enter' || event.key === ' ') {
10
+ event.preventDefault()
11
+ toggle()
12
+ }
13
+ }
14
+ }
@@ -3,13 +3,11 @@ import { createContext, useContext } from 'react'
3
3
  /**
4
4
  * Per-row composition context provided by `Legend.Row`. Actions placed inside
5
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).
6
+ * prop, and the opacity compound (`Legend.Opacity.Item` / `.Inline`) shares
7
+ * its open state through it.
8
8
  */
9
9
  export interface LegendRowContextValue {
10
10
  layerId: string
11
- /** The row's sticky header element — anchor for `Legend.Opacity.Popover`. */
12
- headerEl: HTMLElement | null
13
11
  opacityOpen: boolean
14
12
  setOpacityOpen: (open: boolean) => void
15
13
  }
@@ -30,3 +28,30 @@ export const LegendGroupContext = createContext<string | null>(null)
30
28
  export function useLegendGroupId(): string | null {
31
29
  return useContext(LegendGroupContext)
32
30
  }
31
+
32
+ /**
33
+ * Overflow-menu controller for `Legend.RowMenu` / `Legend.GroupMenu` /
34
+ * `Legend.PanelMenu`. Async items (e.g. `Legend.ZoomTo`) use `setBusy` to keep
35
+ * the menu open and show in-item loading feedback, then `close()` when the
36
+ * handler settles. Prefer `runAsyncMenuAction` over calling these by hand.
37
+ */
38
+ export interface LegendMenuController {
39
+ /** Reactive busy flag for UI (spinner / disabled). */
40
+ busy: boolean
41
+ /**
42
+ * Synchronous busy check (ref-backed) — use this to gate clicks so a second
43
+ * press before React re-renders cannot start another async action.
44
+ */
45
+ isBusy: () => boolean
46
+ setBusy: (busy: boolean) => void
47
+ close: () => void
48
+ }
49
+
50
+ export const LegendMenuContext = createContext<LegendMenuController | null>(
51
+ null,
52
+ )
53
+
54
+ /** Nearest overflow-menu controller, or `null` outside a row/group menu. */
55
+ export function useLegendMenu(): LegendMenuController | null {
56
+ return useContext(LegendMenuContext)
57
+ }
@@ -1,4 +1,3 @@
1
- // Composition shell
2
1
  export { LegendGroup } from './legend-group/legend-group'
3
2
  export type { LegendGroupProps } from './legend-group/legend-group'
4
3
  export { LegendRow } from './legend-row/legend-row'
@@ -8,12 +7,13 @@ export type { LegendActionsProps } from './legend-actions/legend-actions'
8
7
  export {
9
8
  LegendRowContext,
10
9
  LegendGroupContext,
10
+ LegendMenuContext,
11
11
  useLegendRow,
12
12
  useLegendGroupId,
13
+ useLegendMenu,
13
14
  } from './contexts'
14
- export type { LegendRowContextValue } from './contexts'
15
+ export type { LegendRowContextValue, LegendMenuController } from './contexts'
15
16
 
16
- // Store-connected actions & controls (context-driven)
17
17
  export { LegendVisibilityToggle } from './legend-visibility-toggle/legend-visibility-toggle'
18
18
  export { LegendRowMenu } from './legend-row-menu/legend-row-menu'
19
19
  export { LegendZoomTo } from './legend-row-menu/legend-zoom-to'
@@ -21,7 +21,6 @@ export { LegendOpacity } from './legend-opacity/legend-opacity'
21
21
  export { LegendConfigSelect } from './legend-config-select/legend-config-select'
22
22
  export { LegendItem } from './legend-item/legend-item'
23
23
 
24
- // Item renderers (pure UI)
25
24
  export { LegendItemUI } from './legend-item/legend-item-ui'
26
25
  export type { LegendItemUIProps } from './legend-item/legend-item-ui'
27
26
  export {
@@ -15,9 +15,10 @@ export interface LegendActionsProps {
15
15
  /**
16
16
  * Header actions slot for `Legend.Row` / `Legend.Group` (widgets-v2 `Actions`
17
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).
18
+ * renders this slot inside the header, wrapped in the hover-fade group
19
+ * (`PsLegend-rowFade` in rows, `PsLegend-groupFade` in group headers —
20
+ * resolved from the nearest context). Compose the ⋮ menu last so it sits
21
+ * rightmost, per design.
21
22
  *
22
23
  * @experimental This API is new and may change in a future release.
23
24
  */