@carto/ps-react-ui 6.1.0 → 6.2.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 (72) hide show
  1. package/dist/{actions-BWlS5tpP.js → actions-CueYrfVr.js} +2 -2
  2. package/dist/actions-CueYrfVr.js.map +1 -0
  3. package/dist/category-DULnoxvk.js +933 -0
  4. package/dist/category-DULnoxvk.js.map +1 -0
  5. package/dist/chat.js +1 -1
  6. package/dist/chat.js.map +1 -1
  7. package/dist/components-CTUpdQuj.js.map +1 -1
  8. package/dist/download-B4sykOFm.js.map +1 -1
  9. package/dist/{echart-CV8F50gn.js → echart-ysYFLa_T.js} +2 -2
  10. package/dist/echart-ysYFLa_T.js.map +1 -0
  11. package/dist/formula-DJS-JLpk.js.map +1 -1
  12. package/dist/legend.js.map +1 -1
  13. package/dist/{markdown-Bn2nKCbn.js → markdown-DmvLi3vx.js} +2 -2
  14. package/dist/markdown-DmvLi3vx.js.map +1 -0
  15. package/dist/{markdown-content-kYNLOX0T.js → markdown-content-BpAH_V6S.js} +5 -4
  16. package/dist/markdown-content-BpAH_V6S.js.map +1 -0
  17. package/dist/option-builders-DQivvvbb.js.map +1 -1
  18. package/dist/range-BpU2zBmq.js.map +1 -1
  19. package/dist/smart-tooltip-Dc_rHhyI.js.map +1 -1
  20. package/dist/spread-CQwFIwuz.js.map +1 -1
  21. package/dist/stores-DcBOZlU3.js.map +1 -1
  22. package/dist/stores-mad-9yFn.js.map +1 -1
  23. package/dist/styles-BCEBm7xh.js.map +1 -1
  24. package/dist/table-DEdW_fDj.js.map +1 -1
  25. package/dist/types/widgets/category/category-ui.d.ts +10 -2
  26. package/dist/types/widgets/category/category.d.ts +7 -1
  27. package/dist/types/widgets/category/components/category-bar-stacked.d.ts +8 -1
  28. package/dist/types/widgets/category/components/category-bar.d.ts +24 -2
  29. package/dist/types/widgets/category/components/category-row-multi.d.ts +3 -1
  30. package/dist/types/widgets/category/components/category-row-single.d.ts +3 -1
  31. package/dist/types/widgets/category/components/category-row-stacked.d.ts +10 -1
  32. package/dist/types/widgets/category/style.d.ts +33 -6
  33. package/dist/types/widgets/wrapper/style.d.ts +31 -2
  34. package/dist/types/widgets/wrapper/widget-actions.d.ts +2 -2
  35. package/dist/types/widgets/wrapper/widget-wrapper.d.ts +2 -2
  36. package/dist/utils-C-eWwOhE.js.map +1 -1
  37. package/dist/widgets/actions.js +1 -1
  38. package/dist/widgets/bar.js.map +1 -1
  39. package/dist/widgets/category.js +1 -1
  40. package/dist/widgets/echart.js +1 -1
  41. package/dist/widgets/histogram.js.map +1 -1
  42. package/dist/widgets/markdown.js +2 -2
  43. package/dist/widgets/pie.js +2 -2
  44. package/dist/widgets/pie.js.map +1 -1
  45. package/dist/widgets/scatterplot.js.map +1 -1
  46. package/dist/widgets/timeseries.js.map +1 -1
  47. package/dist/widgets.js +344 -297
  48. package/dist/widgets.js.map +1 -1
  49. package/dist/zoom-toggle-MmLjMTEw.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/widgets/category/category-ui.test.tsx +139 -0
  52. package/src/widgets/category/category-ui.tsx +64 -11
  53. package/src/widgets/category/category.test.tsx +67 -0
  54. package/src/widgets/category/category.tsx +32 -15
  55. package/src/widgets/category/components/category-bar-stacked.test.tsx +18 -0
  56. package/src/widgets/category/components/category-bar-stacked.tsx +42 -29
  57. package/src/widgets/category/components/category-bar.test.tsx +66 -0
  58. package/src/widgets/category/components/category-bar.tsx +65 -9
  59. package/src/widgets/category/components/category-row-multi.tsx +21 -0
  60. package/src/widgets/category/components/category-row-single.tsx +4 -0
  61. package/src/widgets/category/components/category-row-stacked.tsx +24 -1
  62. package/src/widgets/category/style.ts +58 -14
  63. package/src/widgets/wrapper/style.ts +38 -2
  64. package/src/widgets/wrapper/widget-actions.tsx +2 -2
  65. package/src/widgets/wrapper/widget-wrapper.test.tsx +32 -2
  66. package/src/widgets/wrapper/widget-wrapper.tsx +62 -36
  67. package/dist/actions-BWlS5tpP.js.map +0 -1
  68. package/dist/category-alzjKzvQ.js +0 -839
  69. package/dist/category-alzjKzvQ.js.map +0 -1
  70. package/dist/echart-CV8F50gn.js.map +0 -1
  71. package/dist/markdown-Bn2nKCbn.js.map +0 -1
  72. package/dist/markdown-content-kYNLOX0T.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import type { CssColor } from '../../../components/types'
2
+ import type { ReactNode } from 'react'
2
3
  import { Box } from '@mui/material'
3
4
  import type { CategorySize } from '../types'
4
5
  import { styles } from '../style'
@@ -6,8 +7,15 @@ import { styles } from '../style'
6
7
  export interface CategoryBarProps {
7
8
  /** Numeric value driving the fill width. */
8
9
  value: number
9
- /** Denominator for the percentage fill. `0` → empty bar (no division by zero). */
10
+ /** Upper end of the domain. `maxValue === minValue` → empty bar (no division by zero). */
10
11
  maxValue: number
12
+ /**
13
+ * Lower end of the domain. `0` (default) keeps the positive-only bar
14
+ * anchored at the left edge; a negative value moves the zero to
15
+ * `-minValue / (maxValue - minValue)` and bars grow from there in either
16
+ * direction, keeping the fill `color` whatever the sign.
17
+ */
18
+ minValue?: number
11
19
  /** Fill color, already resolved by `CategoryUI.colorAt`. */
12
20
  color: CssColor
13
21
  /**
@@ -17,6 +25,11 @@ export interface CategoryBarProps {
17
25
  * size-invariant.
18
26
  */
19
27
  size?: CategorySize
28
+ /**
29
+ * Draw the zero tick when the domain is signed. `false` when the parent
30
+ * draws its own zero line (multi-series rows span it across every bar).
31
+ */
32
+ zeroTick?: boolean
20
33
  }
21
34
 
22
35
  /**
@@ -28,23 +41,66 @@ export interface CategoryBarProps {
28
41
  export function CategoryBar({
29
42
  value,
30
43
  maxValue,
44
+ minValue = 0,
31
45
  color,
32
46
  size = 'small',
47
+ zeroTick = true,
33
48
  }: CategoryBarProps) {
34
- const pct = maxValue > 0 ? (value / maxValue) * 100 : 0
49
+ const range = maxValue - minValue
50
+ const leftPct =
51
+ range > 0 ? ((Math.min(0, value) - minValue) / range) * 100 : 0
52
+ const pct = range > 0 ? (Math.abs(value) / range) * 100 : 0
53
+ const hasZeroTick = zeroTick && minValue < 0 && maxValue > 0
35
54
  // Width + color rendered as inline `style` (not `sx`) to avoid
36
55
  // generating a new emotion class for every distinct value/color pair
37
56
  // — also makes the rendered DOM testable without computed-style math.
38
57
  return (
39
- <Box sx={styles.bar[size]} data-size={size}>
58
+ <ZeroTickWrap zeroPct={hasZeroTick ? (-minValue / range) * 100 : undefined}>
59
+ <Box sx={styles.bar[size]} data-size={size}>
60
+ <Box
61
+ sx={styles.barFill[size]}
62
+ style={{
63
+ left: `${leftPct}%`,
64
+ width: `${pct}%`,
65
+ backgroundColor: color,
66
+ // The end that grows out of the zero is square; only the far
67
+ // end keeps the rounded corners.
68
+ ...(value < 0
69
+ ? { borderTopRightRadius: 0, borderBottomRightRadius: 0 }
70
+ : { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }),
71
+ }}
72
+ // Tag the fill so an ancestor row's `:hover` selector can target
73
+ // it (`&:hover [data-bar-fill]`) — the brightening affordance now
74
+ // fires from anywhere in the row, not only the bar track.
75
+ data-bar-fill='true'
76
+ />
77
+ </Box>
78
+ </ZeroTickWrap>
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Wraps a bar track and, when `zeroPct` is set, marks the zero with a tick
84
+ * that overhangs the track. Positive-only, negative-only and all-zero bars
85
+ * pass `undefined` and get no tick.
86
+ */
87
+ export function ZeroTickWrap({
88
+ zeroPct,
89
+ children,
90
+ }: {
91
+ zeroPct: number | undefined
92
+ children: ReactNode
93
+ }) {
94
+ if (zeroPct === undefined) return children
95
+ return (
96
+ <Box sx={styles.barWrap}>
40
97
  <Box
41
- sx={styles.barFill[size]}
42
- style={{ width: `${pct}%`, backgroundColor: color }}
43
- // Tag the fill so an ancestor row's `:hover` selector can target
44
- // it (`&:hover [data-bar-fill]`) — the brightening affordance now
45
- // fires from anywhere in the row, not only the bar track.
46
- data-bar-fill='true'
98
+ aria-hidden
99
+ data-zero-tick='true'
100
+ sx={styles.zeroTick}
101
+ style={{ left: `${zeroPct}%` }}
47
102
  />
103
+ {children}
48
104
  </Box>
49
105
  )
50
106
  }
@@ -16,6 +16,8 @@ export interface CategoryRowMultiProps {
16
16
  colors: readonly CssColor[]
17
17
  /** Shared max across all series + rows (or `maxOverride`). */
18
18
  maxValue: number
19
+ /** Lower end of the shared domain (`0` for positive-only data). */
20
+ minValue?: number
19
21
  formatter: (n: number) => string
20
22
  selected: boolean
21
23
  onToggle: (name: CategoryKey) => void
@@ -41,11 +43,13 @@ export function CategoryRowMulti({
41
43
  values,
42
44
  colors,
43
45
  maxValue,
46
+ minValue,
44
47
  formatter,
45
48
  selected,
46
49
  onToggle,
47
50
  size,
48
51
  }: CategoryRowMultiProps) {
52
+ const min = minValue ?? 0
49
53
  const handleKey = (e: KeyboardEvent<HTMLDivElement>): void => {
50
54
  if (e.key === 'Enter' || e.key === ' ') {
51
55
  e.preventDefault()
@@ -64,6 +68,21 @@ export function CategoryRowMulti({
64
68
  <Typography variant='body2' sx={styles.multiName}>
65
69
  {displayName ?? name}
66
70
  </Typography>
71
+ {min < 0 && maxValue > 0 && (
72
+ <Box
73
+ aria-hidden
74
+ sx={styles.multiZeroArea}
75
+ // Row 1 is the label; the bars fill the next `values.length` rows.
76
+ // Placed before the bars so their tracks paint over the line.
77
+ style={{ gridRow: `2 / span ${values.length}` }}
78
+ >
79
+ <Box
80
+ data-zero-tick='true'
81
+ sx={styles.zeroTick}
82
+ style={{ left: `${(-min / (maxValue - min)) * 100}%` }}
83
+ />
84
+ </Box>
85
+ )}
67
86
  {values.map((v, i) => (
68
87
  // Multi-bar rows iterate per series — the bar belongs to series `i`,
69
88
  // and the row is keyed on the parent's `name`, so a composite of
@@ -73,8 +92,10 @@ export function CategoryRowMulti({
73
92
  <CategoryBar
74
93
  value={v}
75
94
  maxValue={maxValue}
95
+ minValue={minValue}
76
96
  color={colors[i] ?? ''}
77
97
  size={size}
98
+ zeroTick={false}
78
99
  />
79
100
  </Box>
80
101
  <Typography variant='body2' sx={styles.value}>
@@ -12,6 +12,8 @@ export interface CategoryRowSingleProps {
12
12
  displayName?: string | number
13
13
  value: number
14
14
  maxValue: number
15
+ /** Lower end of the shared domain (`0` for positive-only data). */
16
+ minValue?: number
15
17
  color: CssColor
16
18
  formatter: (n: number) => string
17
19
  selected: boolean
@@ -37,6 +39,7 @@ export function CategoryRowSingle({
37
39
  displayName,
38
40
  value,
39
41
  maxValue,
42
+ minValue,
40
43
  color,
41
44
  formatter,
42
45
  selected,
@@ -69,6 +72,7 @@ export function CategoryRowSingle({
69
72
  <CategoryBar
70
73
  value={value}
71
74
  maxValue={maxValue}
75
+ minValue={minValue}
72
76
  color={color}
73
77
  size={size}
74
78
  />
@@ -26,6 +26,11 @@ export interface CategoryRowStackedProps {
26
26
  onToggle: (name: CategoryKey) => void
27
27
  /** Bar visual density. Forwarded to the inner {@link CategoryBarStacked}. */
28
28
  size?: CategorySize
29
+ /**
30
+ * Where the list's zero sits (% of the track), shared by every row so the
31
+ * zero lines up: `0` positive-only, `50` signed, `100` negative-only.
32
+ */
33
+ zeroPct?: number
29
34
  }
30
35
 
31
36
  /**
@@ -41,6 +46,10 @@ export interface CategoryRowStackedProps {
41
46
  * total relative to other rows. The total numeric value is still shown in
42
47
  * the header so the per-row magnitude isn't lost.
43
48
  *
49
+ * Signed data diverges from `zeroPct`: positives stack rightwards and
50
+ * negatives leftwards, and whichever side is longer reaches its edge of the
51
+ * track. The header total is the signed sum.
52
+ *
44
53
  * Visual selection / dim signal lives entirely in the segment colors
45
54
  * (`colors` prop) — every series in a dimmed row receives
46
55
  * `theme.palette.action.disabled`. The bar visually collapses to a single
@@ -57,6 +66,7 @@ export function CategoryRowStacked({
57
66
  selected,
58
67
  onToggle,
59
68
  size,
69
+ zeroPct = 0,
60
70
  }: CategoryRowStackedProps) {
61
71
  const handleKey = (e: KeyboardEvent<HTMLDivElement>): void => {
62
72
  if (e.key === 'Enter' || e.key === ' ') {
@@ -65,6 +75,18 @@ export function CategoryRowStacked({
65
75
  }
66
76
  }
67
77
  const total = values.reduce((a, b) => a + b, 0)
78
+ let pos = 0
79
+ let neg = 0
80
+ for (const v of values) {
81
+ if (v > 0) pos += v
82
+ else neg -= v
83
+ }
84
+ // Magnitude that would fill the whole track, picked so the longer side
85
+ // just reaches its edge: `pos` spans `100 - zeroPct`, `neg` spans `zeroPct`.
86
+ const scale = Math.max(
87
+ zeroPct < 100 ? (pos * 100) / (100 - zeroPct) : 0,
88
+ zeroPct > 0 ? (neg * 100) / zeroPct : 0,
89
+ )
68
90
  return (
69
91
  <Box
70
92
  role='button'
@@ -85,7 +107,8 @@ export function CategoryRowStacked({
85
107
  <CategoryBarStacked
86
108
  values={values}
87
109
  colors={colors}
88
- maxValue={total}
110
+ maxValue={scale}
111
+ zeroPct={zeroPct}
89
112
  size={size}
90
113
  />
91
114
  <Box sx={styles.stackedBreakdown}>
@@ -28,17 +28,19 @@ const bar: Record<CategorySize, SxProps<Theme>> = {
28
28
  const barFill: Record<CategorySize, SxProps<Theme>> = {
29
29
  small: {
30
30
  position: 'absolute',
31
- inset: '0 auto 0 0',
31
+ top: 0,
32
+ bottom: 0,
32
33
  borderRadius: 1,
33
34
  transition:
34
- 'width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
35
+ 'left 300ms ease-in-out, width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
35
36
  },
36
37
  medium: {
37
38
  position: 'absolute',
38
- inset: '0 auto 0 0',
39
+ top: 0,
40
+ bottom: 0,
39
41
  borderRadius: 0.25,
40
42
  transition:
41
- 'width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
43
+ 'left 300ms ease-in-out, width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
42
44
  },
43
45
  }
44
46
 
@@ -71,13 +73,47 @@ export const styles = {
71
73
  position: 'relative',
72
74
  width: '100%',
73
75
  },
74
- // Flat list of category rows. Holds both single + multi rows.
76
+ // Flat list of category rows. A grid rather than a flex column so the
77
+ // multi-series rows can share one value column through `subgrid`: with
78
+ // every bar track the same width, the zero ticks line up across rows.
75
79
  list: {
76
- display: 'flex',
77
- flexDirection: 'column',
80
+ position: 'relative',
81
+ display: 'grid',
82
+ gridTemplateColumns: 'minmax(0, 1fr)',
78
83
  gap: 1,
79
84
  py: 0.5,
80
85
  },
86
+ // Layered on `list` for unstacked multi-series: bars | values.
87
+ listMulti: {
88
+ gridTemplateColumns: 'minmax(0, 1fr) max-content',
89
+ },
90
+ // Zero tick for signed data: one per bar track, drawn outside the track's
91
+ // `overflow: hidden` so it can stand 6px proud of it on both sides.
92
+ // Tracks share a width (see `listMulti`), so the ticks line up by rows.
93
+ barWrap: {
94
+ position: 'relative',
95
+ },
96
+ // Multi-series rows draw one zero line through every series bar instead
97
+ // of a tick per bar: this box is the row's bar column (grid area set
98
+ // inline) and holds a `zeroTick`, so the line stays out of the label.
99
+ multiZeroArea: {
100
+ position: 'absolute',
101
+ inset: 0,
102
+ gridColumn: '1 / 2',
103
+ pointerEvents: 'none',
104
+ },
105
+ zeroTick: {
106
+ position: 'absolute',
107
+ top: -6,
108
+ bottom: -6,
109
+ width: '1px',
110
+ // Centred on the zero so both halves of the bar meet it evenly. No
111
+ // z-index: it comes before the track in the DOM, so the bar paints over
112
+ // it and only the overhang shows.
113
+ transform: 'translateX(-50%)',
114
+ bgcolor: 'text.disabled',
115
+ pointerEvents: 'none',
116
+ },
81
117
  // Layered on `list` only in scroll mode (`maxItems === 0`): right padding
82
118
  // so the bars + right-aligned values clear the scrollbar. Right side only
83
119
  // — the left edge stays at 0 so content stays aligned with the capped
@@ -91,6 +127,7 @@ export const styles = {
91
127
  // bar fills the row width below. Mirrors `rowMulti` for visual
92
128
  // consistency.
93
129
  rowSingle: {
130
+ gridColumn: '1 / -1',
94
131
  display: 'flex',
95
132
  flexDirection: 'column',
96
133
  gap: 0.5,
@@ -124,10 +161,14 @@ export const styles = {
124
161
  // Flex column: label fills the top row; bar+value pairs stack below.
125
162
  // Bars get full track width — better legibility for N>=2 series than a
126
163
  // shared grid would give.
164
+ // Spans the list and adopts its columns, so every row's value column is
165
+ // as wide as the widest value in the list.
127
166
  rowMulti: {
128
- display: 'flex',
129
- flexDirection: 'column',
130
- gap: 0.5,
167
+ position: 'relative',
168
+ gridColumn: '1 / -1',
169
+ display: 'grid',
170
+ gridTemplateColumns: 'subgrid',
171
+ rowGap: 0.5,
131
172
  // py: 0.5,
132
173
  // px: 0.5,
133
174
  cursor: 'pointer',
@@ -155,6 +196,7 @@ export const styles = {
155
196
  fontWeight: 'medium',
156
197
  },
157
198
  multiName: {
199
+ gridColumn: '1 / -1',
158
200
  overflow: 'hidden',
159
201
  whiteSpace: 'nowrap',
160
202
  textOverflow: 'ellipsis',
@@ -171,6 +213,7 @@ export const styles = {
171
213
  // minWidth here is also harmless in that context.
172
214
  minWidth: 48,
173
215
  textAlign: 'right',
216
+ alignSelf: 'center',
174
217
  },
175
218
 
176
219
  // ── Bar (track + fill) ────────────────────────────────────────────
@@ -184,13 +227,12 @@ export const styles = {
184
227
 
185
228
  // ── Multi-series internals ────────────────────────────────────────
186
229
  // One [bar | value] flex row per series, inside `rowMulti`'s column.
230
+ // `contents` hands the bar and the value to `rowMulti`'s subgrid cells.
187
231
  multiBarRow: {
188
- display: 'flex',
189
- alignItems: 'center',
190
- gap: 1,
232
+ display: 'contents',
191
233
  },
192
234
  multiBarContainer: {
193
- flex: 1,
235
+ alignSelf: 'center',
194
236
  },
195
237
 
196
238
  // ── Stacked-mode row ──────────────────────────────────────────────
@@ -198,6 +240,7 @@ export const styles = {
198
240
  // focus ring) but stack three children vertically: header line, bar,
199
241
  // breakdown line.
200
242
  rowStacked: {
243
+ gridColumn: '1 / -1',
201
244
  display: 'flex',
202
245
  flexDirection: 'column',
203
246
  gap: 0.5,
@@ -285,6 +328,7 @@ export const styles = {
285
328
  // (no parentheses). Only the label is the click target when interactive;
286
329
  // the count is always static text beside it.
287
330
  otherRow: {
331
+ gridColumn: '1 / -1',
288
332
  display: 'flex',
289
333
  alignItems: 'center',
290
334
  gap: 1,
@@ -57,14 +57,50 @@ export const styles = {
57
57
  height: ({ spacing }: Theme) => spacing(0.35),
58
58
  zIndex: 1,
59
59
  },
60
+ // Title button, then Actions / Options, then the chevron, as siblings.
61
+ // Same spacing as when they all sat inside the summary: its 16px end
62
+ // padding moves to the row, the content's 6px end padding sits before
63
+ // the chevron.
64
+ header: {
65
+ display: 'flex',
66
+ alignItems: 'center',
67
+ gap: ({ spacing }: Theme) => spacing(0.5),
68
+ minHeight: ({ spacing }: Theme) => spacing(7),
69
+ paddingInlineEnd: ({ spacing }: Theme) => spacing(2),
70
+ },
60
71
  summary: {
72
+ flex: 1,
73
+ minWidth: 0,
61
74
  minHeight: ({ spacing }: Theme) => spacing(7),
75
+ paddingInlineEnd: 0,
62
76
  '& .MuiAccordionSummary-content': {
63
- gap: ({ spacing }: Theme) => spacing(0.5),
64
- paddingInlineEnd: ({ spacing }: Theme) => spacing(0.75),
65
77
  alignItems: 'center',
78
+ minWidth: 0,
79
+ marginInlineEnd: 0,
80
+ paddingInlineEnd: 0,
66
81
  },
67
82
  },
83
+ // No chevron (disabled): keep the actions where they sat when the
84
+ // chevron's slot was empty — 10px further from the edge.
85
+ headerNoIcon: {
86
+ paddingInlineEnd: ({ spacing }: Theme) => spacing(3.25),
87
+ },
88
+ // Looks like the icon the summary used to render in its
89
+ // `expandIconWrapper`: 18px, secondary text colour, rotated when open.
90
+ expandIcon: {
91
+ display: 'flex',
92
+ marginInlineStart: ({ spacing }: Theme) => spacing(0.75),
93
+ color: 'text.secondary',
94
+ cursor: 'pointer',
95
+ '& svg': { width: 18, height: 18, color: 'inherit' },
96
+ transition: ({ transitions }: Theme) =>
97
+ transitions.create('transform', {
98
+ duration: transitions.duration.shortest,
99
+ }),
100
+ },
101
+ expanded: {
102
+ transform: 'rotate(180deg)',
103
+ },
68
104
  // Disabled state — only the collapse toggle is inert (enforced by the
69
105
  // guarded `onChange` handler, not `pointer-events`). Actions and Options in
70
106
  // the summary row, and the details panel, all keep their events. We just
@@ -14,8 +14,8 @@ export interface ActionsProps {
14
14
  * pointer devices), and stay full-opacity when a descendant is marked with
15
15
  * `.active` — typically applied by toggled actions.
16
16
  *
17
- * The container also stops click propagation so interacting with an action
18
- * doesn't toggle the wrapper's collapse.
17
+ * The container also stops click propagation, so a click on an action never
18
+ * reaches handlers further up the widget.
19
19
  */
20
20
  export function Actions({ sx, children }: ActionsProps) {
21
21
  return (
@@ -112,11 +112,41 @@ describe('<Wrapper>', () => {
112
112
  )
113
113
  const actionBtn = screen.getByTestId('action-btn')
114
114
  const bodyContent = screen.getByTestId('body-content')
115
- // The action lives inside the AccordionSummary, the body inside AccordionDetails.
116
- expect(actionBtn.closest('.MuiAccordionSummary-root')).not.toBeNull()
115
+ // The action lives in the header row, beside the summary button rather
116
+ // than inside it (a <button> can't contain another); the body lives in
117
+ // AccordionDetails.
118
+ expect(actionBtn.closest('.widget-wrapper-header')).not.toBeNull()
119
+ expect(actionBtn.closest('.MuiAccordionSummary-root')).toBeNull()
117
120
  expect(bodyContent.closest('.MuiAccordionDetails-root')).not.toBeNull()
118
121
  })
119
122
 
123
+ it('never nests a button inside another', () => {
124
+ const { container } = render(
125
+ withProvider(
126
+ 'wrap-6b',
127
+ <Wrapper title='t'>
128
+ <Actions>
129
+ <button type='button'>A</button>
130
+ </Actions>
131
+ </Wrapper>,
132
+ ),
133
+ )
134
+ expect(container.querySelector('button button')).toBeNull()
135
+ })
136
+
137
+ it('clicking the chevron toggles the wrapper', () => {
138
+ const { container } = render(
139
+ withProvider('wrap-6c', <Wrapper title='t'>body</Wrapper>),
140
+ )
141
+ const summary = screen.getByRole('button', { name: 'Collapse' })
142
+ expect(summary.getAttribute('aria-expanded')).toBe('true')
143
+ const chevron = container.querySelector(
144
+ '.widget-wrapper-header [aria-hidden]',
145
+ )
146
+ fireEvent.click(chevron!)
147
+ expect(summary.getAttribute('aria-expanded')).toBe('false')
148
+ })
149
+
120
150
  it('shows a LinearProgress at the top when the store reports isFetching', () => {
121
151
  render(
122
152
  withProvider(
@@ -54,8 +54,8 @@ export interface WrapperProps {
54
54
  variant?: 'outlined' | 'elevation'
55
55
  labels?: Partial<WrapperLabels>
56
56
  /**
57
- * Icon shown at the right edge of the summary row. Accordion rotates it
58
- * 180° automatically when the wrapper expands. Default: `ExpandMoreIcon`.
57
+ * Icon shown at the right edge of the header row, rotated 180° while the
58
+ * wrapper is expanded. Default: `ExpandMoreIcon`.
59
59
  */
60
60
  expandIcon?: ComponentType<SvgIconProps>
61
61
  iconProps?: SvgIconProps
@@ -117,10 +117,23 @@ export function Wrapper({
117
117
 
118
118
  const ariaLabel = effectiveCollapsed ? _labels.expand : _labels.collapse
119
119
 
120
+ const expanded = !effectiveCollapsed
121
+ const toggle = useCallback(
122
+ () => handleAccordionToggle(null, !expanded),
123
+ [handleAccordionToggle, expanded],
124
+ )
125
+
126
+ // The header row is the Accordion's first child, which MUI treats as its
127
+ // summary slot. Only the title sits inside `<AccordionSummary>` — it
128
+ // renders as a `<button>`, and Actions / Options hold buttons of their
129
+ // own, which HTML doesn't allow inside another button. They sit next to
130
+ // it instead, followed by the chevron: a pointer shortcut for the same
131
+ // toggle, hidden from assistive tech since the summary button already
132
+ // carries it.
120
133
  return (
121
134
  <Accordion
122
135
  data-collapsed={effectiveCollapsed ? 'true' : undefined}
123
- expanded={!effectiveCollapsed}
136
+ expanded={expanded}
124
137
  onChange={handleAccordionToggle}
125
138
  disableGutters
126
139
  elevation={0}
@@ -131,41 +144,54 @@ export function Wrapper({
131
144
  ...sx,
132
145
  }}
133
146
  >
134
- {isFetching ? (
135
- <LinearProgress sx={styles.loading} color='primary' />
136
- ) : null}
137
-
138
- <AccordionSummary
139
- expandIcon={
140
- disabled ? null : <ExpandIcon fontSize='small' {...iconProps} />
141
- }
142
- aria-label={ariaLabel}
143
- aria-disabled={disabled || undefined}
144
- sx={{
145
- ...styles.summary,
146
- ...(disabled ? styles.summaryDisabled : null),
147
- }}
147
+ <Box
148
+ className='widget-wrapper-header'
149
+ sx={{ ...styles.header, ...(disabled && styles.headerNoIcon) }}
148
150
  >
149
- <SmartTooltip title={title}>
150
- {({ ref }) => (
151
- <Box sx={styles.titleCell}>
152
- <Typography
153
- ref={ref}
154
- variant='subtitle1'
155
- sx={[
156
- {
157
- textTransform: 'none',
158
- },
159
- styles.titleText,
160
- ]}
161
- >
162
- {title}
163
- </Typography>
164
- </Box>
165
- )}
166
- </SmartTooltip>
151
+ {isFetching ? (
152
+ <LinearProgress sx={styles.loading} color='primary' />
153
+ ) : null}
154
+ <AccordionSummary
155
+ // The chevron is rendered after Actions below; `null` also keeps
156
+ // a theme-level default icon out of the summary.
157
+ expandIcon={null}
158
+ aria-label={ariaLabel}
159
+ aria-disabled={disabled || undefined}
160
+ sx={{
161
+ ...styles.summary,
162
+ ...(disabled ? styles.summaryDisabled : null),
163
+ }}
164
+ >
165
+ <SmartTooltip title={title}>
166
+ {({ ref }) => (
167
+ <Box sx={styles.titleCell}>
168
+ <Typography
169
+ ref={ref}
170
+ variant='subtitle1'
171
+ sx={[
172
+ {
173
+ textTransform: 'none',
174
+ },
175
+ styles.titleText,
176
+ ]}
177
+ >
178
+ {title}
179
+ </Typography>
180
+ </Box>
181
+ )}
182
+ </SmartTooltip>
183
+ </AccordionSummary>
167
184
  {summarySlots}
168
- </AccordionSummary>
185
+ {disabled ? null : (
186
+ <Box
187
+ aria-hidden
188
+ onClick={toggle}
189
+ sx={{ ...styles.expandIcon, ...(expanded && styles.expanded) }}
190
+ >
191
+ <ExpandIcon fontSize='small' {...iconProps} />
192
+ </Box>
193
+ )}
194
+ </Box>
169
195
  <AccordionDetails sx={styles.detail}>{detailsSlots}</AccordionDetails>
170
196
  </Accordion>
171
197
  )