@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.
- package/dist/{category-Dnd2_j0x.js → category-CGS_eHr4.js} +2 -2
- package/dist/{category-Dnd2_j0x.js.map → category-CGS_eHr4.js.map} +1 -1
- package/dist/{change-column-DjjwoPt1.js → change-column-CiVAjOUB.js} +2 -2
- package/dist/{change-column-DjjwoPt1.js.map → change-column-CiVAjOUB.js.map} +1 -1
- package/dist/components.js +1232 -777
- package/dist/components.js.map +1 -1
- package/dist/{data-zoom-layout-CkVnm6ej.js → data-zoom-layout-D_FYdnap.js} +2 -2
- package/dist/{data-zoom-layout-CkVnm6ej.js.map → data-zoom-layout-D_FYdnap.js.map} +1 -1
- package/dist/{download-config-oJIFZ2WC.js → download-config-OKlLLSW_.js} +2 -2
- package/dist/{download-config-oJIFZ2WC.js.map → download-config-OKlLLSW_.js.map} +1 -1
- package/dist/{lasso-tool-CDFj4zKY.js → lasso-tool-YLFRgb-S.js} +182 -154
- package/dist/lasso-tool-YLFRgb-S.js.map +1 -0
- package/dist/legend/stores.js +20 -0
- package/dist/legend/stores.js.map +1 -0
- package/dist/legend.js +1536 -0
- package/dist/legend.js.map +1 -0
- package/dist/selectors-DwHj6mE2.js +388 -0
- package/dist/selectors-DwHj6mE2.js.map +1 -0
- package/dist/{spread-DYNpzgh_.js → spread-4MYPOntd.js} +2 -2
- package/dist/{spread-DYNpzgh_.js.map → spread-4MYPOntd.js.map} +1 -1
- package/dist/{table-B3ZWWhJt.js → table-CTcLOycx.js} +2 -2
- package/dist/{table-B3ZWWhJt.js.map → table-CTcLOycx.js.map} +1 -1
- package/dist/types/components/index.d.ts +5 -2
- package/dist/types/components/lasso-tool/geometry-toolbar.d.ts +13 -0
- package/dist/types/components/lasso-tool/icons.d.ts +6 -0
- package/dist/types/components/lasso-tool/lasso-tool.d.ts +1 -1
- package/dist/types/components/lasso-tool/layer-filters.d.ts +10 -0
- package/dist/types/components/lasso-tool/styles.d.ts +1 -1
- package/dist/types/components/lasso-tool/types.d.ts +62 -1
- package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
- package/dist/types/legend/components/contexts.d.ts +20 -0
- package/dist/types/legend/components/icons.d.ts +12 -0
- package/dist/types/legend/components/index.d.ts +26 -0
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
- package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
- package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
- package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
- package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
- package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
- package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
- package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
- package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
- package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
- package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
- package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
- package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
- package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
- package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
- package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
- package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
- package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
- package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
- package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
- package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
- package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
- package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
- package/dist/types/legend/index.d.ts +88 -0
- package/dist/types/legend/provider/index.d.ts +6 -0
- package/dist/types/legend/provider/labels.d.ts +27 -0
- package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
- package/dist/types/legend/provider/legend-provider.d.ts +38 -0
- package/dist/types/legend/stores/index.d.ts +5 -0
- package/dist/types/legend/stores/legend-context.d.ts +4 -0
- package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
- package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
- package/dist/types/legend/stores/selectors.d.ts +33 -0
- package/dist/types/legend/stores/types.d.ts +275 -0
- package/dist/widgets/actions.js +1 -1
- package/dist/widgets/bar.js +1 -1
- package/dist/widgets/category.js +1 -1
- package/dist/widgets/formula.js +1 -1
- package/dist/widgets/histogram.js +1 -1
- package/dist/widgets/markdown.js +1 -1
- package/dist/widgets/pie.js +1 -1
- package/dist/widgets/scatterplot.js +1 -1
- package/dist/widgets/spread.js +1 -1
- package/dist/widgets/table.js +1 -1
- package/dist/widgets/timeseries.js +1 -1
- package/dist/widgets/utils.js +1 -1
- package/dist/widgets/wrapper.js +1 -1
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/bar.js +2 -2
- package/dist/widgets-v2/category.js +2 -2
- package/dist/widgets-v2/formula.js +1 -1
- package/dist/widgets-v2/histogram.js +2 -2
- package/dist/widgets-v2/markdown.js +1 -1
- package/dist/widgets-v2/pie.js +1 -1
- package/dist/widgets-v2/scatterplot.js +1 -1
- package/dist/widgets-v2/spread.js +2 -2
- package/dist/widgets-v2/table.js +3 -3
- package/dist/widgets-v2/timeseries.js +2 -2
- package/dist/widgets-v2/utils.js +1 -1
- package/dist/widgets-v2.js +5 -5
- package/package.json +11 -3
- package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
- package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
- package/src/components/index.ts +10 -0
- package/src/components/lasso-tool/const.tsx +1 -0
- package/src/components/lasso-tool/geometry-toolbar.test.tsx +138 -0
- package/src/components/lasso-tool/geometry-toolbar.tsx +258 -0
- package/src/components/lasso-tool/icons.tsx +40 -0
- package/src/components/lasso-tool/lasso-tool.tsx +25 -1
- package/src/components/lasso-tool/layer-filters.test.tsx +90 -0
- package/src/components/lasso-tool/layer-filters.tsx +236 -0
- package/src/components/lasso-tool/styles.ts +1 -1
- package/src/components/lasso-tool/types.ts +66 -1
- package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
- package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
- package/src/legend/components/contexts.ts +32 -0
- package/src/legend/components/icons.tsx +22 -0
- package/src/legend/components/index.ts +39 -0
- package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
- package/src/legend/components/legend-actions/styles.ts +9 -0
- package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
- package/src/legend/components/legend-category/styles.ts +61 -0
- package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
- package/src/legend/components/legend-config-select/styles.ts +48 -0
- package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
- package/src/legend/components/legend-group/legend-group.tsx +88 -0
- package/src/legend/components/legend-group/styles.ts +82 -0
- package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
- package/src/legend/components/legend-icon/styles.ts +56 -0
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
- package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
- package/src/legend/components/legend-item/legend-item.tsx +34 -0
- package/src/legend/components/legend-item/styles.ts +11 -0
- package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
- package/src/legend/components/legend-opacity/styles.ts +28 -0
- package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
- package/src/legend/components/legend-proportion/styles.ts +43 -0
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
- package/src/legend/components/legend-ramp/styles.ts +62 -0
- package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
- package/src/legend/components/legend-row/legend-row.tsx +129 -0
- package/src/legend/components/legend-row/styles.ts +111 -0
- package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
- package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
- package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
- package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
- package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
- package/src/legend/index.ts +121 -0
- package/src/legend/provider/index.ts +6 -0
- package/src/legend/provider/labels.ts +59 -0
- package/src/legend/provider/legend-config-context.ts +15 -0
- package/src/legend/provider/legend-provider.tsx +102 -0
- package/src/legend/stores/index.ts +46 -0
- package/src/legend/stores/legend-context.ts +17 -0
- package/src/legend/stores/legend-store-registry.ts +639 -0
- package/src/legend/stores/legend-store.test.ts +457 -0
- package/src/legend/stores/selectors.ts +65 -0
- package/src/legend/stores/types.ts +347 -0
- package/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
|
+
}
|