@carto/ps-react-ui 4.12.2 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/{category-Dnd2_j0x.js → category-CGS_eHr4.js} +2 -2
  2. package/dist/{category-Dnd2_j0x.js.map → category-CGS_eHr4.js.map} +1 -1
  3. package/dist/{change-column-DjjwoPt1.js → change-column-CiVAjOUB.js} +2 -2
  4. package/dist/{change-column-DjjwoPt1.js.map → change-column-CiVAjOUB.js.map} +1 -1
  5. package/dist/components.js +1232 -777
  6. package/dist/components.js.map +1 -1
  7. package/dist/{data-zoom-layout-CkVnm6ej.js → data-zoom-layout-D_FYdnap.js} +2 -2
  8. package/dist/{data-zoom-layout-CkVnm6ej.js.map → data-zoom-layout-D_FYdnap.js.map} +1 -1
  9. package/dist/{download-config-oJIFZ2WC.js → download-config-OKlLLSW_.js} +2 -2
  10. package/dist/{download-config-oJIFZ2WC.js.map → download-config-OKlLLSW_.js.map} +1 -1
  11. package/dist/{lasso-tool-CDFj4zKY.js → lasso-tool-YLFRgb-S.js} +182 -154
  12. package/dist/lasso-tool-YLFRgb-S.js.map +1 -0
  13. package/dist/legend/stores.js +20 -0
  14. package/dist/legend/stores.js.map +1 -0
  15. package/dist/legend.js +1536 -0
  16. package/dist/legend.js.map +1 -0
  17. package/dist/selectors-DwHj6mE2.js +388 -0
  18. package/dist/selectors-DwHj6mE2.js.map +1 -0
  19. package/dist/{spread-DYNpzgh_.js → spread-4MYPOntd.js} +2 -2
  20. package/dist/{spread-DYNpzgh_.js.map → spread-4MYPOntd.js.map} +1 -1
  21. package/dist/{table-B3ZWWhJt.js → table-CTcLOycx.js} +2 -2
  22. package/dist/{table-B3ZWWhJt.js.map → table-CTcLOycx.js.map} +1 -1
  23. package/dist/types/components/index.d.ts +5 -2
  24. package/dist/types/components/lasso-tool/geometry-toolbar.d.ts +13 -0
  25. package/dist/types/components/lasso-tool/icons.d.ts +6 -0
  26. package/dist/types/components/lasso-tool/lasso-tool.d.ts +1 -1
  27. package/dist/types/components/lasso-tool/layer-filters.d.ts +10 -0
  28. package/dist/types/components/lasso-tool/styles.d.ts +1 -1
  29. package/dist/types/components/lasso-tool/types.d.ts +62 -1
  30. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
  31. package/dist/types/legend/components/contexts.d.ts +20 -0
  32. package/dist/types/legend/components/icons.d.ts +12 -0
  33. package/dist/types/legend/components/index.d.ts +26 -0
  34. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
  35. package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
  36. package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
  37. package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
  38. package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
  39. package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
  40. package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
  41. package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
  42. package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
  43. package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
  44. package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
  45. package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
  46. package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
  47. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
  48. package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
  49. package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
  50. package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
  51. package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
  52. package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
  53. package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
  54. package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
  55. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
  56. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
  57. package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
  58. package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
  59. package/dist/types/legend/index.d.ts +88 -0
  60. package/dist/types/legend/provider/index.d.ts +6 -0
  61. package/dist/types/legend/provider/labels.d.ts +27 -0
  62. package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
  63. package/dist/types/legend/provider/legend-provider.d.ts +38 -0
  64. package/dist/types/legend/stores/index.d.ts +5 -0
  65. package/dist/types/legend/stores/legend-context.d.ts +4 -0
  66. package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
  67. package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
  68. package/dist/types/legend/stores/selectors.d.ts +33 -0
  69. package/dist/types/legend/stores/types.d.ts +275 -0
  70. package/dist/widgets/actions.js +1 -1
  71. package/dist/widgets/bar.js +1 -1
  72. package/dist/widgets/category.js +1 -1
  73. package/dist/widgets/formula.js +1 -1
  74. package/dist/widgets/histogram.js +1 -1
  75. package/dist/widgets/markdown.js +1 -1
  76. package/dist/widgets/pie.js +1 -1
  77. package/dist/widgets/scatterplot.js +1 -1
  78. package/dist/widgets/spread.js +1 -1
  79. package/dist/widgets/table.js +1 -1
  80. package/dist/widgets/timeseries.js +1 -1
  81. package/dist/widgets/utils.js +1 -1
  82. package/dist/widgets/wrapper.js +1 -1
  83. package/dist/widgets-v2/actions.js +1 -1
  84. package/dist/widgets-v2/bar.js +2 -2
  85. package/dist/widgets-v2/category.js +2 -2
  86. package/dist/widgets-v2/formula.js +1 -1
  87. package/dist/widgets-v2/histogram.js +2 -2
  88. package/dist/widgets-v2/markdown.js +1 -1
  89. package/dist/widgets-v2/pie.js +1 -1
  90. package/dist/widgets-v2/scatterplot.js +1 -1
  91. package/dist/widgets-v2/spread.js +2 -2
  92. package/dist/widgets-v2/table.js +3 -3
  93. package/dist/widgets-v2/timeseries.js +2 -2
  94. package/dist/widgets-v2/utils.js +1 -1
  95. package/dist/widgets-v2.js +5 -5
  96. package/package.json +11 -3
  97. package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
  98. package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
  99. package/src/components/index.ts +10 -0
  100. package/src/components/lasso-tool/const.tsx +1 -0
  101. package/src/components/lasso-tool/geometry-toolbar.test.tsx +138 -0
  102. package/src/components/lasso-tool/geometry-toolbar.tsx +258 -0
  103. package/src/components/lasso-tool/icons.tsx +40 -0
  104. package/src/components/lasso-tool/lasso-tool.tsx +25 -1
  105. package/src/components/lasso-tool/layer-filters.test.tsx +90 -0
  106. package/src/components/lasso-tool/layer-filters.tsx +236 -0
  107. package/src/components/lasso-tool/styles.ts +1 -1
  108. package/src/components/lasso-tool/types.ts +66 -1
  109. package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
  110. package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
  111. package/src/legend/components/contexts.ts +32 -0
  112. package/src/legend/components/icons.tsx +22 -0
  113. package/src/legend/components/index.ts +39 -0
  114. package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
  115. package/src/legend/components/legend-actions/styles.ts +9 -0
  116. package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
  117. package/src/legend/components/legend-category/styles.ts +61 -0
  118. package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
  119. package/src/legend/components/legend-config-select/styles.ts +48 -0
  120. package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
  121. package/src/legend/components/legend-group/legend-group.tsx +88 -0
  122. package/src/legend/components/legend-group/styles.ts +82 -0
  123. package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
  124. package/src/legend/components/legend-icon/styles.ts +56 -0
  125. package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
  126. package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
  127. package/src/legend/components/legend-item/legend-item.tsx +34 -0
  128. package/src/legend/components/legend-item/styles.ts +11 -0
  129. package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
  130. package/src/legend/components/legend-opacity/styles.ts +28 -0
  131. package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
  132. package/src/legend/components/legend-proportion/styles.ts +43 -0
  133. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
  134. package/src/legend/components/legend-ramp/styles.ts +62 -0
  135. package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
  136. package/src/legend/components/legend-row/legend-row.tsx +129 -0
  137. package/src/legend/components/legend-row/styles.ts +111 -0
  138. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
  139. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
  140. package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
  141. package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
  142. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
  143. package/src/legend/index.ts +121 -0
  144. package/src/legend/provider/index.ts +6 -0
  145. package/src/legend/provider/labels.ts +59 -0
  146. package/src/legend/provider/legend-config-context.ts +15 -0
  147. package/src/legend/provider/legend-provider.tsx +102 -0
  148. package/src/legend/stores/index.ts +46 -0
  149. package/src/legend/stores/legend-context.ts +17 -0
  150. package/src/legend/stores/legend-store-registry.ts +639 -0
  151. package/src/legend/stores/legend-store.test.ts +457 -0
  152. package/src/legend/stores/selectors.ts +65 -0
  153. package/src/legend/stores/types.ts +347 -0
  154. package/dist/lasso-tool-CDFj4zKY.js.map +0 -1
@@ -0,0 +1,111 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+
3
+ export const styles = {
4
+ // Padding lives on header/content/footer (not the root) so the sticky
5
+ // header is opaque and full-bleed while pinned.
6
+ row: {
7
+ display: 'flex',
8
+ flexDirection: 'column',
9
+ '& + &': {
10
+ borderTop: '1px solid',
11
+ borderColor: 'divider',
12
+ },
13
+ // Hover-fade actions (widgets-v2 Wrapper parity). Direct children of
14
+ // `.PsLegend-rowFade` are hidden on fine-pointer devices and fade in when
15
+ // the row is hovered or holds focus; a control carrying `.active` (dirty
16
+ // state — hidden layer, open menu, opacity ≠ 1) stays visible. Coarse-
17
+ // pointer (touch) devices always show them. The collapse chevron sits
18
+ // outside the fade group and never fades.
19
+ '& .PsLegend-rowFade > *': {
20
+ opacity: 1,
21
+ transition: ({ transitions }: Theme) =>
22
+ transitions.create('opacity', {
23
+ duration: transitions.duration.standard,
24
+ easing: transitions.easing.easeInOut,
25
+ }),
26
+ '@media (hover: hover)': {
27
+ opacity: 0,
28
+ },
29
+ '&.active': {
30
+ opacity: 1,
31
+ },
32
+ },
33
+ '&:hover .PsLegend-rowFade > *, &:focus-within .PsLegend-rowFade > *': {
34
+ opacity: 1,
35
+ },
36
+ // Hovering anywhere in the row also reveals the category/icon values
37
+ // (the renderers keep their own list-scoped rule for standalone usage).
38
+ '&:hover .PsLegend-value, &:focus-within .PsLegend-value': {
39
+ opacity: 1,
40
+ },
41
+ },
42
+ // Figma `_Legend header`: 16px top/left inset (title glyphs start at
43
+ // y≈17, x=16 in the 68px-tall header), actions share the title's vertical
44
+ // band (subtitle flows below them) — hence `flex-start` with the title
45
+ // optically centered against the 30px icon-button row. Sticky: pins inside
46
+ // the panel's scroll container; `top` is applied inline per row (0, or the
47
+ // group-header height for grouped rows).
48
+ header: {
49
+ display: 'flex',
50
+ alignItems: 'flex-start',
51
+ gap: 0.5,
52
+ minHeight: 32,
53
+ padding: ({ spacing }) => spacing(1.5, 2, 1.5, 2),
54
+ position: 'sticky',
55
+ // Above MUI's z-index-1 form internals (e.g. InputLabel) in row content,
56
+ // below the group header (3).
57
+ zIndex: 2,
58
+ backgroundColor: 'background.paper',
59
+ },
60
+ titleBox: {
61
+ flex: 1,
62
+ minWidth: 0,
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ },
66
+ // The title line and the actions box share the same 24px band, centered,
67
+ // so the title and the action icons stay aligned under the header's
68
+ // `flex-start` — no padding magic. The subtitle flows below.
69
+ titleLine: {
70
+ minHeight: 24,
71
+ display: 'flex',
72
+ alignItems: 'center',
73
+ },
74
+ title: {
75
+ flex: 1,
76
+ minWidth: 0,
77
+ overflow: 'hidden',
78
+ textOverflow: 'ellipsis',
79
+ whiteSpace: 'nowrap',
80
+ },
81
+ // Same 24px band as the title line, so both sides of the header stay
82
+ // aligned even when the composed actions are shorter (or fade out).
83
+ // Icon buttons (actions + collapse chevron) are squeezed to 24×24:
84
+ // 2px padding around the 20px small icon.
85
+ actions: {
86
+ minHeight: 24,
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ gap: 0.25,
90
+ flexShrink: 0,
91
+ '& .MuiIconButton-root': {
92
+ padding: '2px',
93
+ },
94
+ },
95
+ // Figma rhythm: ~16px from the subtitle to the select (8px header bottom
96
+ // padding + 4px here + line leading), 8px between body blocks, 8px bottom
97
+ // inset before the next row's divider.
98
+ content: {
99
+ display: 'flex',
100
+ flexDirection: 'column',
101
+ gap: 1,
102
+ padding: ({ spacing }) => spacing(0.5, 2, 1, 2),
103
+ },
104
+ // ~16px above the note (content's 8px bottom inset + 8px here), per design.
105
+ footer: {
106
+ padding: ({ spacing }) => spacing(1, 2, 1, 2),
107
+ },
108
+ dimmed: {
109
+ opacity: 0.5,
110
+ },
111
+ } satisfies Record<string, SxProps<Theme>>
@@ -0,0 +1,68 @@
1
+ import { useState, type MouseEvent, type ReactNode } from 'react'
2
+ import { IconButton, Menu } from '@mui/material'
3
+ import { MoreVert as MoreVertIcon } from '@mui/icons-material'
4
+ import { useLegendId, useLegendStore } from '../../stores'
5
+ import { useLegendConfig } from '../../provider'
6
+ import { useLegendRow } from '../contexts'
7
+
8
+ export interface LegendRowMenuProps {
9
+ /**
10
+ * The menu items — composed by the consumer (e.g. `<Legend.ZoomTo>` or any
11
+ * MUI `MenuItem`). A click on any item bubbles to the menu and closes it;
12
+ * an item that must keep the menu open should `event.stopPropagation()`.
13
+ */
14
+ children?: ReactNode
15
+ }
16
+
17
+ /**
18
+ * Agnostic overflow (⋮) menu shell for a layer row — the items are passed as
19
+ * children, like `Legend.Actions`. Renders nothing without children, outside
20
+ * a row, or while the layer is hidden (a hidden row keeps only its
21
+ * visibility eye + collapse chevron).
22
+ *
23
+ * @experimental This API is new and may change in a future release.
24
+ */
25
+ export function LegendRowMenu({ children }: LegendRowMenuProps) {
26
+ const id = useLegendId()
27
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
28
+ const open = Boolean(anchorEl)
29
+ const row = useLegendRow()
30
+ const visible = useLegendStore(id, (s) =>
31
+ row ? (s.layers[row.layerId]?.visible ?? true) : true,
32
+ )
33
+ const { labels } = useLegendConfig()
34
+
35
+ if (!row || !children || !visible) return null
36
+
37
+ const handleOpen = (e: MouseEvent<HTMLElement>) => {
38
+ e.stopPropagation()
39
+ setAnchorEl(e.currentTarget)
40
+ }
41
+ const handleClose = () => setAnchorEl(null)
42
+
43
+ return (
44
+ <>
45
+ <IconButton
46
+ size='small'
47
+ className={open ? 'active' : undefined}
48
+ disabled={!visible}
49
+ onClick={handleOpen}
50
+ aria-label={labels.layerOptions}
51
+ aria-haspopup='menu'
52
+ aria-expanded={open}
53
+ >
54
+ <MoreVertIcon fontSize='small' />
55
+ </IconButton>
56
+ {/* Menu-level onClick: any item click (bubbling) closes the menu, so
57
+ consumer items need no legend wiring. */}
58
+ <Menu
59
+ anchorEl={anchorEl}
60
+ open={open}
61
+ onClose={handleClose}
62
+ onClick={handleClose}
63
+ >
64
+ {children}
65
+ </Menu>
66
+ </>
67
+ )
68
+ }
@@ -0,0 +1,35 @@
1
+ import { ListItemIcon, MenuItem } from '@mui/material'
2
+ import { CenterFocusStrong as ZoomIcon } from '@mui/icons-material'
3
+ import { useLegendConfig } from '../../provider'
4
+ import { useLegendRow } from '../contexts'
5
+
6
+ export interface LegendZoomToProps {
7
+ /**
8
+ * Fit the map viewport to the layer. The handler lives at the composition
9
+ * site (not the Provider) — each menu can wire its own behavior.
10
+ */
11
+ onZoomTo: (layerId: string) => void
12
+ }
13
+
14
+ /**
15
+ * "Zoom to" menu item for `Legend.RowMenu` — resolves its layer from the
16
+ * enclosing `Legend.Row` context and calls the `onZoomTo` it was composed
17
+ * with. The click bubbles to the menu, which closes itself.
18
+ *
19
+ * @experimental This API is new and may change in a future release.
20
+ */
21
+ export function LegendZoomTo({ onZoomTo }: LegendZoomToProps) {
22
+ const row = useLegendRow()
23
+ const { labels } = useLegendConfig()
24
+
25
+ if (!row) return null
26
+
27
+ return (
28
+ <MenuItem onClick={() => onZoomTo(row.layerId)}>
29
+ <ListItemIcon>
30
+ <ZoomIcon fontSize='small' />
31
+ </ListItemIcon>
32
+ {labels.zoomTo}
33
+ </MenuItem>
34
+ )
35
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import { LegendSwatch } from './legend-swatch'
4
+
5
+ function swatch(container: HTMLElement) {
6
+ return container.querySelector<SVGSVGElement>('svg')!
7
+ }
8
+
9
+ describe('<LegendSwatch>', () => {
10
+ it('renders a circle by default (filled, no border)', () => {
11
+ const { container } = render(<LegendSwatch color='#f00' />)
12
+ const svg = swatch(container)
13
+ expect(svg.getAttribute('data-shape')).toBe('circle')
14
+ const circle = svg.querySelector('circle')!
15
+ expect(circle.getAttribute('fill')).toBe('#f00')
16
+ expect(circle.getAttribute('stroke')).toBe('none')
17
+ })
18
+
19
+ it('renders a square shape', () => {
20
+ const { container } = render(<LegendSwatch shape='square' color='#f00' />)
21
+ expect(swatch(container).querySelector('rect')).toBeTruthy()
22
+ })
23
+
24
+ it('renders a line shape', () => {
25
+ const { container } = render(<LegendSwatch shape='line' color='#f00' />)
26
+ const svg = swatch(container)
27
+ expect(svg.getAttribute('data-shape')).toBe('line')
28
+ expect(svg.querySelector('line')).toBeTruthy()
29
+ })
30
+
31
+ it('isStrokeColor paints the border, not the fill', () => {
32
+ const { container } = render(
33
+ <LegendSwatch shape='square' color='#0f0' isStrokeColor />,
34
+ )
35
+ const rect = swatch(container).querySelector('rect')!
36
+ expect(rect.getAttribute('fill')).toBe('none')
37
+ expect(rect.getAttribute('stroke')).toBe('#0f0')
38
+ })
39
+
40
+ it('emits a <pattern> def for a patterned fill and references it', () => {
41
+ const { container } = render(
42
+ <LegendSwatch shape='square' color='#00f' fillPattern='diagonal' />,
43
+ )
44
+ const svg = swatch(container)
45
+ const pattern = svg.querySelector('pattern')!
46
+ expect(pattern).toBeTruthy()
47
+ const rect = svg.querySelector('rect')!
48
+ expect(rect.getAttribute('fill')).toBe(`url(#${pattern.id})`)
49
+ })
50
+
51
+ it('solid fill emits no <pattern>', () => {
52
+ const { container } = render(<LegendSwatch shape='square' color='#00f' />)
53
+ expect(swatch(container).querySelector('pattern')).toBeNull()
54
+ })
55
+
56
+ it('dashed border sets a stroke-dasharray', () => {
57
+ const { container } = render(
58
+ <LegendSwatch
59
+ shape='square'
60
+ color='#000'
61
+ isStrokeColor
62
+ strokeStyle='dashed'
63
+ />,
64
+ )
65
+ expect(
66
+ swatch(container).querySelector('rect')!.getAttribute('stroke-dasharray'),
67
+ ).toBeTruthy()
68
+ })
69
+
70
+ it('corner-marks render four paths (square only)', () => {
71
+ const { container } = render(
72
+ <LegendSwatch
73
+ shape='square'
74
+ color='#000'
75
+ isStrokeColor
76
+ strokeStyle='corners'
77
+ />,
78
+ )
79
+ const svg = swatch(container)
80
+ expect(svg.querySelectorAll('path')).toHaveLength(4)
81
+ expect(svg.querySelector('rect')).toBeNull()
82
+ })
83
+
84
+ it('gracefully degrades: corners on a circle falls back to a solid border', () => {
85
+ const { container } = render(
86
+ <LegendSwatch
87
+ shape='circle'
88
+ color='#000'
89
+ isStrokeColor
90
+ strokeStyle='corners'
91
+ />,
92
+ )
93
+ const svg = swatch(container)
94
+ // No corner paths; a plain stroked circle instead.
95
+ expect(svg.querySelectorAll('path')).toHaveLength(0)
96
+ const circle = svg.querySelector('circle')!
97
+ expect(circle.getAttribute('stroke')).toBe('#000')
98
+ expect(circle.getAttribute('stroke-dasharray')).toBeFalsy()
99
+ })
100
+
101
+ it('dash-dot line sets the dash-dot dasharray', () => {
102
+ const { container } = render(
103
+ <LegendSwatch shape='line' color='#000' strokeStyle='dash-dot' />,
104
+ )
105
+ expect(
106
+ swatch(container).querySelector('line')!.getAttribute('stroke-dasharray'),
107
+ ).toBe('5 2 0.1 2')
108
+ })
109
+ })
@@ -0,0 +1,237 @@
1
+ import { useId } from 'react'
2
+ import type {
3
+ LegendFillPattern,
4
+ LegendStrokeStyle,
5
+ LegendSwatchShape,
6
+ } from '../../stores'
7
+
8
+ export interface LegendSwatchProps {
9
+ /** Shape to render. Default `'circle'`. */
10
+ shape?: LegendSwatchShape
11
+ /** CSS color string (hex / rgb / named). */
12
+ color: string
13
+ /** Paint the outline/border instead of the fill (square/circle only). */
14
+ isStrokeColor?: boolean
15
+ /** Fill pattern when filled. Default `'solid'`. */
16
+ fillPattern?: LegendFillPattern
17
+ /** Border style (stroked square/circle) or line style (`shape: 'line'`). Default `'solid'`. */
18
+ strokeStyle?: LegendStrokeStyle
19
+ /** Primary dimension in px (square side / circle diameter / line length). Default 12. */
20
+ size?: number
21
+ }
22
+
23
+ /** Default swatch dimension in px — shared with the category renderer. */
24
+ export const DEFAULT_SWATCH_SIZE = 12
25
+
26
+ const STROKE_WIDTH = 1.5
27
+ const LINE_WIDTH = 2
28
+ const HAIRLINE = 1
29
+
30
+ /** Dash array for stroked square/circle borders. */
31
+ function borderDash(style: LegendStrokeStyle): {
32
+ dash?: string
33
+ round?: boolean
34
+ } {
35
+ switch (style) {
36
+ case 'dashed':
37
+ return { dash: '3 2' }
38
+ case 'dotted':
39
+ return { dash: `0.1 ${STROKE_WIDTH * 2}`, round: true }
40
+ // `dash-dot` is line-only and `corners` is handled separately: both
41
+ // degrade to a solid border here.
42
+ default:
43
+ return {}
44
+ }
45
+ }
46
+
47
+ /** Dash array for `shape: 'line'`. */
48
+ function lineDash(style: LegendStrokeStyle): {
49
+ dash?: string
50
+ round?: boolean
51
+ } {
52
+ switch (style) {
53
+ case 'dashed':
54
+ return { dash: '4 3' }
55
+ case 'dotted':
56
+ return { dash: `0.1 ${LINE_WIDTH * 2}`, round: true }
57
+ case 'dash-dot':
58
+ return { dash: '5 2 0.1 2', round: true }
59
+ // `corners` is square-only → solid line.
60
+ default:
61
+ return {}
62
+ }
63
+ }
64
+
65
+ /** SVG children for a fill `<pattern>` tile, drawn in `color`. */
66
+ function patternTile(pattern: LegendFillPattern, color: string) {
67
+ const line = (d: string) => (
68
+ <path d={d} stroke={color} strokeWidth={1.2} fill='none' />
69
+ )
70
+ switch (pattern) {
71
+ case 'horizontal':
72
+ return { size: 4, content: line('M0 2 H4') }
73
+ case 'vertical':
74
+ return { size: 4, content: line('M2 0 V4') }
75
+ case 'diagonal':
76
+ // "/" with wrap segments for a seamless tile.
77
+ return { size: 4, content: line('M0 4 L4 0 M-1 1 L1 -1 M3 5 L5 3') }
78
+ case 'diagonal-alt':
79
+ // "\"
80
+ return { size: 4, content: line('M0 0 L4 4 M-1 3 L1 5 M3 -1 L5 1') }
81
+ case 'cross':
82
+ return {
83
+ size: 4,
84
+ content: line(
85
+ 'M0 4 L4 0 M-1 1 L1 -1 M3 5 L5 3 M0 0 L4 4 M-1 3 L1 5 M3 -1 L5 1',
86
+ ),
87
+ }
88
+ case 'dots':
89
+ return { size: 4, content: <circle cx={2} cy={2} r={0.9} fill={color} /> }
90
+ case 'grid':
91
+ return {
92
+ size: 3,
93
+ content: <circle cx={1.5} cy={1.5} r={0.7} fill={color} />,
94
+ }
95
+ default:
96
+ return null
97
+ }
98
+ }
99
+
100
+ /**
101
+ * A single legend swatch rendered as inline SVG: a square, circle, or line,
102
+ * painted as a solid/patterned fill or a styled outline. Pure and theme-free —
103
+ * `color` is a raw CSS color. Decorative (`aria-hidden`); the adjacent label
104
+ * carries the accessible text.
105
+ *
106
+ * @experimental This API is new and may change in a future release.
107
+ */
108
+ export function LegendSwatch({
109
+ shape = 'circle',
110
+ color,
111
+ isStrokeColor = false,
112
+ fillPattern = 'solid',
113
+ strokeStyle = 'solid',
114
+ size = DEFAULT_SWATCH_SIZE,
115
+ }: LegendSwatchProps) {
116
+ const patternId = useId()
117
+
118
+ // ── Line ──────────────────────────────────────────────────────────────────
119
+ if (shape === 'line') {
120
+ const { dash, round } = lineDash(strokeStyle)
121
+ return (
122
+ <svg
123
+ width={size}
124
+ height={size}
125
+ viewBox={`0 0 ${size} ${size}`}
126
+ aria-hidden
127
+ data-shape='line'
128
+ style={{ display: 'block', overflow: 'visible' }}
129
+ >
130
+ <line
131
+ x1={0}
132
+ y1={size / 2}
133
+ x2={size}
134
+ y2={size / 2}
135
+ stroke={color}
136
+ strokeWidth={LINE_WIDTH}
137
+ strokeDasharray={dash}
138
+ strokeLinecap={round ? 'round' : 'butt'}
139
+ />
140
+ </svg>
141
+ )
142
+ }
143
+
144
+ // ── Square / circle ────────────────────────────────────────────────────────
145
+ const inset = isStrokeColor ? STROKE_WIDTH / 2 : 0
146
+ const usePattern = !isStrokeColor && fillPattern !== 'solid'
147
+ const tile = usePattern ? patternTile(fillPattern, color) : null
148
+
149
+ // corner-marks only apply to a stroked square.
150
+ const isCorners =
151
+ isStrokeColor && strokeStyle === 'corners' && shape === 'square'
152
+
153
+ const { dash, round } = isStrokeColor
154
+ ? borderDash(strokeStyle)
155
+ : { dash: undefined, round: false }
156
+
157
+ const commonStroke = {
158
+ stroke: color,
159
+ strokeWidth: isStrokeColor ? STROKE_WIDTH : HAIRLINE,
160
+ strokeDasharray: isStrokeColor ? dash : undefined,
161
+ strokeLinecap: round ? ('round' as const) : ('butt' as const),
162
+ }
163
+
164
+ const fill = isStrokeColor
165
+ ? 'none'
166
+ : usePattern
167
+ ? `url(#${patternId})`
168
+ : color
169
+
170
+ return (
171
+ <svg
172
+ width={size}
173
+ height={size}
174
+ viewBox={`0 0 ${size} ${size}`}
175
+ aria-hidden
176
+ data-shape={shape}
177
+ style={{ display: 'block' }}
178
+ >
179
+ {tile && (
180
+ <defs>
181
+ <pattern
182
+ id={patternId}
183
+ patternUnits='userSpaceOnUse'
184
+ width={tile.size}
185
+ height={tile.size}
186
+ >
187
+ {tile.content}
188
+ </pattern>
189
+ </defs>
190
+ )}
191
+
192
+ {isCorners ? (
193
+ <CornerMarks size={size} color={color} />
194
+ ) : shape === 'circle' ? (
195
+ <circle
196
+ cx={size / 2}
197
+ cy={size / 2}
198
+ r={size / 2 - inset}
199
+ fill={fill}
200
+ {...commonStroke}
201
+ // A solid-color filled circle needs no border.
202
+ stroke={isStrokeColor || usePattern ? color : 'none'}
203
+ />
204
+ ) : (
205
+ <rect
206
+ x={inset}
207
+ y={inset}
208
+ width={size - inset * 2}
209
+ height={size - inset * 2}
210
+ fill={fill}
211
+ {...commonStroke}
212
+ stroke={isStrokeColor || usePattern ? color : 'none'}
213
+ />
214
+ )}
215
+ </svg>
216
+ )
217
+ }
218
+
219
+ /** Four L-shaped corner brackets for the `corners` border style (square). */
220
+ function CornerMarks({ size, color }: { size: number; color: string }) {
221
+ const arm = Math.max(2, size * 0.3)
222
+ const o = STROKE_WIDTH / 2
223
+ const max = size - o
224
+ const props = {
225
+ stroke: color,
226
+ strokeWidth: STROKE_WIDTH,
227
+ fill: 'none',
228
+ }
229
+ return (
230
+ <>
231
+ <path d={`M${o} ${o + arm} V${o} H${o + arm}`} {...props} />
232
+ <path d={`M${max - arm} ${o} H${max} V${o + arm}`} {...props} />
233
+ <path d={`M${max} ${max - arm} V${max} H${max - arm}`} {...props} />
234
+ <path d={`M${o + arm} ${max} H${o} V${max - arm}`} {...props} />
235
+ </>
236
+ )
237
+ }
@@ -0,0 +1,127 @@
1
+ import { useEffect } from 'react'
2
+ import { IconButton } from '@mui/material'
3
+ import {
4
+ VisibilityOutlined as VisibilityIcon,
5
+ VisibilityOffOutlined as VisibilityOffIcon,
6
+ } from '@mui/icons-material'
7
+ import {
8
+ selectGroupVisibility,
9
+ useLegendId,
10
+ useLegendShallow,
11
+ useLegendStore,
12
+ type LegendVisibility,
13
+ } from '../../stores'
14
+ import { useLegendConfig } from '../../provider'
15
+ import type { LegendLabels } from '../../provider/labels'
16
+ import { VisibilityIndeterminateIcon } from '../icons'
17
+ import { useLegendGroupId, useLegendRow } from '../contexts'
18
+
19
+ const ICONS = {
20
+ visible: VisibilityIcon,
21
+ hidden: VisibilityOffIcon,
22
+ mixed: VisibilityIndeterminateIcon,
23
+ } as const
24
+
25
+ export type LegendVisibilityToggleProps = {
26
+ /**
27
+ * Override the aria labels (defaults: the layer pair in rows and controlled
28
+ * mode, the group pair in group headers) — e.g.
29
+ * `labels={{ hideLayer: 'Hide section', showLayer: 'Show section' }}`.
30
+ */
31
+ labels?: Pick<LegendLabels, 'hideLayer' | 'showLayer'>
32
+ } & (
33
+ | {
34
+ /**
35
+ * Controlled mode: current state — renders the matching eye (`mixed` →
36
+ * the indeterminate icon). Use it for element kinds beyond layer/group.
37
+ */
38
+ visibility: LegendVisibility
39
+ /**
40
+ * Controlled mode: called with the **next** visibility (`hidden` →
41
+ * `true`; `visible`/`mixed` → `false`) — mirrors the store setters.
42
+ */
43
+ onToggle: (visible: boolean) => void
44
+ }
45
+ | { visibility?: never; onToggle?: never }
46
+ )
47
+
48
+ /**
49
+ * Agnostic visibility eye. Pass `visibility` + `onToggle` to control it for
50
+ * any element kind (no context needed); without them it wires itself to the
51
+ * nearest context — the layer eye inside a `Legend.Row`, the tri-state group
52
+ * eye (cascading to every member) inside a `Legend.Group`. Pinned `.active`
53
+ * in the hover-fade group while not fully visible. Renders nothing without
54
+ * props or context (dev-only warning).
55
+ *
56
+ * @experimental This API is new and may change in a future release.
57
+ */
58
+ export function LegendVisibilityToggle({
59
+ visibility,
60
+ onToggle,
61
+ labels,
62
+ }: LegendVisibilityToggleProps) {
63
+ const id = useLegendId()
64
+ const row = useLegendRow()
65
+ const groupId = useLegendGroupId()
66
+ const { labels: configLabels } = useLegendConfig()
67
+
68
+ const controlled = visibility !== undefined && onToggle !== undefined
69
+
70
+ const layerVisible = useLegendStore(id, (s) =>
71
+ !controlled && row ? (s.layers[row.layerId]?.visible ?? true) : true,
72
+ )
73
+ const groupVisibility = useLegendShallow(id, (s) =>
74
+ !controlled && !row && groupId
75
+ ? selectGroupVisibility(s, groupId)
76
+ : 'visible',
77
+ )
78
+ const setVisibility = useLegendStore(id, (s) => s.setVisibility)
79
+ const setGroupVisibility = useLegendStore(id, (s) => s.setGroupVisibility)
80
+
81
+ useEffect(() => {
82
+ if (!controlled && !row && !groupId) {
83
+ // eslint-disable-next-line no-console
84
+ console.warn(
85
+ 'Legend.VisibilityToggle needs `visibility`/`onToggle` props or a Legend.Row / Legend.Group context.',
86
+ )
87
+ }
88
+ }, [controlled, row, groupId])
89
+
90
+ // Resolve the tri-state + handler + label pair for the three modes.
91
+ let state: LegendVisibility
92
+ let handleToggle: () => void
93
+ let labelPair: Pick<LegendLabels, 'hideLayer' | 'showLayer'> | undefined =
94
+ labels
95
+
96
+ if (controlled) {
97
+ state = visibility
98
+ handleToggle = () => onToggle(visibility === 'hidden')
99
+ } else if (row) {
100
+ state = layerVisible ? 'visible' : 'hidden'
101
+ handleToggle = () => setVisibility(row.layerId, !layerVisible)
102
+ } else if (groupId) {
103
+ state = groupVisibility
104
+ handleToggle = () =>
105
+ setGroupVisibility(groupId, groupVisibility === 'hidden')
106
+ labelPair = labels ?? {
107
+ hideLayer: configLabels.hideGroup,
108
+ showLayer: configLabels.showGroup,
109
+ }
110
+ } else {
111
+ return null
112
+ }
113
+
114
+ const { hideLayer, showLayer } = labelPair ?? configLabels
115
+ const EyeIcon = ICONS[state]
116
+
117
+ return (
118
+ <IconButton
119
+ size='small'
120
+ className={state === 'visible' ? undefined : 'active'}
121
+ onClick={handleToggle}
122
+ aria-label={state === 'hidden' ? showLayer : hideLayer}
123
+ >
124
+ <EyeIcon fontSize='small' />
125
+ </IconButton>
126
+ )
127
+ }