@carto/ps-react-ui 4.17.2 → 4.18.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-Ct2BzADB.js +753 -0
- package/dist/category-Ct2BzADB.js.map +1 -0
- package/dist/change-column-Bhcxmp-r.js +1148 -0
- package/dist/change-column-Bhcxmp-r.js.map +1 -0
- package/dist/echart-B10dhEaM.js +262 -0
- package/dist/echart-B10dhEaM.js.map +1 -0
- package/dist/legend/stores.js +1 -1
- package/dist/{legend-store-registry-Bo8U_qWM.js → legend-store-registry-p1tSwJXH.js} +151 -147
- package/dist/legend-store-registry-p1tSwJXH.js.map +1 -0
- package/dist/legend.js +473 -415
- package/dist/legend.js.map +1 -1
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +2 -2
- package/dist/types/legend/components/legend-group/styles.d.ts +31 -20
- package/dist/types/legend/components/legend-row/styles.d.ts +24 -4
- package/dist/types/legend/components/legend-sortable/styles.d.ts +4 -3
- package/dist/types/widgets-v2/actions/lock-selection/lock-selection.d.ts +15 -5
- package/dist/types/widgets-v2/category/components/category-legend.d.ts +18 -2
- package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +6 -1
- package/dist/types/widgets-v2/category/style.d.ts +8 -0
- package/dist/types/widgets-v2/echart/use-chart-selection.d.ts +10 -4
- package/dist/types/widgets-v2/histogram/index.d.ts +1 -1
- package/dist/types/widgets-v2/histogram/transforms.d.ts +11 -0
- package/dist/types/widgets-v2/histogram/types.d.ts +9 -0
- package/dist/types/widgets-v2/scatterplot/index.d.ts +1 -1
- package/dist/types/widgets-v2/scatterplot/transforms.d.ts +9 -0
- package/dist/types/widgets-v2/table/index.d.ts +1 -0
- package/dist/types/widgets-v2/table/transforms.d.ts +13 -0
- package/dist/types/widgets-v2/table/transforms.test.d.ts +1 -0
- package/dist/types/widgets-v2/timeseries/index.d.ts +1 -0
- package/dist/types/widgets-v2/timeseries/transforms.d.ts +10 -0
- package/dist/types/widgets-v2/timeseries/transforms.test.d.ts +1 -0
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/echart.js +1 -1
- package/dist/widgets-v2/echart.js.map +1 -1
- package/dist/widgets-v2/histogram.js +181 -158
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/pie.js +155 -116
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +91 -85
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/table.js +55 -45
- package/dist/widgets-v2/table.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +96 -85
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +4 -4
- package/package.json +3 -3
- package/src/legend/components/legend-actions/legend-actions.tsx +2 -2
- package/src/legend/components/legend-group/styles.ts +59 -36
- package/src/legend/components/legend-row/legend-row.tsx +10 -7
- package/src/legend/components/legend-row/styles.ts +41 -10
- package/src/legend/components/legend-sortable/styles.ts +13 -4
- package/src/legend/stores/legend-store-registry.ts +28 -4
- package/src/legend/stores/legend-store.test.ts +23 -0
- package/src/widgets-v2/actions/lock-selection/lock-selection.test.tsx +62 -0
- package/src/widgets-v2/actions/lock-selection/lock-selection.tsx +45 -13
- package/src/widgets-v2/category/category-ui.test.tsx +55 -0
- package/src/widgets-v2/category/category-ui.tsx +65 -6
- package/src/widgets-v2/category/components/category-legend.test.tsx +53 -2
- package/src/widgets-v2/category/components/category-legend.tsx +55 -12
- package/src/widgets-v2/category/components/category-row-stacked.test.tsx +7 -3
- package/src/widgets-v2/category/components/category-row-stacked.tsx +8 -3
- package/src/widgets-v2/category/style.ts +10 -2
- package/src/widgets-v2/echart/echart-ui.test.tsx +25 -0
- package/src/widgets-v2/echart/echart-ui.tsx +20 -0
- package/src/widgets-v2/echart/use-chart-selection.test.tsx +3 -1
- package/src/widgets-v2/echart/use-chart-selection.ts +10 -4
- package/src/widgets-v2/histogram/index.ts +4 -1
- package/src/widgets-v2/histogram/options.test.ts +86 -0
- package/src/widgets-v2/histogram/options.ts +50 -0
- package/src/widgets-v2/histogram/transforms.test.ts +36 -1
- package/src/widgets-v2/histogram/transforms.ts +23 -0
- package/src/widgets-v2/histogram/types.ts +9 -0
- package/src/widgets-v2/pie/options.test.ts +262 -0
- package/src/widgets-v2/pie/options.ts +80 -5
- package/src/widgets-v2/scatterplot/index.ts +4 -1
- package/src/widgets-v2/scatterplot/transforms.test.ts +38 -1
- package/src/widgets-v2/scatterplot/transforms.ts +23 -0
- package/src/widgets-v2/table/index.ts +1 -0
- package/src/widgets-v2/table/transforms.test.ts +40 -0
- package/src/widgets-v2/table/transforms.ts +26 -0
- package/src/widgets-v2/timeseries/index.ts +1 -0
- package/src/widgets-v2/timeseries/transforms.test.ts +49 -0
- package/src/widgets-v2/timeseries/transforms.ts +31 -0
- package/dist/category-CGS_eHr4.js +0 -719
- package/dist/category-CGS_eHr4.js.map +0 -1
- package/dist/change-column-CiVAjOUB.js +0 -1143
- package/dist/change-column-CiVAjOUB.js.map +0 -1
- package/dist/echart-Bdvbfx9s.js +0 -250
- package/dist/echart-Bdvbfx9s.js.map +0 -1
- package/dist/legend-store-registry-Bo8U_qWM.js.map +0 -1
|
@@ -15,16 +15,46 @@ export const styles = {
|
|
|
15
15
|
borderTop: '1px solid',
|
|
16
16
|
borderColor: 'divider',
|
|
17
17
|
},
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
},
|
|
19
|
+
// The original stays in place, dimmed, while its overlay clone is dragged.
|
|
20
|
+
groupDragging: {
|
|
21
|
+
opacity: 0.4,
|
|
22
|
+
},
|
|
23
|
+
// Figma: 48px tall, 16px left inset (matches the row's own left inset),
|
|
24
|
+
// 8px gap to the label, actions right-aligned with ~8px edge inset (the
|
|
25
|
+
// small icon-button padding brings the glyphs to the design's 16px optical
|
|
26
|
+
// inset), hairline divider below. Sticky: pins to the top of the panel's
|
|
27
|
+
// scroll container.
|
|
28
|
+
header: {
|
|
29
|
+
display: 'flex',
|
|
30
|
+
alignItems: 'center',
|
|
31
|
+
gap: 1,
|
|
32
|
+
padding: ({ spacing }) => spacing(1.5, 2, 1.5, 2),
|
|
33
|
+
minHeight: LEGEND_GROUP_HEADER_HEIGHT,
|
|
34
|
+
borderBottom: '1px solid',
|
|
35
|
+
borderColor: 'divider',
|
|
36
|
+
position: 'sticky',
|
|
37
|
+
top: 0,
|
|
38
|
+
// Above the sticky row headers (2), which in turn sit above MUI's
|
|
39
|
+
// z-index-1 form internals in row content.
|
|
40
|
+
zIndex: 3,
|
|
41
|
+
// Distinct from the row header / panel body (both `background.paper`) so
|
|
42
|
+
// the group header visually separates from the rest.
|
|
43
|
+
backgroundColor: 'background.default',
|
|
44
|
+
// Hover-fade group actions (widgets-v2 Wrapper parity). Scoped to the
|
|
45
|
+
// header itself (not the group root) so hovering a member row's body
|
|
46
|
+
// below never lights up the group's own actions/handle — only hovering
|
|
47
|
+
// the header (title/handle/actions) does. A control carrying `.active`
|
|
48
|
+
// (hidden group) stays visible. Touch devices always show them. The
|
|
49
|
+
// collapse chevron sits outside the fade group. Each non-active child
|
|
50
|
+
// also collapses to zero width on its own (not just the whole actions
|
|
51
|
+
// slot) so a sibling's `.active` pin (e.g. the eye on a mixed-visibility
|
|
52
|
+
// group) doesn't leave a same-sized invisible hole where the still-faded
|
|
53
|
+
// ⋮ menu would've been.
|
|
24
54
|
'& .PsLegend-groupFade > *': {
|
|
25
55
|
opacity: 1,
|
|
26
56
|
transition: ({ transitions }: Theme) =>
|
|
27
|
-
transitions.create('opacity', {
|
|
57
|
+
transitions.create(['opacity', 'width'], {
|
|
28
58
|
duration: transitions.duration.standard,
|
|
29
59
|
easing: transitions.easing.easeInOut,
|
|
30
60
|
}),
|
|
@@ -35,14 +65,21 @@ export const styles = {
|
|
|
35
65
|
opacity: 1,
|
|
36
66
|
},
|
|
37
67
|
},
|
|
38
|
-
'&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
|
|
39
|
-
opacity: 1,
|
|
40
|
-
},
|
|
41
68
|
// Faded actions don't reserve flex space on fine-pointer devices — the
|
|
42
69
|
// label grows to the trailing edge. Hover/focus-within restores the slot
|
|
43
70
|
// so the label reflows with the buttons; a dirty `.active` child keeps
|
|
44
71
|
// the slot open even outside hover.
|
|
45
72
|
'@media (hover: hover)': {
|
|
73
|
+
'& .PsLegend-groupFade > *:not(.active)': {
|
|
74
|
+
width: 0,
|
|
75
|
+
minWidth: 0,
|
|
76
|
+
// border-box IconButtons keep their padding inside `width` — zero it
|
|
77
|
+
// too, otherwise the fixed 2px+2px padding survives as a tiny but
|
|
78
|
+
// needless residual gap.
|
|
79
|
+
padding: 0,
|
|
80
|
+
overflow: 'hidden',
|
|
81
|
+
pointerEvents: 'none',
|
|
82
|
+
},
|
|
46
83
|
'& .PsLegend-groupActions:not(:has(.active))': {
|
|
47
84
|
width: 0,
|
|
48
85
|
minWidth: 0,
|
|
@@ -52,6 +89,14 @@ export const styles = {
|
|
|
52
89
|
pointerEvents: 'none',
|
|
53
90
|
},
|
|
54
91
|
},
|
|
92
|
+
'&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
|
|
93
|
+
opacity: 1,
|
|
94
|
+
width: 'auto',
|
|
95
|
+
minWidth: 0,
|
|
96
|
+
padding: '2px',
|
|
97
|
+
overflow: 'visible',
|
|
98
|
+
pointerEvents: 'auto',
|
|
99
|
+
},
|
|
55
100
|
'&:hover .PsLegend-groupActions, &:focus-within .PsLegend-groupActions': {
|
|
56
101
|
width: 'auto',
|
|
57
102
|
minWidth: 0,
|
|
@@ -78,31 +123,6 @@ export const styles = {
|
|
|
78
123
|
opacity: 1,
|
|
79
124
|
},
|
|
80
125
|
},
|
|
81
|
-
// The original stays in place, dimmed, while its overlay clone is dragged.
|
|
82
|
-
groupDragging: {
|
|
83
|
-
opacity: 0.4,
|
|
84
|
-
},
|
|
85
|
-
// Figma: 48px tall, icon at 12px from the left, 8px gap to the label,
|
|
86
|
-
// actions right-aligned with ~8px edge inset (the small icon-button padding
|
|
87
|
-
// brings the glyphs to the design's 16px optical inset), hairline divider
|
|
88
|
-
// below. Sticky: pins to the top of the panel's scroll container.
|
|
89
|
-
header: {
|
|
90
|
-
display: 'flex',
|
|
91
|
-
alignItems: 'center',
|
|
92
|
-
gap: 1,
|
|
93
|
-
padding: ({ spacing }) => spacing(1.5, 2, 1.5, 1),
|
|
94
|
-
minHeight: LEGEND_GROUP_HEADER_HEIGHT,
|
|
95
|
-
borderBottom: '1px solid',
|
|
96
|
-
borderColor: 'divider',
|
|
97
|
-
position: 'sticky',
|
|
98
|
-
top: 0,
|
|
99
|
-
// Above the sticky row headers (2), which in turn sit above MUI's
|
|
100
|
-
// z-index-1 form internals in row content.
|
|
101
|
-
zIndex: 3,
|
|
102
|
-
// Distinct from the row header / panel body (both `background.paper`) so
|
|
103
|
-
// the group header visually separates from the rest.
|
|
104
|
-
backgroundColor: 'background.default',
|
|
105
|
-
},
|
|
106
126
|
// Accordion click target: everything in the header but the actions box.
|
|
107
127
|
// Non-native button (role='button'), so it carries its own focus ring.
|
|
108
128
|
titleArea: {
|
|
@@ -123,8 +143,11 @@ export const styles = {
|
|
|
123
143
|
display: 'flex',
|
|
124
144
|
color: 'text.secondary',
|
|
125
145
|
},
|
|
146
|
+
// No `flex:1` here — the label sizes to its own text (shrinking to
|
|
147
|
+
// ellipsis when too long) so the chevron sits right after it, instead of
|
|
148
|
+
// being dragged to the trailing edge of `titleArea` (which keeps its own
|
|
149
|
+
// `flex:1` to fill the header up to the actions).
|
|
126
150
|
label: {
|
|
127
|
-
flex: 1,
|
|
128
151
|
minWidth: 0,
|
|
129
152
|
overflow: 'hidden',
|
|
130
153
|
textOverflow: 'ellipsis',
|
|
@@ -74,7 +74,10 @@ export function LegendRow({ layerId, children }: LegendRowProps) {
|
|
|
74
74
|
sortableProps?.dragging === true && styles.rowDragging,
|
|
75
75
|
]}
|
|
76
76
|
>
|
|
77
|
-
<Box
|
|
77
|
+
<Box
|
|
78
|
+
sx={[styles.header, inGroup && styles.headerGrouped]}
|
|
79
|
+
style={{ top: stickyTop }}
|
|
80
|
+
>
|
|
78
81
|
{sortableProps?.handle}
|
|
79
82
|
<Box
|
|
80
83
|
sx={[styles.titleBox, canToggle && styles.titleBoxInteractive]}
|
|
@@ -122,16 +125,16 @@ export function LegendRow({ layerId, children }: LegendRowProps) {
|
|
|
122
125
|
|
|
123
126
|
<Collapse in={!effectivelyCollapsed}>
|
|
124
127
|
<Box
|
|
125
|
-
sx={
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
128
|
+
sx={[
|
|
129
|
+
styles.content,
|
|
130
|
+
inGroup && styles.contentGrouped,
|
|
131
|
+
!layer.visible && styles.dimmed,
|
|
132
|
+
]}
|
|
130
133
|
>
|
|
131
134
|
{body}
|
|
132
135
|
</Box>
|
|
133
136
|
{layer.helperText && (
|
|
134
|
-
<Box sx={styles.footer}>
|
|
137
|
+
<Box sx={[styles.footer, inGroup && styles.footerGrouped]}>
|
|
135
138
|
{typeof layer.helperText === 'string' ? (
|
|
136
139
|
<Typography variant='caption' color='text.secondary'>
|
|
137
140
|
{layer.helperText}
|
|
@@ -16,14 +16,16 @@ export const styles = {
|
|
|
16
16
|
// `.PsLegend-rowFade` are hidden on fine-pointer devices and fade in when
|
|
17
17
|
// the row is hovered or holds focus; a control carrying `.active` (dirty
|
|
18
18
|
// state — hidden layer, open menu, opacity ≠ 1) stays visible. Coarse-
|
|
19
|
-
// pointer (touch) devices always show them.
|
|
20
|
-
// collapses to zero width
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// pointer (touch) devices always show them. Each non-active child also
|
|
20
|
+
// collapses to zero width on its own (not just the whole actions slot —
|
|
21
|
+
// see `PsLegend-rowActions` below) so a sibling's `.active` pin (e.g. the
|
|
22
|
+
// eye while hidden) doesn't leave a same-sized invisible hole where the
|
|
23
|
+
// still-faded ⋮ menu would've been; the title grows into that space. The
|
|
24
|
+
// collapse chevron sits outside the fade group and never fades.
|
|
23
25
|
'& .PsLegend-rowFade > *': {
|
|
24
26
|
opacity: 1,
|
|
25
27
|
transition: ({ transitions }: Theme) =>
|
|
26
|
-
transitions.create('opacity', {
|
|
28
|
+
transitions.create(['opacity', 'width'], {
|
|
27
29
|
duration: transitions.duration.standard,
|
|
28
30
|
easing: transitions.easing.easeInOut,
|
|
29
31
|
}),
|
|
@@ -34,14 +36,21 @@ export const styles = {
|
|
|
34
36
|
opacity: 1,
|
|
35
37
|
},
|
|
36
38
|
},
|
|
37
|
-
'&:hover .PsLegend-rowFade > *, &:focus-within .PsLegend-rowFade > *': {
|
|
38
|
-
opacity: 1,
|
|
39
|
-
},
|
|
40
39
|
// Faded actions don't reserve flex space on fine-pointer devices — the
|
|
41
40
|
// title grows to the trailing edge. Hover/focus-within restores the slot
|
|
42
41
|
// so the title reflows with the buttons; a dirty `.active` child keeps
|
|
43
42
|
// the slot open even outside hover.
|
|
44
43
|
'@media (hover: hover)': {
|
|
44
|
+
'& .PsLegend-rowFade > *:not(.active)': {
|
|
45
|
+
width: 0,
|
|
46
|
+
minWidth: 0,
|
|
47
|
+
// border-box IconButtons keep their padding inside `width` — zero it
|
|
48
|
+
// too, otherwise the fixed 2px+2px padding survives as a tiny but
|
|
49
|
+
// needless residual gap.
|
|
50
|
+
padding: 0,
|
|
51
|
+
overflow: 'hidden',
|
|
52
|
+
pointerEvents: 'none',
|
|
53
|
+
},
|
|
45
54
|
'& .PsLegend-rowActions:not(:has(.active))': {
|
|
46
55
|
width: 0,
|
|
47
56
|
minWidth: 0,
|
|
@@ -51,6 +60,14 @@ export const styles = {
|
|
|
51
60
|
pointerEvents: 'none',
|
|
52
61
|
},
|
|
53
62
|
},
|
|
63
|
+
'&:hover .PsLegend-rowFade > *, &:focus-within .PsLegend-rowFade > *': {
|
|
64
|
+
opacity: 1,
|
|
65
|
+
width: 'auto',
|
|
66
|
+
minWidth: 0,
|
|
67
|
+
padding: '2px',
|
|
68
|
+
overflow: 'visible',
|
|
69
|
+
pointerEvents: 'auto',
|
|
70
|
+
},
|
|
54
71
|
'&:hover .PsLegend-rowActions, &:focus-within .PsLegend-rowActions': {
|
|
55
72
|
width: 'auto',
|
|
56
73
|
minWidth: 0,
|
|
@@ -91,7 +108,9 @@ export const styles = {
|
|
|
91
108
|
// band (subtitle flows below them) — hence `flex-start` with the title
|
|
92
109
|
// optically centered against the 30px icon-button row. Sticky: pins inside
|
|
93
110
|
// the panel's scroll container; `top` is applied inline per row (0, or the
|
|
94
|
-
// group-header height for grouped rows).
|
|
111
|
+
// group-header height for grouped rows). Grouped rows get an extra 20px
|
|
112
|
+
// left inset (36px total) so member layers read as nested under the group
|
|
113
|
+
// and line up with the group's own icon+label text start.
|
|
95
114
|
header: {
|
|
96
115
|
display: 'flex',
|
|
97
116
|
alignItems: 'flex-start',
|
|
@@ -104,6 +123,9 @@ export const styles = {
|
|
|
104
123
|
zIndex: 2,
|
|
105
124
|
backgroundColor: 'background.paper',
|
|
106
125
|
},
|
|
126
|
+
headerGrouped: {
|
|
127
|
+
paddingLeft: 4.5,
|
|
128
|
+
},
|
|
107
129
|
titleBox: {
|
|
108
130
|
flex: 1,
|
|
109
131
|
minWidth: 0,
|
|
@@ -130,8 +152,11 @@ export const styles = {
|
|
|
130
152
|
alignItems: 'center',
|
|
131
153
|
gap: 0.5,
|
|
132
154
|
},
|
|
155
|
+
// No `flex:1` here — the title sizes to its own text (shrinking to
|
|
156
|
+
// ellipsis when too long) so the chevron sits right after it, instead of
|
|
157
|
+
// being dragged to the trailing edge of `titleLine` (which spans the full
|
|
158
|
+
// width of `titleBox`, itself `flex:1` to fill the header up to actions).
|
|
133
159
|
title: {
|
|
134
|
-
flex: 1,
|
|
135
160
|
minWidth: 0,
|
|
136
161
|
overflow: 'hidden',
|
|
137
162
|
textOverflow: 'ellipsis',
|
|
@@ -178,10 +203,16 @@ export const styles = {
|
|
|
178
203
|
gap: 1,
|
|
179
204
|
padding: ({ spacing }) => spacing(0.5, 2, 1, 2),
|
|
180
205
|
},
|
|
206
|
+
contentGrouped: {
|
|
207
|
+
paddingLeft: 4.5,
|
|
208
|
+
},
|
|
181
209
|
// ~16px above the note (content's 8px bottom inset + 8px here), per design.
|
|
182
210
|
footer: {
|
|
183
211
|
padding: ({ spacing }) => spacing(1, 2, 1, 2),
|
|
184
212
|
},
|
|
213
|
+
footerGrouped: {
|
|
214
|
+
paddingLeft: 4.5,
|
|
215
|
+
},
|
|
185
216
|
dimmed: {
|
|
186
217
|
opacity: 0.5,
|
|
187
218
|
},
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import type { SxProps, Theme } from '@mui/material'
|
|
2
|
+
import { LEGEND_GROUP_HEADER_HEIGHT } from '../legend-group/styles'
|
|
2
3
|
|
|
3
4
|
export const styles = {
|
|
4
|
-
// Absolute handle
|
|
5
|
+
// Absolute handle, flush against the header's top-left corner — no flex
|
|
6
|
+
// slot; sticky header is the containing block.
|
|
5
7
|
handleSlot: {
|
|
6
8
|
position: 'absolute',
|
|
7
9
|
zIndex: 1,
|
|
8
10
|
display: 'flex',
|
|
9
11
|
alignItems: 'center',
|
|
10
12
|
justifyContent: 'center',
|
|
11
|
-
|
|
13
|
+
left: 0,
|
|
14
|
+
top: 0,
|
|
12
15
|
},
|
|
16
|
+
// Both headers center their title on the same 24px band from the header's
|
|
17
|
+
// top edge (group: 48px header, `alignItems:'center'`; row: 12px top
|
|
18
|
+
// padding + 24px `titleLine`, `alignItems:'flex-start'`) — so `height:48`
|
|
19
|
+
// centers the icon on the title in both cases, even though the row's own
|
|
20
|
+
// header is only 32px tall (a 32px-tall slot would center at 16px, 8px
|
|
21
|
+
// short of the title).
|
|
13
22
|
handleSlotLayer: {
|
|
14
|
-
|
|
23
|
+
height: LEGEND_GROUP_HEADER_HEIGHT,
|
|
15
24
|
},
|
|
16
25
|
handleSlotGroup: {
|
|
17
|
-
|
|
26
|
+
height: LEGEND_GROUP_HEADER_HEIGHT,
|
|
18
27
|
},
|
|
19
28
|
handle: {
|
|
20
29
|
padding: '2px',
|
|
@@ -238,15 +238,21 @@ function mergeLayerEntity(
|
|
|
238
238
|
} as LegendLayer
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* Group counterpart of {@link mergeLayerEntity}. A group with no `prev`
|
|
243
|
+
* state (first sync) defaults collapsed when none of its member layers is
|
|
244
|
+
* visible yet — `hasVisibleLayer` is ignored once the group exists so a
|
|
245
|
+
* user's manual expand/collapse always sticks.
|
|
246
|
+
*/
|
|
242
247
|
function mergeGroupEntity(
|
|
243
248
|
prev: LegendGroup | undefined,
|
|
244
249
|
input: LegendGroupInput,
|
|
245
250
|
fallbackOrder: number,
|
|
251
|
+
hasVisibleLayer: boolean,
|
|
246
252
|
): LegendGroup {
|
|
247
253
|
return {
|
|
248
254
|
...input,
|
|
249
|
-
collapsed: prev?.collapsed ?? input.collapsed ??
|
|
255
|
+
collapsed: prev?.collapsed ?? input.collapsed ?? !hasVisibleLayer,
|
|
250
256
|
collapsedBeforeHide: prev?.collapsedBeforeHide,
|
|
251
257
|
order: prev?.order ?? input.order ?? fallbackOrder,
|
|
252
258
|
} as LegendGroup
|
|
@@ -274,9 +280,19 @@ function syncState(
|
|
|
274
280
|
}))
|
|
275
281
|
normalizeOrders(layerEntries, (l) => l.groupId)
|
|
276
282
|
|
|
283
|
+
const groupsWithVisibleLayer = new Set(
|
|
284
|
+
layerEntries
|
|
285
|
+
.filter(({ merged }) => merged.visible && merged.groupId != null)
|
|
286
|
+
.map(({ merged }) => merged.groupId!),
|
|
287
|
+
)
|
|
277
288
|
const groupEntries = groupInputs.map((input, index) => ({
|
|
278
289
|
index,
|
|
279
|
-
merged: mergeGroupEntity(
|
|
290
|
+
merged: mergeGroupEntity(
|
|
291
|
+
state.groups[input.id],
|
|
292
|
+
input,
|
|
293
|
+
index,
|
|
294
|
+
groupsWithVisibleLayer.has(input.id),
|
|
295
|
+
),
|
|
280
296
|
}))
|
|
281
297
|
normalizeOrders(groupEntries, () => undefined)
|
|
282
298
|
|
|
@@ -382,7 +398,15 @@ function upsertGroupState(
|
|
|
382
398
|
const count = Object.values(state.groups).filter(
|
|
383
399
|
(g) => g.id !== input.id,
|
|
384
400
|
).length
|
|
385
|
-
const
|
|
401
|
+
const hasVisibleLayer = Object.values(state.layers).some(
|
|
402
|
+
(l) => l.groupId === input.id && l.visible,
|
|
403
|
+
)
|
|
404
|
+
const merged = mergeGroupEntity(
|
|
405
|
+
state.groups[input.id],
|
|
406
|
+
input,
|
|
407
|
+
count,
|
|
408
|
+
hasVisibleLayer,
|
|
409
|
+
)
|
|
386
410
|
const groups = rebuildRecord(
|
|
387
411
|
state.groups,
|
|
388
412
|
{ ...state.groups, [input.id]: merged },
|
|
@@ -148,6 +148,29 @@ describe('legend store', () => {
|
|
|
148
148
|
expect(store.getState().groups.g1!.collapsed).toBe(true)
|
|
149
149
|
})
|
|
150
150
|
|
|
151
|
+
it('a group with no visible member starts collapsed', () => {
|
|
152
|
+
const store = createLegendStore('s', {
|
|
153
|
+
layers: layers.map((l) =>
|
|
154
|
+
l.groupId === 'g1' ? { ...l, visible: false } : l,
|
|
155
|
+
),
|
|
156
|
+
groups: [
|
|
157
|
+
{ id: 'g1', label: 'One' },
|
|
158
|
+
{ id: 'g2', label: 'Empty' },
|
|
159
|
+
],
|
|
160
|
+
})
|
|
161
|
+
expect(store.getState().groups.g1!.collapsed).toBe(true)
|
|
162
|
+
// No members at all also counts as "nothing enabled".
|
|
163
|
+
expect(store.getState().groups.g2!.collapsed).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('a group with at least one visible member starts expanded', () => {
|
|
167
|
+
const store = createLegendStore('s', {
|
|
168
|
+
layers,
|
|
169
|
+
groups: [{ id: 'g1', label: 'One' }],
|
|
170
|
+
})
|
|
171
|
+
expect(store.getState().groups.g1!.collapsed).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
151
174
|
it('selectGroupVisibility derives the tri-state from member layers', () => {
|
|
152
175
|
const store = createLegendStore('s', {
|
|
153
176
|
layers,
|
|
@@ -124,6 +124,68 @@ describe('<LockSelection>', () => {
|
|
|
124
124
|
expect(unlocked.flat().length).toBe(3)
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
+
it('uses a custom filterFn instead of filterByLockedItems when supplied', () => {
|
|
128
|
+
const customFilter = vi.fn(
|
|
129
|
+
(data: unknown, locked: readonly (string | number)[]) =>
|
|
130
|
+
(data as unknown[][]).map((series) =>
|
|
131
|
+
series.filter((_, i) => locked.includes(i)),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
render(
|
|
135
|
+
<Provider id='ls8' data={DATA}>
|
|
136
|
+
<LockSelection
|
|
137
|
+
selection={[0]}
|
|
138
|
+
lockedItems={[0]}
|
|
139
|
+
onLockChange={() => undefined}
|
|
140
|
+
filterFn={customFilter}
|
|
141
|
+
/>
|
|
142
|
+
</Provider>,
|
|
143
|
+
)
|
|
144
|
+
expect(customFilter).toHaveBeenCalledWith(DATA, [0])
|
|
145
|
+
const locked = getWidgetStore('ls8').getState().data as unknown[][]
|
|
146
|
+
expect(locked).toEqual([[{ name: 'A', value: 10 }]])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('does not rebuild the registered transform when filterFn gets a fresh identity but lockedItems is unchanged', () => {
|
|
150
|
+
// Mirrors the documented usage in table/transforms.ts: an inline lambda
|
|
151
|
+
// closing over `filterFn`, a new function identity on every render.
|
|
152
|
+
// `SELECTION`/`LOCKED` are hoisted so their *reference* stays stable
|
|
153
|
+
// across renders — only `filterFn`'s identity should vary here.
|
|
154
|
+
const SELECTION = ['A']
|
|
155
|
+
const LOCKED = ['A']
|
|
156
|
+
let renderCount = 0
|
|
157
|
+
const Wrapper = () => {
|
|
158
|
+
renderCount++
|
|
159
|
+
return (
|
|
160
|
+
<LockSelection
|
|
161
|
+
selection={SELECTION}
|
|
162
|
+
lockedItems={LOCKED}
|
|
163
|
+
onLockChange={() => undefined}
|
|
164
|
+
filterFn={(data, locked) => filterByLockedItems(data, locked)}
|
|
165
|
+
/>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
const { rerender } = render(
|
|
169
|
+
<Provider id='ls9' data={DATA}>
|
|
170
|
+
<Wrapper />
|
|
171
|
+
</Provider>,
|
|
172
|
+
)
|
|
173
|
+
const firstTransforms = getWidgetStore('ls9').getState().dataTransforms
|
|
174
|
+
rerender(
|
|
175
|
+
<Provider id='ls9' data={DATA}>
|
|
176
|
+
<Wrapper />
|
|
177
|
+
</Provider>,
|
|
178
|
+
)
|
|
179
|
+
expect(renderCount).toBe(2)
|
|
180
|
+
const secondTransforms = getWidgetStore('ls9').getState().dataTransforms
|
|
181
|
+
// Same reference — `useMemoPair`'s `useCallback` only depends on
|
|
182
|
+
// `lockedItems`, so a fresh inline `filterFn` each render must not
|
|
183
|
+
// rebuild the transform pair.
|
|
184
|
+
expect(secondTransforms).toBe(firstTransforms)
|
|
185
|
+
const locked = getWidgetStore('ls9').getState().data as unknown[][]
|
|
186
|
+
expect(locked).toEqual([[{ name: 'A', value: 10 }]])
|
|
187
|
+
})
|
|
188
|
+
|
|
127
189
|
it('lets the destination feed a different lockedItems set later', () => {
|
|
128
190
|
const { rerender } = render(
|
|
129
191
|
<Provider id='ls7' data={DATA}>
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
type ComponentType,
|
|
7
|
+
} from 'react'
|
|
2
8
|
import { IconButton, type SvgIconProps } from '@mui/material'
|
|
3
9
|
import { Lock as LockIcon } from '@mui/icons-material'
|
|
4
10
|
import { LockOpen as LockOpenIcon } from '@mui/icons-material'
|
|
@@ -20,28 +26,40 @@ const LOCK_SELECTION_DESCRIPTOR = {
|
|
|
20
26
|
|
|
21
27
|
export type LockSelectionKey = string | number
|
|
22
28
|
|
|
23
|
-
export interface LockSelectionProps
|
|
29
|
+
export interface LockSelectionProps<
|
|
30
|
+
K extends LockSelectionKey = LockSelectionKey,
|
|
31
|
+
> {
|
|
24
32
|
/**
|
|
25
33
|
* Currently-selected items (destination-owned). On lock, this set is
|
|
26
34
|
* snapshotted into `lockedItems` via `onLockChange`.
|
|
27
35
|
*/
|
|
28
|
-
selection: readonly
|
|
36
|
+
selection: readonly K[]
|
|
29
37
|
/**
|
|
30
38
|
* Currently-locked items (destination-owned). When non-empty, the widget's
|
|
31
39
|
* data is filtered to these items via the registered transform.
|
|
32
40
|
*/
|
|
33
|
-
lockedItems: readonly
|
|
41
|
+
lockedItems: readonly K[]
|
|
34
42
|
/**
|
|
35
43
|
* Fires when the user toggles the lock. The destination is responsible for
|
|
36
44
|
* persisting the new locked set (and re-feeding it via `lockedItems`).
|
|
37
45
|
*/
|
|
38
|
-
onLockChange: (next: readonly
|
|
46
|
+
onLockChange: (next: readonly K[]) => void
|
|
39
47
|
labels?: Partial<LockSelectionLabels>
|
|
40
48
|
/** Lock icon (default: `LockIcon`). */
|
|
41
49
|
lockIcon?: ComponentType<SvgIconProps>
|
|
42
50
|
/** Unlock icon (default: `LockOpenIcon`). */
|
|
43
51
|
unlockIcon?: ComponentType<SvgIconProps>
|
|
44
52
|
iconProps?: SvgIconProps
|
|
53
|
+
/**
|
|
54
|
+
* Data transform applied while locked. Defaults to {@link filterByLockedItems}
|
|
55
|
+
* (keeps only `{name, ...}` items whose `name` is in `lockedItems`, across a
|
|
56
|
+
* `X[][]` series shape — matches Bar/Pie/Category/Timeseries data). Widgets
|
|
57
|
+
* whose data shape or selection identity differs (bin index, composite
|
|
58
|
+
* point key, flat rows) pass their own — e.g. Histogram's
|
|
59
|
+
* `filterHistogramByLockedBins`, Scatterplot's
|
|
60
|
+
* `filterScatterplotByLockedPoints`, Table's `filterTableByLockedRows`.
|
|
61
|
+
*/
|
|
62
|
+
filterFn?: (data: unknown, lockedItems: readonly K[]) => unknown
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
/**
|
|
@@ -52,7 +70,7 @@ export interface LockSelectionProps {
|
|
|
52
70
|
*
|
|
53
71
|
* The trigger is disabled when there is nothing to lock and nothing locked.
|
|
54
72
|
*/
|
|
55
|
-
export function LockSelection({
|
|
73
|
+
export function LockSelection<K extends LockSelectionKey = LockSelectionKey>({
|
|
56
74
|
selection,
|
|
57
75
|
lockedItems,
|
|
58
76
|
onLockChange,
|
|
@@ -60,7 +78,8 @@ export function LockSelection({
|
|
|
60
78
|
lockIcon: LockSvg = LockIcon,
|
|
61
79
|
unlockIcon: UnlockSvg = LockOpenIcon,
|
|
62
80
|
iconProps,
|
|
63
|
-
|
|
81
|
+
filterFn = filterByLockedItems,
|
|
82
|
+
}: LockSelectionProps<K>) {
|
|
64
83
|
const id = useWidgetId()
|
|
65
84
|
const _labels = { ...DEFAULT_LOCK_SELECTION_LABELS, ...labels }
|
|
66
85
|
const isLocked = lockedItems.length > 0
|
|
@@ -69,7 +88,7 @@ export function LockSelection({
|
|
|
69
88
|
// Pair memoized on lockedItems identity — useTransform re-registers the fn
|
|
70
89
|
// (which closes over the current locked set) whenever the destination feeds
|
|
71
90
|
// a new array. setEnabled drives the middleware-level `enabled` flag.
|
|
72
|
-
const pairs = useMemoPair(lockedItems)
|
|
91
|
+
const pairs = useMemoPair(lockedItems, filterFn)
|
|
73
92
|
const { enabled, setEnabled } = useTransform(id, pairs, {
|
|
74
93
|
initialEnabled: isLocked,
|
|
75
94
|
})
|
|
@@ -116,14 +135,27 @@ export function LockSelection({
|
|
|
116
135
|
|
|
117
136
|
/**
|
|
118
137
|
* Builds a stable `[{ descriptor, fn }]` pair where `fn` closes over the
|
|
119
|
-
* latest `lockedItems
|
|
120
|
-
* `
|
|
138
|
+
* latest `lockedItems` and reads `filterFn` from a ref kept fresh via
|
|
139
|
+
* `useEffect` (never mutated during render — that would break the compiler's
|
|
140
|
+
* memoization guarantees). `filterFn` is deliberately NOT a `useCallback`
|
|
141
|
+
* dep — callers (see `filterTableByLockedRows`'s JSDoc) are expected to pass
|
|
142
|
+
* an inline closure like `(data, locked) => filterTableByLockedRows(data,
|
|
143
|
+
* locked, keyColumn)`, which has a new identity every render; depending on
|
|
144
|
+
* it directly would rebuild the transform pair (and everything downstream
|
|
145
|
+
* of it) every render instead of only when `lockedItems` actually changes.
|
|
146
|
+
* `fn` itself is invoked later by the data pipeline, not during this
|
|
147
|
+
* component's render, so reading a ref there is safe.
|
|
121
148
|
*/
|
|
122
|
-
function useMemoPair(
|
|
123
|
-
lockedItems: readonly
|
|
149
|
+
function useMemoPair<K extends LockSelectionKey>(
|
|
150
|
+
lockedItems: readonly K[],
|
|
151
|
+
filterFn: (data: unknown, lockedItems: readonly K[]) => unknown,
|
|
124
152
|
): readonly TransformPair[] {
|
|
153
|
+
const filterFnRef = useRef(filterFn)
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
filterFnRef.current = filterFn
|
|
156
|
+
})
|
|
125
157
|
const fn = useCallback(
|
|
126
|
-
(data: unknown) =>
|
|
158
|
+
(data: unknown) => filterFnRef.current(data, lockedItems),
|
|
127
159
|
[lockedItems],
|
|
128
160
|
)
|
|
129
161
|
return useMemo(() => [{ descriptor: LOCK_SELECTION_DESCRIPTOR, fn }], [fn])
|
|
@@ -338,6 +338,35 @@ describe('<CategoryUI>', () => {
|
|
|
338
338
|
// The text "A" is still present from the row label.
|
|
339
339
|
expect(screen.getAllByText('A')).toHaveLength(1)
|
|
340
340
|
})
|
|
341
|
+
|
|
342
|
+
it('clicking a legend item hides that series from the rows', () => {
|
|
343
|
+
render(
|
|
344
|
+
<CategoryUI
|
|
345
|
+
data={[[{ name: 'A', value: 10 }], [{ name: 'A', value: 5 }]]}
|
|
346
|
+
series={[{ name: '2024' }, { name: '2025' }]}
|
|
347
|
+
/>,
|
|
348
|
+
)
|
|
349
|
+
expect(screen.getByText('10')).toBeTruthy()
|
|
350
|
+
expect(screen.getByText('5')).toBeTruthy()
|
|
351
|
+
fireEvent.click(screen.getByText('2025'))
|
|
352
|
+
expect(screen.queryByText('5')).toBeNull()
|
|
353
|
+
expect(screen.getByText('10')).toBeTruthy()
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('hiding the last visible series reverts to showing all (never leaves it blank)', () => {
|
|
357
|
+
render(
|
|
358
|
+
<CategoryUI
|
|
359
|
+
data={[[{ name: 'A', value: 10 }], [{ name: 'A', value: 5 }]]}
|
|
360
|
+
series={[{ name: '2024' }, { name: '2025' }]}
|
|
361
|
+
/>,
|
|
362
|
+
)
|
|
363
|
+
fireEvent.click(screen.getByText('2025'))
|
|
364
|
+
expect(screen.queryByText('5')).toBeNull()
|
|
365
|
+
fireEvent.click(screen.getByText('2024'))
|
|
366
|
+
// Both would be hidden — guard reverts to showing every series instead.
|
|
367
|
+
expect(screen.getByText('10')).toBeTruthy()
|
|
368
|
+
expect(screen.getByText('5')).toBeTruthy()
|
|
369
|
+
})
|
|
341
370
|
})
|
|
342
371
|
|
|
343
372
|
// ── Selection visual ───────────────────────────────────────────────
|
|
@@ -716,6 +745,32 @@ describe('<CategoryUI>', () => {
|
|
|
716
745
|
expect(screen.getByText('Series 2: 32')).toBeTruthy()
|
|
717
746
|
})
|
|
718
747
|
|
|
748
|
+
it('numbers the "Series N" fallback by ORIGINAL series index, not by visible position, after hiding an earlier series via the legend', () => {
|
|
749
|
+
// 3 data series; series[2] is unnamed (metadata array shorter than
|
|
750
|
+
// data — a supported "gap" per this prop's own doc). Hiding series 0
|
|
751
|
+
// ("Women") via the legend leaves series 1 ("Men") and 2 (unnamed)
|
|
752
|
+
// visible — series 2's fallback must still read "Series 3" (its
|
|
753
|
+
// ORIGINAL index), not "Series 2" (its now-first visible position).
|
|
754
|
+
const THREE_SERIES = [
|
|
755
|
+
[{ name: 'A', value: 45 }],
|
|
756
|
+
[{ name: 'A', value: 32 }],
|
|
757
|
+
[{ name: 'A', value: 12 }],
|
|
758
|
+
]
|
|
759
|
+
render(
|
|
760
|
+
<CategoryUI
|
|
761
|
+
data={THREE_SERIES}
|
|
762
|
+
series={[{ name: 'Women' }, { name: 'Men' }]}
|
|
763
|
+
stacked
|
|
764
|
+
formatter={(n) => `${n}`}
|
|
765
|
+
/>,
|
|
766
|
+
)
|
|
767
|
+
const checkbox = screen.getByRole('checkbox', { name: 'Women' })
|
|
768
|
+
fireEvent.click(checkbox)
|
|
769
|
+
expect(screen.queryByText(/Women:/)).toBeNull()
|
|
770
|
+
expect(screen.getByText('Men: 32')).toBeTruthy()
|
|
771
|
+
expect(screen.getByText('Series 3: 12')).toBeTruthy()
|
|
772
|
+
})
|
|
773
|
+
|
|
719
774
|
it('stacked is a no-op for single-series data (still renders CategoryRowSingle)', () => {
|
|
720
775
|
const { container } = render(
|
|
721
776
|
<CategoryUI data={[[{ name: 'A', value: 10 }]]} stacked />,
|