@carto/ps-react-ui 4.17.1 → 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.
Files changed (27) hide show
  1. package/dist/components.js +1 -1
  2. package/dist/legend/stores.js +1 -1
  3. package/dist/{legend-store-registry-Bo8U_qWM.js → legend-store-registry-p1tSwJXH.js} +151 -147
  4. package/dist/legend-store-registry-p1tSwJXH.js.map +1 -0
  5. package/dist/legend.js +670 -562
  6. package/dist/legend.js.map +1 -1
  7. package/dist/smart-tooltip-DFV6GD8R.js +40 -0
  8. package/dist/smart-tooltip-DFV6GD8R.js.map +1 -0
  9. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +2 -2
  10. package/dist/types/legend/components/legend-group/styles.d.ts +44 -16
  11. package/dist/types/legend/components/legend-row/styles.d.ts +37 -0
  12. package/dist/types/legend/components/legend-sortable/styles.d.ts +4 -3
  13. package/dist/widgets/wrapper.js +1 -1
  14. package/dist/widgets-v2.js +1 -1
  15. package/package.json +3 -3
  16. package/src/components/smart-tooltip/smart-tooltip.tsx +25 -10
  17. package/src/legend/components/legend-actions/legend-actions.tsx +2 -2
  18. package/src/legend/components/legend-group/legend-group.tsx +3 -1
  19. package/src/legend/components/legend-group/styles.ts +77 -32
  20. package/src/legend/components/legend-row/legend-row.tsx +13 -8
  21. package/src/legend/components/legend-row/styles.ts +59 -4
  22. package/src/legend/components/legend-sortable/styles.ts +13 -4
  23. package/src/legend/stores/legend-store-registry.ts +28 -4
  24. package/src/legend/stores/legend-store.test.ts +23 -0
  25. package/dist/legend-store-registry-Bo8U_qWM.js.map +0 -1
  26. package/dist/smart-tooltip-D4vwQpFf.js +0 -37
  27. package/dist/smart-tooltip-D4vwQpFf.js.map +0 -1
@@ -0,0 +1,40 @@
1
+ import { jsx as y } from "react/jsx-runtime";
2
+ import { c as z } from "react/compiler-runtime";
3
+ import { useRef as I, useState as N, useLayoutEffect as P } from "react";
4
+ import { T as R } from "./tooltip-BDnrRKrp.js";
5
+ const x = [];
6
+ function j(E) {
7
+ const e = z(13), {
8
+ title: g,
9
+ dependencies: a,
10
+ timeout: h,
11
+ TooltipProps: c,
12
+ children: f
13
+ } = E, u = a === void 0 ? x : a, t = h === void 0 ? 500 : h, d = I(null), [S, b] = N(!1);
14
+ let s;
15
+ e[0] !== u ? (s = JSON.stringify(u), e[0] = u, e[1] = s) : s = e[1];
16
+ const m = s;
17
+ let n;
18
+ e[2] !== t ? (n = () => {
19
+ const O = () => {
20
+ const i = d.current;
21
+ i && b(i.scrollWidth > i.clientWidth || i.scrollHeight > i.clientHeight);
22
+ }, w = setTimeout(O, t), v = d.current, T = v && typeof ResizeObserver < "u" ? new ResizeObserver(O) : null;
23
+ return v && T?.observe(v), () => {
24
+ clearTimeout(w), T?.disconnect();
25
+ };
26
+ }, e[2] = t, e[3] = n) : n = e[3];
27
+ let r;
28
+ e[4] !== m || e[5] !== t ? (r = [m, t], e[4] = m, e[5] = t, e[6] = r) : r = e[6], P(n, r);
29
+ const p = S && g;
30
+ let o;
31
+ e[7] !== f ? (o = f({
32
+ ref: d
33
+ }), e[7] = f, e[8] = o) : o = e[8];
34
+ let l;
35
+ return e[9] !== c || e[10] !== p || e[11] !== o ? (l = /* @__PURE__ */ y(R, { title: p, ...c, children: o }), e[9] = c, e[10] = p, e[11] = o, e[12] = l) : l = e[12], l;
36
+ }
37
+ export {
38
+ j as S
39
+ };
40
+ //# sourceMappingURL=smart-tooltip-DFV6GD8R.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smart-tooltip-DFV6GD8R.js","sources":["../src/components/smart-tooltip/smart-tooltip.tsx"],"sourcesContent":["import type { TooltipProps } from '@mui/material'\nimport { useLayoutEffect, useRef, useState, type JSX, type Ref } from 'react'\nimport { Tooltip } from '../tooltip/tooltip'\n\nconst EMPTY_DEPENDENCIES: unknown[] = []\n\n/**\n * An intelligent tooltip wrapper that automatically detects text overflow and displays a tooltip only when content is truncated.\n *\n * @remarks\n * Uses a render prop pattern. The child function receives a `ref` that must be attached to the element being monitored for overflow. Use the `dependencies` array to trigger re-evaluation when content or container size changes.\n *\n * @example\n * ```tsx\n * <SmartTooltip title=\"This is a long text that might get truncated\">\n * {({ ref }) => (\n * <Typography ref={ref} noWrap sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>\n * This is a long text that might get truncated\n * </Typography>\n * )}\n * </SmartTooltip>\n * ```\n */\nexport function SmartTooltip<T extends HTMLElement>({\n title,\n dependencies = EMPTY_DEPENDENCIES,\n timeout = 500,\n TooltipProps,\n children,\n}: {\n title: string | undefined\n dependencies?: unknown[]\n timeout?: number\n children: (props: { ref: Ref<T> }) => JSX.Element\n TooltipProps?: Partial<TooltipProps>\n}) {\n const ref = useRef<T>(null)\n const [isOverflowing, setIsOverflowing] = useState(false)\n // Call sites pass a fresh `dependencies={[…]}` literal each render; key by\n // value so the effect does not churn ResizeObserver on referential churn.\n const dependenciesKey = JSON.stringify(dependencies)\n\n useLayoutEffect(() => {\n const checkOverflow = () => {\n const el = ref.current\n if (!el) return\n setIsOverflowing(\n el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight,\n )\n }\n\n // Initial measure after layout settles (fonts, flex collapse, …).\n const timerId = setTimeout(checkOverflow, timeout)\n // Re-measure when the element is resized (e.g. legend title reflows as\n // hover-faded actions collapse/expand).\n const el = ref.current\n const resizeObserver =\n el && typeof ResizeObserver !== 'undefined'\n ? new ResizeObserver(checkOverflow)\n : null\n if (el) resizeObserver?.observe(el)\n\n return () => {\n clearTimeout(timerId)\n resizeObserver?.disconnect()\n }\n }, [dependenciesKey, timeout])\n\n return (\n <Tooltip title={isOverflowing && title} {...TooltipProps}>\n {children({ ref })}\n </Tooltip>\n )\n}\n"],"names":["EMPTY_DEPENDENCIES","SmartTooltip","t0","$","_c","title","dependencies","t1","timeout","t2","TooltipProps","children","undefined","ref","useRef","isOverflowing","setIsOverflowing","useState","t3","JSON","stringify","dependenciesKey","t4","checkOverflow","el","current","scrollWidth","clientWidth","scrollHeight","clientHeight","timerId","setTimeout","el_0","resizeObserver","ResizeObserver","observe","clearTimeout","disconnect","t5","useLayoutEffect","t6","t7","t8","Tooltip"],"mappings":";;;;AAIA,MAAMA,IAAgC,CAAA;AAmB/B,SAAAC,EAAAC,GAAA;AAAA,QAAAC,IAAAC,EAAA,EAAA,GAA6C;AAAA,IAAAC,OAAAA;AAAAA,IAAAC,cAAAC;AAAAA,IAAAC,SAAAC;AAAAA,IAAAC,cAAAA;AAAAA,IAAAC,UAAAA;AAAAA,EAAAA,IAAAT,GAElDI,IAAAC,MAAAK,SAAAZ,IAAAO,GACAC,IAAAC,MAAAG,SAAA,MAAAH,GAUAI,IAAYC,EAAU,IAAI,GAC1B,CAAAC,GAAAC,CAAA,IAA0CC,EAAS,EAAK;AAAC,MAAAC;AAAA,EAAAf,SAAAG,KAGjCY,IAAAC,KAAIC,UAAWd,CAAY,GAACH,OAAAG,GAAAH,OAAAe,KAAAA,IAAAf,EAAA,CAAA;AAApD,QAAAkB,IAAwBH;AAA4B,MAAAI;AAAA,EAAAnB,SAAAK,KAEpCc,IAAAA,MAAA;AACd,UAAAC,IAAsBA,MAAA;AACpB,YAAAC,IAAWX,EAAGY;AACd,MAAKD,KACLR,EACEQ,EAAEE,cAAeF,EAAEG,eAAgBH,EAAEI,eAAgBJ,EAAEK,YACzD;AAAA,IAAC,GAIHC,IAAgBC,WAAWR,GAAef,CAAO,GAGjDwB,IAAWnB,EAAGY,SACdQ,IACED,KAAM,OAAOE,iBAAmB,MAAhC,IACQA,eAAeX,CAChB,IAFP;AAGF,WAAIC,KAAIS,GAAcE,QAAUX,CAAE,GAE3B,MAAA;AACLY,mBAAaN,CAAO,GACpBG,GAAcI,WAAAA;AAAAA,IAAc;AAAA,EAC7B,GACFlC,OAAAK,GAAAL,OAAAmB,KAAAA,IAAAnB,EAAA,CAAA;AAAA,MAAAmC;AAAA,EAAAnC,EAAA,CAAA,MAAAkB,KAAAlB,SAAAK,KAAE8B,IAAA,CAACjB,GAAiBb,CAAO,GAACL,OAAAkB,GAAAlB,OAAAK,GAAAL,OAAAmC,KAAAA,IAAAnC,EAAA,CAAA,GAxB7BoC,EAAgBjB,GAwBbgB,CAA0B;AAGX,QAAAE,IAAAzB,KAAAV;AAAsB,MAAAoC;AAAA,EAAAtC,SAAAQ,KACnC8B,IAAA9B,EAAS;AAAA,IAAAE,KAAAA;AAAAA,EAAAA,CAAO,GAACV,OAAAQ,GAAAR,OAAAsC,KAAAA,IAAAtC,EAAA,CAAA;AAAA,MAAAuC;AAAA,SAAAvC,EAAA,CAAA,MAAAO,KAAAP,UAAAqC,KAAArC,EAAA,EAAA,MAAAsC,KADpBC,sBAACC,GAAA,EAAe,OAAAH,GAAsB,GAAM9B,GACzC+B,UAAAA,GACH,GAAUtC,OAAAO,GAAAP,QAAAqC,GAAArC,QAAAsC,GAAAtC,QAAAuC,KAAAA,IAAAvC,EAAA,EAAA,GAFVuC;AAEU;"}
@@ -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,8 +39,36 @@ export declare const styles: {
23
39
  opacity: number;
24
40
  };
25
41
  };
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
+ };
50
+ '& .PsLegend-groupActions:not(:has(.active))': {
51
+ width: number;
52
+ minWidth: number;
53
+ overflow: "hidden";
54
+ marginInlineStart: ({ spacing }: Theme) => string;
55
+ pointerEvents: "none";
56
+ };
57
+ };
26
58
  '&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
27
59
  opacity: number;
60
+ width: string;
61
+ minWidth: number;
62
+ padding: string;
63
+ overflow: "visible";
64
+ pointerEvents: "auto";
65
+ };
66
+ '&:hover .PsLegend-groupActions, &:focus-within .PsLegend-groupActions': {
67
+ width: string;
68
+ minWidth: number;
69
+ overflow: "visible";
70
+ marginInlineStart: number;
71
+ pointerEvents: "auto";
28
72
  };
29
73
  '& .PsLegend-dragHandle-group > *': {
30
74
  opacity: number;
@@ -37,22 +81,6 @@ export declare const styles: {
37
81
  opacity: number;
38
82
  };
39
83
  };
40
- groupDragging: {
41
- opacity: number;
42
- };
43
- header: {
44
- display: "flex";
45
- alignItems: "center";
46
- gap: number;
47
- padding: ({ spacing }: Theme) => string;
48
- minHeight: number;
49
- borderBottom: string;
50
- borderColor: "divider";
51
- position: "sticky";
52
- top: number;
53
- zIndex: number;
54
- backgroundColor: "background.default";
55
- };
56
84
  titleArea: {
57
85
  flex: number;
58
86
  minWidth: number;
@@ -18,8 +18,36 @@ export declare const styles: {
18
18
  opacity: number;
19
19
  };
20
20
  };
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
+ };
29
+ '& .PsLegend-rowActions:not(:has(.active))': {
30
+ width: number;
31
+ minWidth: number;
32
+ overflow: "hidden";
33
+ marginInlineStart: ({ spacing }: Theme) => string;
34
+ pointerEvents: "none";
35
+ };
36
+ };
21
37
  '&:hover .PsLegend-rowFade > *, &:focus-within .PsLegend-rowFade > *': {
22
38
  opacity: number;
39
+ width: string;
40
+ minWidth: number;
41
+ padding: string;
42
+ overflow: "visible";
43
+ pointerEvents: "auto";
44
+ };
45
+ '&:hover .PsLegend-rowActions, &:focus-within .PsLegend-rowActions': {
46
+ width: string;
47
+ minWidth: number;
48
+ overflow: "visible";
49
+ marginInlineStart: number;
50
+ pointerEvents: "auto";
23
51
  };
24
52
  '&:hover .PsLegend-value, &:focus-within .PsLegend-value': {
25
53
  opacity: number;
@@ -48,6 +76,9 @@ export declare const styles: {
48
76
  zIndex: number;
49
77
  backgroundColor: "background.paper";
50
78
  };
79
+ headerGrouped: {
80
+ paddingLeft: number;
81
+ };
51
82
  titleBox: {
52
83
  flex: number;
53
84
  minWidth: number;
@@ -103,9 +134,15 @@ export declare const styles: {
103
134
  gap: number;
104
135
  padding: ({ spacing }: Theme) => string;
105
136
  };
137
+ contentGrouped: {
138
+ paddingLeft: number;
139
+ };
106
140
  footer: {
107
141
  padding: ({ spacing }: Theme) => string;
108
142
  };
143
+ footerGrouped: {
144
+ paddingLeft: number;
145
+ };
109
146
  dimmed: {
110
147
  opacity: number;
111
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;
@@ -5,7 +5,7 @@ import { MoreVert as D } from "@mui/icons-material";
5
5
  import { useState as H, useLayoutEffect as G } from "react";
6
6
  import "../lasso-tool-YLFRgb-S.js";
7
7
  import "../cjs-D4KH3azB.js";
8
- import { S as U } from "../smart-tooltip-D4vwQpFf.js";
8
+ import { S as U } from "../smart-tooltip-DFV6GD8R.js";
9
9
  import "@carto/ps-utils";
10
10
  import { u as V } from "../use-widget-selector-DFl2hW0R.js";
11
11
  import { w as M } from "../widget-store-Bw5zRUGg.js";
@@ -6,7 +6,7 @@ import { j as we, i as Ee, e as Ce, n as Te, r as ke, q as _e, v as Ie, u as Le,
6
6
  import "zustand";
7
7
  import { Box as W, LinearProgress as We, Typography as Z, AccordionSummary as $e, AccordionDetails as De, Accordion as Fe, AlertTitle as Be, Alert as Oe, Button as Re, IconButton as Me, Divider as Pe, Popper as Ue, Grow as Ne, Paper as je, Link as pe } from "@mui/material";
8
8
  import { ExpandMore as ze, Close as He } from "@mui/icons-material";
9
- import { S as Ve } from "./smart-tooltip-D4vwQpFf.js";
9
+ import { S as Ve } from "./smart-tooltip-DFV6GD8R.js";
10
10
  import { createPortal as Ge } from "react-dom";
11
11
  import { WidgetOptions as qe } from "@carto/meridian-ds/custom-icons";
12
12
  import "./lasso-tool-YLFRgb-S.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carto/ps-react-ui",
3
- "version": "4.17.1",
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",
@@ -36,20 +36,35 @@ export function SmartTooltip<T extends HTMLElement>({
36
36
  }) {
37
37
  const ref = useRef<T>(null)
38
38
  const [isOverflowing, setIsOverflowing] = useState(false)
39
+ // Call sites pass a fresh `dependencies={[…]}` literal each render; key by
40
+ // value so the effect does not churn ResizeObserver on referential churn.
41
+ const dependenciesKey = JSON.stringify(dependencies)
39
42
 
40
43
  useLayoutEffect(() => {
41
- const timerId = setTimeout(() => {
42
- if (ref.current) {
43
- const isOverflowing =
44
- ref.current.scrollWidth > ref.current.clientWidth ||
45
- ref.current.scrollHeight > ref.current.clientHeight
44
+ const checkOverflow = () => {
45
+ const el = ref.current
46
+ if (!el) return
47
+ setIsOverflowing(
48
+ el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight,
49
+ )
50
+ }
46
51
 
47
- setIsOverflowing(isOverflowing)
48
- }
49
- }, timeout)
52
+ // Initial measure after layout settles (fonts, flex collapse, …).
53
+ const timerId = setTimeout(checkOverflow, timeout)
54
+ // Re-measure when the element is resized (e.g. legend title reflows as
55
+ // hover-faded actions collapse/expand).
56
+ const el = ref.current
57
+ const resizeObserver =
58
+ el && typeof ResizeObserver !== 'undefined'
59
+ ? new ResizeObserver(checkOverflow)
60
+ : null
61
+ if (el) resizeObserver?.observe(el)
50
62
 
51
- return () => clearTimeout(timerId)
52
- }, [dependencies, timeout])
63
+ return () => {
64
+ clearTimeout(timerId)
65
+ resizeObserver?.disconnect()
66
+ }
67
+ }, [dependenciesKey, timeout])
53
68
 
54
69
  return (
55
70
  <Tooltip title={isOverflowing && title} {...TooltipProps}>
@@ -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
  */
@@ -97,7 +97,9 @@ export function LegendGroup({ groupId, children }: LegendGroupProps) {
97
97
  />
98
98
  </Box>
99
99
 
100
- <Box sx={styles.actions}>{actions}</Box>
100
+ <Box className='PsLegend-groupActions' sx={styles.actions}>
101
+ {actions}
102
+ </Box>
101
103
  </Box>
102
104
 
103
105
  <Collapse in={!group.collapsed}>
@@ -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,8 +65,44 @@ export const styles = {
35
65
  opacity: 1,
36
66
  },
37
67
  },
68
+ // Faded actions don't reserve flex space on fine-pointer devices — the
69
+ // label grows to the trailing edge. Hover/focus-within restores the slot
70
+ // so the label reflows with the buttons; a dirty `.active` child keeps
71
+ // the slot open even outside hover.
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
+ },
83
+ '& .PsLegend-groupActions:not(:has(.active))': {
84
+ width: 0,
85
+ minWidth: 0,
86
+ overflow: 'hidden',
87
+ // Cancel the header gap so nothing is left behind the label.
88
+ marginInlineStart: ({ spacing }: Theme) => `calc(-1 * ${spacing(1)})`,
89
+ pointerEvents: 'none',
90
+ },
91
+ },
38
92
  '&:hover .PsLegend-groupFade > *, &:focus-within .PsLegend-groupFade > *': {
39
93
  opacity: 1,
94
+ width: 'auto',
95
+ minWidth: 0,
96
+ padding: '2px',
97
+ overflow: 'visible',
98
+ pointerEvents: 'auto',
99
+ },
100
+ '&:hover .PsLegend-groupActions, &:focus-within .PsLegend-groupActions': {
101
+ width: 'auto',
102
+ minWidth: 0,
103
+ overflow: 'visible',
104
+ marginInlineStart: 0,
105
+ pointerEvents: 'auto',
40
106
  },
41
107
  // Drag handle (Legend.Sortable): absolutely positioned (no reserved flex
42
108
  // slot, so it never shifts the title/icon); the button inside fades in
@@ -57,31 +123,6 @@ export const styles = {
57
123
  opacity: 1,
58
124
  },
59
125
  },
60
- // The original stays in place, dimmed, while its overlay clone is dragged.
61
- groupDragging: {
62
- opacity: 0.4,
63
- },
64
- // Figma: 48px tall, icon at 12px from the left, 8px gap to the label,
65
- // actions right-aligned with ~8px edge inset (the small icon-button padding
66
- // brings the glyphs to the design's 16px optical inset), hairline divider
67
- // below. Sticky: pins to the top of the panel's scroll container.
68
- header: {
69
- display: 'flex',
70
- alignItems: 'center',
71
- gap: 1,
72
- padding: ({ spacing }) => spacing(1.5, 2, 1.5, 1),
73
- minHeight: LEGEND_GROUP_HEADER_HEIGHT,
74
- borderBottom: '1px solid',
75
- borderColor: 'divider',
76
- position: 'sticky',
77
- top: 0,
78
- // Above the sticky row headers (2), which in turn sit above MUI's
79
- // z-index-1 form internals in row content.
80
- zIndex: 3,
81
- // Distinct from the row header / panel body (both `background.paper`) so
82
- // the group header visually separates from the rest.
83
- backgroundColor: 'background.default',
84
- },
85
126
  // Accordion click target: everything in the header but the actions box.
86
127
  // Non-native button (role='button'), so it carries its own focus ring.
87
128
  titleArea: {
@@ -102,6 +143,10 @@ export const styles = {
102
143
  display: 'flex',
103
144
  color: 'text.secondary',
104
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).
105
150
  label: {
106
151
  minWidth: 0,
107
152
  overflow: 'hidden',
@@ -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]}
@@ -115,21 +118,23 @@ export function LegendRow({ layerId, children }: LegendRowProps) {
115
118
  )}
116
119
  </Box>
117
120
 
118
- <Box sx={styles.actions}>{actions}</Box>
121
+ <Box className='PsLegend-rowActions' sx={styles.actions}>
122
+ {actions}
123
+ </Box>
119
124
  </Box>
120
125
 
121
126
  <Collapse in={!effectivelyCollapsed}>
122
127
  <Box
123
- sx={
124
- layer.visible
125
- ? styles.content
126
- : { ...styles.content, ...styles.dimmed }
127
- }
128
+ sx={[
129
+ styles.content,
130
+ inGroup && styles.contentGrouped,
131
+ !layer.visible && styles.dimmed,
132
+ ]}
128
133
  >
129
134
  {body}
130
135
  </Box>
131
136
  {layer.helperText && (
132
- <Box sx={styles.footer}>
137
+ <Box sx={[styles.footer, inGroup && styles.footerGrouped]}>
133
138
  {typeof layer.helperText === 'string' ? (
134
139
  <Typography variant='caption' color='text.secondary'>
135
140
  {layer.helperText}
@@ -16,12 +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 collapse chevron sits
20
- // 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.
21
25
  '& .PsLegend-rowFade > *': {
22
26
  opacity: 1,
23
27
  transition: ({ transitions }: Theme) =>
24
- transitions.create('opacity', {
28
+ transitions.create(['opacity', 'width'], {
25
29
  duration: transitions.duration.standard,
26
30
  easing: transitions.easing.easeInOut,
27
31
  }),
@@ -32,8 +36,44 @@ export const styles = {
32
36
  opacity: 1,
33
37
  },
34
38
  },
39
+ // Faded actions don't reserve flex space on fine-pointer devices — the
40
+ // title grows to the trailing edge. Hover/focus-within restores the slot
41
+ // so the title reflows with the buttons; a dirty `.active` child keeps
42
+ // the slot open even outside hover.
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
+ },
54
+ '& .PsLegend-rowActions:not(:has(.active))': {
55
+ width: 0,
56
+ minWidth: 0,
57
+ overflow: 'hidden',
58
+ // Cancel the header gap so nothing is left behind the title.
59
+ marginInlineStart: ({ spacing }: Theme) => `calc(-1 * ${spacing(0.5)})`,
60
+ pointerEvents: 'none',
61
+ },
62
+ },
35
63
  '&:hover .PsLegend-rowFade > *, &:focus-within .PsLegend-rowFade > *': {
36
64
  opacity: 1,
65
+ width: 'auto',
66
+ minWidth: 0,
67
+ padding: '2px',
68
+ overflow: 'visible',
69
+ pointerEvents: 'auto',
70
+ },
71
+ '&:hover .PsLegend-rowActions, &:focus-within .PsLegend-rowActions': {
72
+ width: 'auto',
73
+ minWidth: 0,
74
+ overflow: 'visible',
75
+ marginInlineStart: 0,
76
+ pointerEvents: 'auto',
37
77
  },
38
78
  // Hovering anywhere in the row also reveals the category/icon values
39
79
  // (the renderers keep their own list-scoped rule for standalone usage).
@@ -68,7 +108,9 @@ export const styles = {
68
108
  // band (subtitle flows below them) — hence `flex-start` with the title
69
109
  // optically centered against the 30px icon-button row. Sticky: pins inside
70
110
  // the panel's scroll container; `top` is applied inline per row (0, or the
71
- // 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.
72
114
  header: {
73
115
  display: 'flex',
74
116
  alignItems: 'flex-start',
@@ -81,6 +123,9 @@ export const styles = {
81
123
  zIndex: 2,
82
124
  backgroundColor: 'background.paper',
83
125
  },
126
+ headerGrouped: {
127
+ paddingLeft: 4.5,
128
+ },
84
129
  titleBox: {
85
130
  flex: 1,
86
131
  minWidth: 0,
@@ -107,6 +152,10 @@ export const styles = {
107
152
  alignItems: 'center',
108
153
  gap: 0.5,
109
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).
110
159
  title: {
111
160
  minWidth: 0,
112
161
  overflow: 'hidden',
@@ -154,10 +203,16 @@ export const styles = {
154
203
  gap: 1,
155
204
  padding: ({ spacing }) => spacing(0.5, 2, 1, 2),
156
205
  },
206
+ contentGrouped: {
207
+ paddingLeft: 4.5,
208
+ },
157
209
  // ~16px above the note (content's 8px bottom inset + 8px here), per design.
158
210
  footer: {
159
211
  padding: ({ spacing }) => spacing(1, 2, 1, 2),
160
212
  },
213
+ footerGrouped: {
214
+ paddingLeft: 4.5,
215
+ },
161
216
  dimmed: {
162
217
  opacity: 0.5,
163
218
  },