@carto/ps-react-ui 4.17.2 → 4.17.3

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.
@@ -7,8 +7,8 @@ export interface LegendActionsProps {
7
7
  * parity): the wrapper partitions its children by component identity and
8
8
  * renders this slot inside the header, wrapped in the hover-fade group
9
9
  * (`PsLegend-rowFade` in rows, `PsLegend-groupFade` in group headers —
10
- * resolved from the nearest context). Compose the menu last so it sits
11
- * rightmost, per design.
10
+ * resolved from the nearest context). Compose the visibility toggle last so
11
+ * it sits rightmost, per design.
12
12
  *
13
13
  * @experimental This API is new and may change in a future release.
14
14
  */
@@ -13,6 +13,22 @@ export declare const styles: {
13
13
  borderTop: string;
14
14
  borderColor: "divider";
15
15
  };
16
+ };
17
+ groupDragging: {
18
+ opacity: number;
19
+ };
20
+ header: {
21
+ display: "flex";
22
+ alignItems: "center";
23
+ gap: number;
24
+ padding: ({ spacing }: Theme) => string;
25
+ minHeight: number;
26
+ borderBottom: string;
27
+ borderColor: "divider";
28
+ position: "sticky";
29
+ top: number;
30
+ zIndex: number;
31
+ backgroundColor: "background.default";
16
32
  '& .PsLegend-groupFade > *': {
17
33
  opacity: number;
18
34
  transition: ({ transitions }: Theme) => string;
@@ -23,10 +39,14 @@ export declare const styles: {
23
39
  opacity: number;
24
40
  };
25
41
  };
26
- '&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
27
- opacity: number;
28
- };
29
42
  '@media (hover: hover)': {
43
+ '& .PsLegend-groupFade > *:not(.active)': {
44
+ width: number;
45
+ minWidth: number;
46
+ padding: number;
47
+ overflow: "hidden";
48
+ pointerEvents: "none";
49
+ };
30
50
  '& .PsLegend-groupActions:not(:has(.active))': {
31
51
  width: number;
32
52
  minWidth: number;
@@ -35,6 +55,14 @@ export declare const styles: {
35
55
  pointerEvents: "none";
36
56
  };
37
57
  };
58
+ '&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
59
+ opacity: number;
60
+ width: string;
61
+ minWidth: number;
62
+ padding: string;
63
+ overflow: "visible";
64
+ pointerEvents: "auto";
65
+ };
38
66
  '&:hover .PsLegend-groupActions, &:focus-within .PsLegend-groupActions': {
39
67
  width: string;
40
68
  minWidth: number;
@@ -53,22 +81,6 @@ export declare const styles: {
53
81
  opacity: number;
54
82
  };
55
83
  };
56
- groupDragging: {
57
- opacity: number;
58
- };
59
- header: {
60
- display: "flex";
61
- alignItems: "center";
62
- gap: number;
63
- padding: ({ spacing }: Theme) => string;
64
- minHeight: number;
65
- borderBottom: string;
66
- borderColor: "divider";
67
- position: "sticky";
68
- top: number;
69
- zIndex: number;
70
- backgroundColor: "background.default";
71
- };
72
84
  titleArea: {
73
85
  flex: number;
74
86
  minWidth: number;
@@ -88,7 +100,6 @@ export declare const styles: {
88
100
  color: "text.secondary";
89
101
  };
90
102
  label: {
91
- flex: number;
92
103
  minWidth: number;
93
104
  overflow: "hidden";
94
105
  textOverflow: "ellipsis";
@@ -18,10 +18,14 @@ export declare const styles: {
18
18
  opacity: number;
19
19
  };
20
20
  };
21
- '&:hover .PsLegend-rowFade > *, &:focus-within .PsLegend-rowFade > *': {
22
- opacity: number;
23
- };
24
21
  '@media (hover: hover)': {
22
+ '& .PsLegend-rowFade > *:not(.active)': {
23
+ width: number;
24
+ minWidth: number;
25
+ padding: number;
26
+ overflow: "hidden";
27
+ pointerEvents: "none";
28
+ };
25
29
  '& .PsLegend-rowActions:not(:has(.active))': {
26
30
  width: number;
27
31
  minWidth: number;
@@ -30,6 +34,14 @@ export declare const styles: {
30
34
  pointerEvents: "none";
31
35
  };
32
36
  };
37
+ '&:hover .PsLegend-rowFade > *, &:focus-within .PsLegend-rowFade > *': {
38
+ opacity: number;
39
+ width: string;
40
+ minWidth: number;
41
+ padding: string;
42
+ overflow: "visible";
43
+ pointerEvents: "auto";
44
+ };
33
45
  '&:hover .PsLegend-rowActions, &:focus-within .PsLegend-rowActions': {
34
46
  width: string;
35
47
  minWidth: number;
@@ -64,6 +76,9 @@ export declare const styles: {
64
76
  zIndex: number;
65
77
  backgroundColor: "background.paper";
66
78
  };
79
+ headerGrouped: {
80
+ paddingLeft: number;
81
+ };
67
82
  titleBox: {
68
83
  flex: number;
69
84
  minWidth: number;
@@ -86,7 +101,6 @@ export declare const styles: {
86
101
  gap: number;
87
102
  };
88
103
  title: {
89
- flex: number;
90
104
  minWidth: number;
91
105
  overflow: "hidden";
92
106
  textOverflow: "ellipsis";
@@ -120,9 +134,15 @@ export declare const styles: {
120
134
  gap: number;
121
135
  padding: ({ spacing }: Theme) => string;
122
136
  };
137
+ contentGrouped: {
138
+ paddingLeft: number;
139
+ };
123
140
  footer: {
124
141
  padding: ({ spacing }: Theme) => string;
125
142
  };
143
+ footerGrouped: {
144
+ paddingLeft: number;
145
+ };
126
146
  dimmed: {
127
147
  opacity: number;
128
148
  };
@@ -6,13 +6,14 @@ export declare const styles: {
6
6
  display: "flex";
7
7
  alignItems: "center";
8
8
  justifyContent: "center";
9
- height: number;
9
+ left: number;
10
+ top: number;
10
11
  };
11
12
  handleSlotLayer: {
12
- right: number;
13
+ height: number;
13
14
  };
14
15
  handleSlotGroup: {
15
- right: number;
16
+ height: number;
16
17
  };
17
18
  handle: {
18
19
  padding: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carto/ps-react-ui",
3
- "version": "4.17.2",
3
+ "version": "4.17.3",
4
4
  "description": "CARTO's Professional Service React Material library",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -17,8 +17,8 @@
17
17
  "react-dom": "19.2.4",
18
18
  "react-markdown": "10.1.0",
19
19
  "zustand": "5.0.12",
20
- "@carto/ps-utils": "2.1.0",
21
- "@carto/ps-common-types": "1.0.0"
20
+ "@carto/ps-common-types": "1.0.0",
21
+ "@carto/ps-utils": "2.1.0"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@carto/meridian-ds": "^2.0.0",
@@ -17,8 +17,8 @@ export interface LegendActionsProps {
17
17
  * parity): the wrapper partitions its children by component identity and
18
18
  * renders this slot inside the header, wrapped in the hover-fade group
19
19
  * (`PsLegend-rowFade` in rows, `PsLegend-groupFade` in group headers —
20
- * resolved from the nearest context). Compose the menu last so it sits
21
- * rightmost, per design.
20
+ * resolved from the nearest context). Compose the visibility toggle last so
21
+ * it sits rightmost, per design.
22
22
  *
23
23
  * @experimental This API is new and may change in a future release.
24
24
  */
@@ -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,