@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.
Files changed (91) hide show
  1. package/dist/category-Ct2BzADB.js +753 -0
  2. package/dist/category-Ct2BzADB.js.map +1 -0
  3. package/dist/change-column-Bhcxmp-r.js +1148 -0
  4. package/dist/change-column-Bhcxmp-r.js.map +1 -0
  5. package/dist/echart-B10dhEaM.js +262 -0
  6. package/dist/echart-B10dhEaM.js.map +1 -0
  7. package/dist/legend/stores.js +1 -1
  8. package/dist/{legend-store-registry-Bo8U_qWM.js → legend-store-registry-p1tSwJXH.js} +151 -147
  9. package/dist/legend-store-registry-p1tSwJXH.js.map +1 -0
  10. package/dist/legend.js +473 -415
  11. package/dist/legend.js.map +1 -1
  12. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +2 -2
  13. package/dist/types/legend/components/legend-group/styles.d.ts +31 -20
  14. package/dist/types/legend/components/legend-row/styles.d.ts +24 -4
  15. package/dist/types/legend/components/legend-sortable/styles.d.ts +4 -3
  16. package/dist/types/widgets-v2/actions/lock-selection/lock-selection.d.ts +15 -5
  17. package/dist/types/widgets-v2/category/components/category-legend.d.ts +18 -2
  18. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +6 -1
  19. package/dist/types/widgets-v2/category/style.d.ts +8 -0
  20. package/dist/types/widgets-v2/echart/use-chart-selection.d.ts +10 -4
  21. package/dist/types/widgets-v2/histogram/index.d.ts +1 -1
  22. package/dist/types/widgets-v2/histogram/transforms.d.ts +11 -0
  23. package/dist/types/widgets-v2/histogram/types.d.ts +9 -0
  24. package/dist/types/widgets-v2/scatterplot/index.d.ts +1 -1
  25. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +9 -0
  26. package/dist/types/widgets-v2/table/index.d.ts +1 -0
  27. package/dist/types/widgets-v2/table/transforms.d.ts +13 -0
  28. package/dist/types/widgets-v2/table/transforms.test.d.ts +1 -0
  29. package/dist/types/widgets-v2/timeseries/index.d.ts +1 -0
  30. package/dist/types/widgets-v2/timeseries/transforms.d.ts +10 -0
  31. package/dist/types/widgets-v2/timeseries/transforms.test.d.ts +1 -0
  32. package/dist/widgets-v2/actions.js +1 -1
  33. package/dist/widgets-v2/category.js +1 -1
  34. package/dist/widgets-v2/echart.js +1 -1
  35. package/dist/widgets-v2/echart.js.map +1 -1
  36. package/dist/widgets-v2/histogram.js +181 -158
  37. package/dist/widgets-v2/histogram.js.map +1 -1
  38. package/dist/widgets-v2/pie.js +155 -116
  39. package/dist/widgets-v2/pie.js.map +1 -1
  40. package/dist/widgets-v2/scatterplot.js +91 -85
  41. package/dist/widgets-v2/scatterplot.js.map +1 -1
  42. package/dist/widgets-v2/table.js +55 -45
  43. package/dist/widgets-v2/table.js.map +1 -1
  44. package/dist/widgets-v2/timeseries.js +96 -85
  45. package/dist/widgets-v2/timeseries.js.map +1 -1
  46. package/dist/widgets-v2.js +4 -4
  47. package/package.json +3 -3
  48. package/src/legend/components/legend-actions/legend-actions.tsx +2 -2
  49. package/src/legend/components/legend-group/styles.ts +59 -36
  50. package/src/legend/components/legend-row/legend-row.tsx +10 -7
  51. package/src/legend/components/legend-row/styles.ts +41 -10
  52. package/src/legend/components/legend-sortable/styles.ts +13 -4
  53. package/src/legend/stores/legend-store-registry.ts +28 -4
  54. package/src/legend/stores/legend-store.test.ts +23 -0
  55. package/src/widgets-v2/actions/lock-selection/lock-selection.test.tsx +62 -0
  56. package/src/widgets-v2/actions/lock-selection/lock-selection.tsx +45 -13
  57. package/src/widgets-v2/category/category-ui.test.tsx +55 -0
  58. package/src/widgets-v2/category/category-ui.tsx +65 -6
  59. package/src/widgets-v2/category/components/category-legend.test.tsx +53 -2
  60. package/src/widgets-v2/category/components/category-legend.tsx +55 -12
  61. package/src/widgets-v2/category/components/category-row-stacked.test.tsx +7 -3
  62. package/src/widgets-v2/category/components/category-row-stacked.tsx +8 -3
  63. package/src/widgets-v2/category/style.ts +10 -2
  64. package/src/widgets-v2/echart/echart-ui.test.tsx +25 -0
  65. package/src/widgets-v2/echart/echart-ui.tsx +20 -0
  66. package/src/widgets-v2/echart/use-chart-selection.test.tsx +3 -1
  67. package/src/widgets-v2/echart/use-chart-selection.ts +10 -4
  68. package/src/widgets-v2/histogram/index.ts +4 -1
  69. package/src/widgets-v2/histogram/options.test.ts +86 -0
  70. package/src/widgets-v2/histogram/options.ts +50 -0
  71. package/src/widgets-v2/histogram/transforms.test.ts +36 -1
  72. package/src/widgets-v2/histogram/transforms.ts +23 -0
  73. package/src/widgets-v2/histogram/types.ts +9 -0
  74. package/src/widgets-v2/pie/options.test.ts +262 -0
  75. package/src/widgets-v2/pie/options.ts +80 -5
  76. package/src/widgets-v2/scatterplot/index.ts +4 -1
  77. package/src/widgets-v2/scatterplot/transforms.test.ts +38 -1
  78. package/src/widgets-v2/scatterplot/transforms.ts +23 -0
  79. package/src/widgets-v2/table/index.ts +1 -0
  80. package/src/widgets-v2/table/transforms.test.ts +40 -0
  81. package/src/widgets-v2/table/transforms.ts +26 -0
  82. package/src/widgets-v2/timeseries/index.ts +1 -0
  83. package/src/widgets-v2/timeseries/transforms.test.ts +49 -0
  84. package/src/widgets-v2/timeseries/transforms.ts +31 -0
  85. package/dist/category-CGS_eHr4.js +0 -719
  86. package/dist/category-CGS_eHr4.js.map +0 -1
  87. package/dist/change-column-CiVAjOUB.js +0 -1143
  88. package/dist/change-column-CiVAjOUB.js.map +0 -1
  89. package/dist/echart-Bdvbfx9s.js +0 -250
  90. package/dist/echart-Bdvbfx9s.js.map +0 -1
  91. 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
- // Hover-fade group actions (widgets-v2 Wrapper parity). Revealed by
19
- // hovering anywhere in the group block (including member rows); a control
20
- // carrying `.active` (hidden group) stays visible. Touch devices always
21
- // show them. The collapse chevron sits outside the fade group. Member
22
- // rows use the distinct `PsLegend-rowFade` class, so group hover never
23
- // lights up row actions.
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 sx={styles.header} style={{ top: stickyTop }}>
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
- layer.visible
127
- ? styles.content
128
- : { ...styles.content, ...styles.dimmed }
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. The actions slot itself also
20
- // collapses to zero width while faded (see `PsLegend-rowActions` below)
21
- // so the title can grow to the trailing edge. The collapse chevron sits
22
- // outside the fade group and never fades.
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 no flex slot; sticky header is the containing block.
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
- height: 24,
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
- right: 2,
23
+ height: LEGEND_GROUP_HEADER_HEIGHT,
15
24
  },
16
25
  handleSlotGroup: {
17
- right: 2,
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
- /** Group counterpart of {@link mergeLayerEntity}. */
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 ?? false,
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(state.groups[input.id], input, index),
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 merged = mergeGroupEntity(state.groups[input.id], input, count)
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 { useCallback, useEffect, useMemo, type ComponentType } from 'react'
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 LockSelectionKey[]
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 LockSelectionKey[]
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 LockSelectionKey[]) => void
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
- }: LockSelectionProps) {
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`. Re-memoized whenever the array identity changes so
120
- * `useTransform` re-registers the closure with the freshest data.
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 LockSelectionKey[],
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) => filterByLockedItems(data, lockedItems),
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 />,