@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
@@ -0,0 +1,91 @@
1
+ import { Box, Typography } from '@mui/material'
2
+ import { styles } from './styles'
3
+ import {
4
+ LegendSwatch,
5
+ DEFAULT_SWATCH_SIZE,
6
+ } from '../legend-swatch/legend-swatch'
7
+ import type { LegendCategoryVariable } from '../../stores'
8
+
9
+ export interface LegendCategoryUIProps {
10
+ data: LegendCategoryVariable
11
+ }
12
+
13
+ const MIN_GUTTER = 16
14
+
15
+ /**
16
+ * Categorical legend — a list of swatches with labels (and optional trailing
17
+ * values, compact-formatted). The variable-level shape / fill pattern /
18
+ * stroke style / `isStrokeColor` act as defaults; each item may override any
19
+ * of them, so one list can mix patterns (or shapes) across entries.
20
+ *
21
+ * @experimental This API is new and may change in a future release.
22
+ */
23
+ export function LegendCategoryUI({ data }: LegendCategoryUIProps) {
24
+ const {
25
+ items,
26
+ shape = 'circle',
27
+ isStrokeColor = false,
28
+ fillPattern = 'solid',
29
+ strokeStyle = 'solid',
30
+ orientation = 'vertical',
31
+ } = data
32
+ const horizontal = orientation === 'horizontal'
33
+
34
+ // Fixed gutter wide enough for the largest swatch so labels align — only
35
+ // meaningful in the vertical list; horizontal chips size to content.
36
+ const maxSize = items.reduce(
37
+ (m, it) => Math.max(m, it.size ?? DEFAULT_SWATCH_SIZE),
38
+ 0,
39
+ )
40
+ const gutter = Math.max(MIN_GUTTER, maxSize)
41
+
42
+ return (
43
+ <Box
44
+ data-orientation={orientation}
45
+ sx={{ ...styles.list, ...(horizontal ? styles.listHorizontal : null) }}
46
+ >
47
+ {items.map((item, index) => (
48
+ <Box
49
+ key={`${item.label}-${index}`}
50
+ sx={{
51
+ ...styles.item,
52
+ ...(horizontal ? styles.itemHorizontal : null),
53
+ }}
54
+ >
55
+ <Box
56
+ sx={{ ...styles.gutter, width: horizontal ? undefined : gutter }}
57
+ >
58
+ <LegendSwatch
59
+ shape={item.shape ?? shape}
60
+ color={item.color}
61
+ isStrokeColor={item.isStrokeColor ?? isStrokeColor}
62
+ fillPattern={item.fillPattern ?? fillPattern}
63
+ strokeStyle={item.strokeStyle ?? strokeStyle}
64
+ size={item.size ?? DEFAULT_SWATCH_SIZE}
65
+ />
66
+ </Box>
67
+ <Typography
68
+ variant='caption'
69
+ color='text.primary'
70
+ sx={{
71
+ ...styles.label,
72
+ ...(horizontal ? styles.labelHorizontal : null),
73
+ }}
74
+ >
75
+ {item.label}
76
+ </Typography>
77
+ {item.value != null && (
78
+ <Typography
79
+ className='PsLegend-value'
80
+ variant='caption'
81
+ color='text.secondary'
82
+ sx={styles.value}
83
+ >
84
+ {item.value}
85
+ </Typography>
86
+ )}
87
+ </Box>
88
+ ))}
89
+ </Box>
90
+ )
91
+ }
@@ -0,0 +1,61 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+
3
+ export const styles = {
4
+ list: {
5
+ display: 'flex',
6
+ flexDirection: 'column',
7
+ gap: 0.5,
8
+ // Values are hidden until the legend content is hovered (or keyboard-
9
+ // focused). On hover-incapable (touch) devices they're always visible —
10
+ // tap-:hover is unreliable on non-interactive elements.
11
+ '&:hover .PsLegend-value, &:focus-within .PsLegend-value': {
12
+ opacity: 1,
13
+ },
14
+ '@media (hover: none)': {
15
+ '& .PsLegend-value': { opacity: 1 },
16
+ },
17
+ },
18
+ item: {
19
+ display: 'flex',
20
+ alignItems: 'center',
21
+ gap: 1,
22
+ },
23
+ // Fixed-width gutter so labels stay column-aligned regardless of swatch
24
+ // shape or per-item size; the swatch is centered within it.
25
+ gutter: {
26
+ flexShrink: 0,
27
+ display: 'flex',
28
+ alignItems: 'center',
29
+ justifyContent: 'center',
30
+ },
31
+ label: {
32
+ flex: 1,
33
+ minWidth: 0,
34
+ overflow: 'hidden',
35
+ textOverflow: 'ellipsis',
36
+ whiteSpace: 'nowrap',
37
+ },
38
+ value: {
39
+ flexShrink: 0,
40
+ opacity: 0,
41
+ transition: 'opacity 120ms',
42
+ },
43
+ // ─── Horizontal orientation modifiers (spread over the vertical base) ───
44
+ // Items flow left-to-right and wrap; each is a compact swatch+label pair.
45
+ listHorizontal: {
46
+ flexDirection: 'row',
47
+ flexWrap: 'wrap',
48
+ columnGap: 2,
49
+ rowGap: 0.5,
50
+ },
51
+ // No fixed gutter — the swatch sits at its natural size next to the label.
52
+ itemHorizontal: {
53
+ gap: 0.5,
54
+ },
55
+ // Each chip sizes to its content (no flex-grow / ellipsis).
56
+ labelHorizontal: {
57
+ flex: '0 0 auto',
58
+ overflow: 'visible',
59
+ textOverflow: 'clip',
60
+ },
61
+ } satisfies Record<string, SxProps<Theme>>
@@ -0,0 +1,96 @@
1
+ import { useState } from 'react'
2
+ import { ButtonBase, Menu, MenuItem, Typography } from '@mui/material'
3
+ import { ArrowDropDown as ArrowDropDownIcon } from '@mui/icons-material'
4
+ import { useLegendId, useLegendShallow } from '../../stores'
5
+ import { useLegendConfig } from '../../provider'
6
+ import { useLegendRow } from '../contexts'
7
+ import { styles } from './styles'
8
+
9
+ /**
10
+ * The layer configuration select — resolves its layer from the enclosing
11
+ * `Legend.Row` context. The closed field shows the **first section's** active
12
+ * option; the menu lists every section under its title (omitted when the
13
+ * layer has a single section) and each section highlights its own active
14
+ * option. Picking an option writes `setSectionValue` and closes the menu.
15
+ * Renders nothing when the layer declares no sections (or outside a row).
16
+ *
17
+ * A native single-value `Select` can't represent one active per section,
18
+ * hence the outlined-trigger + `Menu` composition.
19
+ *
20
+ * @experimental This API is new and may change in a future release.
21
+ */
22
+ export function LegendConfigSelect() {
23
+ const id = useLegendId()
24
+ const row = useLegendRow()
25
+ const layerId = row?.layerId
26
+ const sections = useLegendShallow(id, (s) =>
27
+ layerId ? s.layers[layerId]?.sections : undefined,
28
+ )
29
+ const setSectionValue = useLegendShallow(id, (s) => s.setSectionValue)
30
+ const { labels } = useLegendConfig()
31
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
32
+ const open = Boolean(anchorEl)
33
+
34
+ if (!layerId || !sections || sections.length === 0) return null
35
+
36
+ const display = sections[0]!.active
37
+ const showTitles = sections.length > 1
38
+
39
+ return (
40
+ <>
41
+ <ButtonBase
42
+ sx={styles.trigger}
43
+ onClick={(event) => setAnchorEl(event.currentTarget)}
44
+ aria-label={labels.attribute}
45
+ aria-haspopup='listbox'
46
+ aria-expanded={open}
47
+ >
48
+ <Typography variant='body2' sx={styles.value}>
49
+ {display}
50
+ </Typography>
51
+ <ArrowDropDownIcon
52
+ fontSize='small'
53
+ sx={
54
+ open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron
55
+ }
56
+ />
57
+ </ButtonBase>
58
+
59
+ <Menu
60
+ anchorEl={anchorEl}
61
+ open={open}
62
+ onClose={() => setAnchorEl(null)}
63
+ slotProps={{
64
+ paper: { sx: { width: anchorEl?.clientWidth } },
65
+ }}
66
+ >
67
+ {sections.map((section) => [
68
+ showTitles && section.title ? (
69
+ <Typography
70
+ key={`${section.id}-title`}
71
+ variant='overline'
72
+ color='text.secondary'
73
+ component='li'
74
+ sx={styles.sectionTitle}
75
+ >
76
+ {section.title}
77
+ </Typography>
78
+ ) : null,
79
+ ...section.options.map((option) => (
80
+ <MenuItem
81
+ key={`${section.id}-${option}`}
82
+ selected={option === section.active}
83
+ sx={styles.option}
84
+ onClick={() => {
85
+ setSectionValue(layerId, section.id, option)
86
+ setAnchorEl(null)
87
+ }}
88
+ >
89
+ {option}
90
+ </MenuItem>
91
+ )),
92
+ ])}
93
+ </Menu>
94
+ </>
95
+ )
96
+ }
@@ -0,0 +1,48 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+
3
+ export const styles = {
4
+ // Outlined-input look-alike trigger (the design's select has no floating
5
+ // label — just the value + chevron in a 32px bordered field).
6
+ trigger: {
7
+ width: '100%',
8
+ minHeight: 32,
9
+ display: 'flex',
10
+ alignItems: 'center',
11
+ justifyContent: 'space-between',
12
+ gap: 1,
13
+ padding: ({ spacing }) => spacing(0, 1.5),
14
+ border: '1px solid',
15
+ borderColor: 'divider',
16
+ borderRadius: 1,
17
+ backgroundColor: 'background.paper',
18
+ color: 'text.primary',
19
+ '&:hover': {
20
+ borderColor: 'text.primary',
21
+ },
22
+ },
23
+ value: {
24
+ minWidth: 0,
25
+ overflow: 'hidden',
26
+ textOverflow: 'ellipsis',
27
+ whiteSpace: 'nowrap',
28
+ },
29
+ chevron: {
30
+ flexShrink: 0,
31
+ color: 'action.active',
32
+ transition: ({ transitions }: Theme) => transitions.create('transform'),
33
+ },
34
+ chevronOpen: {
35
+ transform: 'rotate(180deg)',
36
+ },
37
+ sectionTitle: {
38
+ padding: ({ spacing }) => spacing(1, 2, 0.5, 2),
39
+ lineHeight: 1.5,
40
+ },
41
+ // Figma: the active option reads as primary-colored text, no background.
42
+ option: {
43
+ '&.Mui-selected, &.Mui-selected:hover': {
44
+ backgroundColor: 'transparent',
45
+ color: 'primary.main',
46
+ },
47
+ },
48
+ } satisfies Record<string, SxProps<Theme>>
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, it, expect } from 'vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { LegendProvider } from '../../provider'
4
+ import { LegendGroup } from './legend-group'
5
+ import { LegendRow } from '../legend-row/legend-row'
6
+ import { LegendActions } from '../legend-actions/legend-actions'
7
+ import { LegendVisibilityToggle } from '../legend-visibility-toggle/legend-visibility-toggle'
8
+ import {
9
+ clearAllLegendStores,
10
+ getLegendStore,
11
+ type LegendGroupInput,
12
+ type LegendLayerInput,
13
+ type LegendVariable,
14
+ } from '../../stores'
15
+
16
+ const cat: LegendVariable = {
17
+ type: 'category',
18
+ items: [{ label: 'A', color: '#123456' }],
19
+ }
20
+
21
+ const groups: LegendGroupInput[] = [{ id: 'g1', label: 'Demographics' }]
22
+ const layers: LegendLayerInput[] = [
23
+ { id: 'a', name: 'Alpha', variables: [cat], groupId: 'g1', order: 1 },
24
+ { id: 'b', name: 'Beta', variables: [cat], groupId: 'g1', order: 0 },
25
+ ]
26
+
27
+ afterEach(() => clearAllLegendStores())
28
+
29
+ /** Module-shaped composition: group eye in the header, explicit member rows. */
30
+ function renderGroup(id: string) {
31
+ return render(
32
+ <LegendProvider id={id} layers={layers} groups={groups}>
33
+ <LegendGroup groupId='g1'>
34
+ <LegendActions>
35
+ <LegendVisibilityToggle />
36
+ </LegendActions>
37
+ {['b', 'a'].map((layerId) => (
38
+ <LegendRow key={layerId} layerId={layerId}>
39
+ <LegendActions>
40
+ <LegendVisibilityToggle />
41
+ </LegendActions>
42
+ </LegendRow>
43
+ ))}
44
+ </LegendGroup>
45
+ </LegendProvider>,
46
+ )
47
+ }
48
+
49
+ describe('<LegendGroup> composition', () => {
50
+ it('renders the label from the store, Actions in the header, rows in the body', () => {
51
+ const { container } = renderGroup('g-comp')
52
+ expect(screen.getByText('Demographics')).toBeTruthy()
53
+ // The group eye landed in the group fade slot of the header…
54
+ const fade = container.querySelector('.PsLegend-groupFade')!
55
+ expect(fade.contains(screen.getByLabelText('Hide group'))).toBe(true)
56
+ // …and both member rows render in the body.
57
+ expect(screen.getByText('Alpha')).toBeTruthy()
58
+ expect(screen.getByText('Beta')).toBeTruthy()
59
+ })
60
+
61
+ it('the group eye cascades visibility to every member layer', () => {
62
+ renderGroup('g-vis')
63
+ fireEvent.click(screen.getByLabelText('Hide group'))
64
+ let state = getLegendStore('g-vis').getState()
65
+ expect(state.layers.a!.visible).toBe(false)
66
+ expect(state.layers.b!.visible).toBe(false)
67
+ fireEvent.click(screen.getByLabelText('Show group'))
68
+ state = getLegendStore('g-vis').getState()
69
+ expect(state.layers.a!.visible).toBe(true)
70
+ expect(state.layers.b!.visible).toBe(true)
71
+ })
72
+
73
+ it('toggles group collapse through the store', () => {
74
+ renderGroup('g-col')
75
+ fireEvent.click(screen.getByLabelText('Collapse group'))
76
+ expect(getLegendStore('g-col').getState().groups.g1!.collapsed).toBe(true)
77
+ })
78
+
79
+ it('shows the indeterminate icon (mixed) when only some layers are hidden', () => {
80
+ renderGroup('g-mixed')
81
+ // Hide one member row via its own (row-context) eye.
82
+ fireEvent.click(screen.getAllByLabelText('Hide layer')[0]!)
83
+ // Mixed: the group eye still offers "Hide group", is pinned active, and
84
+ // renders the indeterminate glyph.
85
+ const eye = screen.getByLabelText('Hide group')
86
+ expect(eye.className).toContain('active')
87
+ expect(
88
+ eye.querySelector('[data-testid="VisibilityIndeterminateIcon"]'),
89
+ ).toBeTruthy()
90
+ // Clicking on mixed hides the remaining visible member.
91
+ fireEvent.click(eye)
92
+ const state = getLegendStore('g-mixed').getState()
93
+ expect(state.layers.a!.visible).toBe(false)
94
+ expect(state.layers.b!.visible).toBe(false)
95
+ expect(screen.getByLabelText('Show group')).toBeTruthy()
96
+ })
97
+
98
+ it('marks the visibility toggle active while the group is hidden', () => {
99
+ renderGroup('g-active')
100
+ fireEvent.click(screen.getByLabelText('Hide group'))
101
+ expect(screen.getByLabelText('Show group').className).toContain('active')
102
+ })
103
+
104
+ it('hiding the group collapses it, but the chevron stays usable', () => {
105
+ renderGroup('g-hide')
106
+ fireEvent.click(screen.getByLabelText('Hide group'))
107
+ expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(true)
108
+ // The chevron reads "Expand group" and is NOT disabled — a hidden group
109
+ // can be peeked into to re-show layers one by one.
110
+ const chevron = screen.getByLabelText<HTMLButtonElement>('Expand group')
111
+ expect(chevron.disabled).toBe(false)
112
+ fireEvent.click(chevron)
113
+ expect(getLegendStore('g-hide').getState().groups.g1!.collapsed).toBe(false)
114
+ })
115
+ })
@@ -0,0 +1,88 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Box, Collapse, IconButton, Typography } from '@mui/material'
3
+ import {
4
+ LayersOutlined as GroupIcon,
5
+ ExpandMore as ExpandMoreIcon,
6
+ ExpandLess as ExpandLessIcon,
7
+ } from '@mui/icons-material'
8
+ import { SmartTooltip } from '../../../components/smart-tooltip/smart-tooltip'
9
+ import { useLegendId, useLegendShallow } from '../../stores'
10
+ import { useLegendConfig } from '../../provider'
11
+ import { LegendGroupContext } from '../contexts'
12
+ import { partitionLegendChildren } from '../legend-actions/legend-actions'
13
+ import { styles } from './styles'
14
+
15
+ export interface LegendGroupProps {
16
+ groupId: string
17
+ /**
18
+ * Composition slots: `<Legend.Actions>` children render in the sticky
19
+ * header (hover-fade); everything else is the collapsible body — the
20
+ * consumer renders the member `<Legend.Row>`s explicitly (use
21
+ * `selectLayersByGroup` to resolve them).
22
+ */
23
+ children?: ReactNode
24
+ }
25
+
26
+ /**
27
+ * Store-connected group wrapper (widgets-v2 `Wrapper` parity). Renders the
28
+ * sticky 48px header — group icon + label from the store, the partitioned
29
+ * `<Legend.Actions>` slot, and the collapse chevron — over the collapsible
30
+ * body. Provides the group composition context so `Legend.VisibilityToggle`
31
+ * placed in the header becomes the tri-state group eye.
32
+ *
33
+ * @experimental This API is new and may change in a future release.
34
+ */
35
+ export function LegendGroup({ groupId, children }: LegendGroupProps) {
36
+ const id = useLegendId()
37
+ const group = useLegendShallow(id, (s) => s.groups[groupId])
38
+ const setGroupCollapsed = useLegendShallow(id, (s) => s.setGroupCollapsed)
39
+ const { labels } = useLegendConfig()
40
+
41
+ if (!group) return null
42
+
43
+ const { actions, body } = partitionLegendChildren(children)
44
+
45
+ return (
46
+ <LegendGroupContext.Provider value={groupId}>
47
+ <Box sx={styles.group}>
48
+ <Box sx={styles.header}>
49
+ <Box sx={styles.groupIcon}>
50
+ <GroupIcon fontSize='small' />
51
+ </Box>
52
+
53
+ <SmartTooltip title={group.label} dependencies={[group.label]}>
54
+ {({ ref }) => (
55
+ <Typography
56
+ ref={ref}
57
+ variant='button'
58
+ color='text.primary'
59
+ sx={styles.label}
60
+ >
61
+ {group.label}
62
+ </Typography>
63
+ )}
64
+ </SmartTooltip>
65
+
66
+ <Box sx={styles.actions}>
67
+ {actions}
68
+ <IconButton
69
+ size='small'
70
+ onClick={() => setGroupCollapsed(groupId, !group.collapsed)}
71
+ aria-label={
72
+ group.collapsed ? labels.expandGroup : labels.collapseGroup
73
+ }
74
+ >
75
+ {group.collapsed ? (
76
+ <ExpandMoreIcon fontSize='small' />
77
+ ) : (
78
+ <ExpandLessIcon fontSize='small' />
79
+ )}
80
+ </IconButton>
81
+ </Box>
82
+ </Box>
83
+
84
+ <Collapse in={!group.collapsed}>{body}</Collapse>
85
+ </Box>
86
+ </LegendGroupContext.Provider>
87
+ )
88
+ }
@@ -0,0 +1,82 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+
3
+ /**
4
+ * Design height of the group header (Figma "Group layers header", 48px).
5
+ * Grouped layer-row headers use it as their sticky `top` offset so they pin
6
+ * just below their group's header while the panel scrolls.
7
+ */
8
+ export const LEGEND_GROUP_HEADER_HEIGHT = 48
9
+
10
+ export const styles = {
11
+ group: {
12
+ display: 'flex',
13
+ flexDirection: 'column',
14
+ '& + &': {
15
+ borderTop: '1px solid',
16
+ borderColor: 'divider',
17
+ },
18
+ // Hover-fade group actions (widgets-v2 Wrapper parity). Revealed by
19
+ // hovering anywhere in the group block (including member rows); a control
20
+ // carrying `.active` (hidden group) stays visible. Touch devices always
21
+ // show them. The collapse chevron sits outside the fade group. Member
22
+ // rows use the distinct `PsLegend-rowFade` class, so group hover never
23
+ // lights up row actions.
24
+ '& .PsLegend-groupFade > *': {
25
+ opacity: 1,
26
+ transition: ({ transitions }: Theme) =>
27
+ transitions.create('opacity', {
28
+ duration: transitions.duration.standard,
29
+ easing: transitions.easing.easeInOut,
30
+ }),
31
+ '@media (hover: hover)': {
32
+ opacity: 0,
33
+ },
34
+ '&.active': {
35
+ opacity: 1,
36
+ },
37
+ },
38
+ '&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
39
+ opacity: 1,
40
+ },
41
+ },
42
+ // Figma: 48px tall, icon at 12px from the left, 8px gap to the label,
43
+ // actions right-aligned with ~8px edge inset (the small icon-button padding
44
+ // brings the glyphs to the design's 16px optical inset), hairline divider
45
+ // below. Sticky: pins to the top of the panel's scroll container.
46
+ header: {
47
+ display: 'flex',
48
+ alignItems: 'center',
49
+ gap: 1,
50
+ padding: ({ spacing }) => spacing(1.5, 2, 1.5, 1),
51
+ minHeight: LEGEND_GROUP_HEADER_HEIGHT,
52
+ borderBottom: '1px solid',
53
+ borderColor: 'divider',
54
+ position: 'sticky',
55
+ top: 0,
56
+ // Above the sticky row headers (2), which in turn sit above MUI's
57
+ // z-index-1 form internals in row content.
58
+ zIndex: 3,
59
+ backgroundColor: 'background.paper',
60
+ },
61
+ groupIcon: {
62
+ display: 'flex',
63
+ color: 'text.secondary',
64
+ },
65
+ label: {
66
+ flex: 1,
67
+ minWidth: 0,
68
+ overflow: 'hidden',
69
+ textOverflow: 'ellipsis',
70
+ whiteSpace: 'nowrap',
71
+ },
72
+ // Icon buttons match the row header's 24×24 (2px padding + 20px icon).
73
+ actions: {
74
+ display: 'flex',
75
+ alignItems: 'center',
76
+ gap: 0.25,
77
+ flexShrink: 0,
78
+ '& .MuiIconButton-root': {
79
+ padding: '2px',
80
+ },
81
+ },
82
+ } satisfies Record<string, SxProps<Theme>>
@@ -0,0 +1,56 @@
1
+ import { Box, Typography } from '@mui/material'
2
+ import { styles } from './styles'
3
+ import type { LegendIconVariable } from '../../stores'
4
+
5
+ export interface LegendIconUIProps {
6
+ data: LegendIconVariable
7
+ }
8
+
9
+ /**
10
+ * Icon legend — a list of icon images with labels (and optional values).
11
+ *
12
+ * @experimental This API is new and may change in a future release.
13
+ */
14
+ export function LegendIconUI({ data }: LegendIconUIProps) {
15
+ const orientation = data.orientation ?? 'vertical'
16
+ const horizontal = orientation === 'horizontal'
17
+
18
+ return (
19
+ <Box
20
+ data-orientation={orientation}
21
+ sx={{ ...styles.list, ...(horizontal ? styles.listHorizontal : null) }}
22
+ >
23
+ {data.items.map((item, index) => (
24
+ <Box
25
+ key={`${item.label}-${index}`}
26
+ sx={{
27
+ ...styles.item,
28
+ ...(horizontal ? styles.itemHorizontal : null),
29
+ }}
30
+ >
31
+ <Box component='img' src={item.icon} alt='' sx={styles.icon} />
32
+ <Typography
33
+ variant='caption'
34
+ color='text.primary'
35
+ sx={{
36
+ ...styles.label,
37
+ ...(horizontal ? styles.labelHorizontal : null),
38
+ }}
39
+ >
40
+ {item.label}
41
+ </Typography>
42
+ {item.value != null && (
43
+ <Typography
44
+ className='PsLegend-value'
45
+ variant='caption'
46
+ color='text.secondary'
47
+ sx={styles.value}
48
+ >
49
+ {item.value}
50
+ </Typography>
51
+ )}
52
+ </Box>
53
+ ))}
54
+ </Box>
55
+ )
56
+ }