@ultimat3/ui 24.0.0 → 25.1.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 (86) hide show
  1. package/CATALOG.md +117 -13
  2. package/CLAUDE.md +14 -5
  3. package/README.md +109 -24
  4. package/package.json +6 -6
  5. package/src/a11y.ts +3 -3
  6. package/src/catalog/build-catalog.ts +2 -2
  7. package/src/components/Accordion.tsx +8 -2
  8. package/src/components/AppShell.module.scss +105 -4
  9. package/src/components/AppShell.tsx +48 -10
  10. package/src/components/BarChart.module.scss +47 -9
  11. package/src/components/BarChart.tsx +152 -72
  12. package/src/components/Card.tsx +52 -16
  13. package/src/components/ChartFrame.module.scss +188 -0
  14. package/src/components/ChartFrame.tsx +155 -0
  15. package/src/components/Container.tsx +46 -9
  16. package/src/components/DataTable.module.scss +190 -0
  17. package/src/components/DataTable.tsx +124 -17
  18. package/src/components/Divider.tsx +0 -2
  19. package/src/components/DonutChart.module.scss +73 -0
  20. package/src/components/DonutChart.tsx +154 -0
  21. package/src/components/Dropzone.tsx +9 -1
  22. package/src/components/ErrorState.tsx +39 -12
  23. package/src/components/Form.tsx +17 -4
  24. package/src/components/Gauge.module.scss +48 -0
  25. package/src/components/Gauge.tsx +67 -0
  26. package/src/components/Grid.tsx +34 -13
  27. package/src/components/Image.tsx +2 -2
  28. package/src/components/InlineBar.module.scss +23 -0
  29. package/src/components/InlineBar.tsx +31 -0
  30. package/src/components/LineChart.module.scss +147 -0
  31. package/src/components/LineChart.tsx +169 -0
  32. package/src/components/Menu.tsx +9 -5
  33. package/src/components/PageHeader.tsx +5 -2
  34. package/src/components/Popover.tsx +2 -2
  35. package/src/components/Section.tsx +45 -26
  36. package/src/components/Stack.tsx +59 -18
  37. package/src/components/Table.module.scss +30 -0
  38. package/src/components/Table.tsx +14 -3
  39. package/src/components/Tabs.tsx +2 -2
  40. package/src/components/Text.tsx +41 -15
  41. package/src/components/Toaster.tsx +1 -1
  42. package/src/components/Toolbar.tsx +7 -6
  43. package/src/components/app-shell-view.ts +6 -4
  44. package/src/components/bar-chart-view.ts +5 -4
  45. package/src/components/chart-frame-view.ts +219 -0
  46. package/src/components/chart-ticks-view.ts +173 -0
  47. package/src/components/data-table-view.ts +68 -0
  48. package/src/components/donut-chart-view.ts +120 -0
  49. package/src/components/file-input-view.ts +1 -1
  50. package/src/components/gauge-view.ts +43 -0
  51. package/src/components/heading-node.tsx +62 -0
  52. package/src/components/image-source.ts +3 -3
  53. package/src/components/line-chart-view.ts +153 -0
  54. package/src/components/money-view.ts +2 -7
  55. package/src/form/form-binding.ts +23 -6
  56. package/src/form/use-form.ts +3 -3
  57. package/src/global.scss +9 -0
  58. package/src/icons/build-icons.ts +15 -6
  59. package/src/index.ts +45 -9
  60. package/src/jsx-probe.ts +5 -4
  61. package/src/theme/brand-declarations.ts +113 -0
  62. package/src/theme/brand.ts +98 -109
  63. package/src/theme/context.ts +1 -1
  64. package/src/theme/preset-scifi.ts +99 -0
  65. package/src/theme/preset-shape.ts +18 -0
  66. package/src/theme/presets.ts +15 -0
  67. package/src/theme/shadow-value.ts +39 -0
  68. package/src/theme/theme.ts +1 -1
  69. package/src/toast/toast-store.ts +1 -1
  70. package/src/toast/use-toasts.ts +1 -1
  71. package/src/tokens/_colors.scss +20 -0
  72. package/src/tokens/_effects.scss +59 -0
  73. package/src/tokens/_index.scss +2 -0
  74. package/src/tokens/_mixins.scss +6 -4
  75. package/src/tokens/_shadow.scss +16 -0
  76. package/src/tokens/_touch.scss +39 -0
  77. package/src/tokens/_typography.scss +14 -0
  78. package/src/tokens/color-tokens.ts +133 -0
  79. package/src/tokens/colour-vision.ts +106 -0
  80. package/src/tokens/contrast-pairs.ts +6 -1
  81. package/src/tokens/contrast.ts +1 -1
  82. package/src/tokens/theme.scss +2 -0
  83. package/src/tokens/tokens.ts +41 -87
  84. package/src/components/qr-reader.ts +0 -187
  85. package/src/fake-dom.ts +0 -206
  86. package/src/sass-probe.ts +0 -55
@@ -0,0 +1,67 @@
1
+ // One value against its maximum as a 270° dial — CPU, a quota, a queue's fill — with the reading
2
+ // in its centre and its label under it. A `meter` role carries the value for assistive tech, the
3
+ // way `Meter` does; the readout is HTML over the svg so it keeps its size on a phone.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { useUi } from '../theme/context';
8
+ import { ChartFrame } from './ChartFrame';
9
+ import { chartPercentFormat, cssPercent } from './chart-frame-view';
10
+ import styles from './Gauge.module.scss';
11
+ import { GAUGE, gaugeArcs } from './gauge-view';
12
+ import { meterAria, meterShare } from './meter-view';
13
+ import type { Tone } from './variants';
14
+
15
+ export interface GaugeProps {
16
+ /** What is measured, already translated: shown under the dial, and the meter's name. */
17
+ label: string;
18
+ value: number;
19
+ max: number;
20
+ /** The fill's colour role. Default `accent`; a caller picks `warning`/`danger` by threshold. */
21
+ tone?: Tone | undefined;
22
+ /** The readout, from the value and max. Default: the share as a percentage in the page's locale. */
23
+ format?: ((value: number, max: number) => string) | undefined;
24
+ /** Lands on the root `<figure>`. */
25
+ class?: string | undefined;
26
+ }
27
+
28
+ export function Gauge(props: GaugeProps): JSX.Element {
29
+ const ui = useUi();
30
+ const share = (): number => meterShare(props.value, props.max);
31
+ const readout = (): string =>
32
+ props.format === undefined
33
+ ? chartPercentFormat(ui.locale)(share())
34
+ : props.format(props.value, props.max);
35
+ const arcs = () => gaugeArcs(share());
36
+ return (
37
+ <ChartFrame label={props.label} showCaption class={cx(styles['gauge'], props.class)}>
38
+ <div class={cx(styles['dial'], styles[`tone-${props.tone ?? 'accent'}`])}>
39
+ {/* A native <meter> paints through per-browser pseudo-elements, so it cannot be drawn
40
+ from tokens; the role goes on the svg that draws it, as in `Meter`. */}
41
+ {/* biome-ignore lint/a11y/useSemanticElements: <meter> cannot be styled from tokens */}
42
+ <svg
43
+ class={styles['plot']}
44
+ viewBox={`0 0 ${GAUGE.width} ${GAUGE.height}`}
45
+ role="meter"
46
+ aria-label={props.label}
47
+ aria-valuemin={0}
48
+ aria-valuemax={meterAria(props.value, props.max).max}
49
+ aria-valuenow={meterAria(props.value, props.max).now}
50
+ aria-valuetext={readout()}
51
+ >
52
+ <path class={styles['track']} d={arcs().track} />
53
+ {arcs().fill === '' ? null : <path class={styles['fill']} d={arcs().fill} />}
54
+ </svg>
55
+ {/* aria-hidden: the meter already speaks this as its value text. */}
56
+ <span
57
+ class={styles['readout']}
58
+ aria-hidden="true"
59
+ data-readout="true"
60
+ style={{ '--centre': cssPercent(GAUGE.centre, GAUGE.height) }}
61
+ >
62
+ {readout()}
63
+ </span>
64
+ </div>
65
+ </ChartFrame>
66
+ );
67
+ }
@@ -19,22 +19,43 @@ export interface GridProps {
19
19
  }
20
20
 
21
21
  export function Grid(props: GridProps): JSX.Element {
22
- const Tag = props.as ?? 'div';
23
22
  const tracks = (): string =>
24
23
  props.columns === undefined
25
24
  ? `repeat(auto-fit, minmax(${props.minColumn ?? '16rem'}, 1fr))`
26
25
  : `repeat(${props.columns}, minmax(0, 1fr))`;
26
+ const cls = (): string => cx(styles['grid'], props.class);
27
+ const style = (): JSX.CSSProperties => ({
28
+ '--grid-tracks': tracks(),
29
+ '--grid-gap': `var(--space-${props.gap ?? 4})`,
30
+ '--grid-row-gap': `var(--space-${props.rowGap ?? props.gap ?? 4})`,
31
+ });
27
32
 
28
- return (
29
- <Tag
30
- class={cx(styles['grid'], props.class)}
31
- style={{
32
- '--grid-tracks': tracks(),
33
- '--grid-gap': `var(--space-${props.gap ?? 4})`,
34
- '--grid-row-gap': `var(--space-${props.rowGap ?? props.gap ?? 4})`,
35
- }}
36
- >
37
- {props.children}
38
- </Tag>
39
- );
33
+ // One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
34
+ // capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
35
+ switch (props.as ?? 'div') {
36
+ case 'ul':
37
+ return (
38
+ <ul class={cls()} style={style()}>
39
+ {props.children}
40
+ </ul>
41
+ );
42
+ case 'ol':
43
+ return (
44
+ <ol class={cls()} style={style()}>
45
+ {props.children}
46
+ </ol>
47
+ );
48
+ case 'section':
49
+ return (
50
+ <section class={cls()} style={style()}>
51
+ {props.children}
52
+ </section>
53
+ );
54
+ case 'div':
55
+ return (
56
+ <div class={cls()} style={style()}>
57
+ {props.children}
58
+ </div>
59
+ );
60
+ }
40
61
  }
@@ -20,7 +20,7 @@ import {
20
20
  loadingHints,
21
21
  reservedRatio,
22
22
  sourceSetsFor,
23
- srcsetFor,
23
+ variantSrcset,
24
24
  } from './image-source';
25
25
 
26
26
  /**
@@ -77,7 +77,7 @@ export function Image(props: ImageBaseProps & ImageDimensions): JSX.Element {
77
77
  style={{ '--image-ratio': ratio() }}
78
78
  src={src()}
79
79
  alt={props.alt}
80
- srcset={srcsetFor(props.variants)}
80
+ srcset={variantSrcset(props.variants)}
81
81
  sizes={props.sizes}
82
82
  width={box()?.width}
83
83
  height={box()?.height}
@@ -0,0 +1,23 @@
1
+ @use '../tokens' as t;
2
+
3
+ // The figure first, the bar filling what is left: in a numeric column the figures align at the
4
+ // trailing edge and the bars start where the figures end, so the column reads as one chart.
5
+ .inlineBar {
6
+ display: inline-grid;
7
+ grid-template-columns: auto minmax(t.space(12), 1fr);
8
+ align-items: center;
9
+ gap: t.space(2);
10
+ inline-size: 100%;
11
+ min-inline-size: 0;
12
+ }
13
+
14
+ .value {
15
+ @include t.data-text;
16
+ text-align: end;
17
+ }
18
+
19
+ // `Meter` draws in an SVG, whose x axis is physical: mirrored here so the bar grows from the
20
+ // inline start in a right-to-left document too.
21
+ .bar:dir(rtl) {
22
+ scale: -1 1;
23
+ }
@@ -0,0 +1,31 @@
1
+ // A figure with a bar drawn to its share of a maximum — the in-cell bar a dashboard table uses to
2
+ // make a column of numbers scannable. The text is the fact and is what assistive tech reads; the
3
+ // bar is `Meter`'s decorative form, so the share rule and its clamping are written once.
4
+ // The figure arrives formatted, as `StatTile`'s does: this component never guesses a locale.
5
+
6
+ import type { JSX } from 'solid-js';
7
+ import { cx } from '../cx';
8
+ import styles from './InlineBar.module.scss';
9
+ import { Meter } from './Meter';
10
+ import type { Tone } from './variants';
11
+
12
+ export interface InlineBarProps {
13
+ value: number;
14
+ /** The whole the bar is a share of — usually the largest value in the column. */
15
+ max: number;
16
+ /** The figure as the cell shows it, already formatted (`Intl.NumberFormat`, `formatMoney`). */
17
+ text: string;
18
+ tone?: Tone | undefined;
19
+ class?: string | undefined;
20
+ }
21
+
22
+ export function InlineBar(props: InlineBarProps): JSX.Element {
23
+ return (
24
+ <span class={cx(styles['inlineBar'], props.class)}>
25
+ <span class={styles['value']} data-inline-bar-value="">
26
+ {props.text}
27
+ </span>
28
+ <Meter class={styles['bar']} value={props.value} max={props.max} tone={props.tone} />
29
+ </span>
30
+ );
31
+ }
@@ -0,0 +1,147 @@
1
+ @use '../tokens' as t;
2
+
3
+ // Value labels | plot, then the key labels under the plot. The grid is the container the label
4
+ // density is decided against — the plot's own width, not the viewport's.
5
+ .chart {
6
+ @include t.container(line-chart);
7
+ display: grid;
8
+ grid-template-columns: auto minmax(0, 1fr);
9
+ grid-template-areas:
10
+ 'values box'
11
+ '. keys';
12
+ column-gap: t.space(2);
13
+ row-gap: t.space(1);
14
+ // The svg does not mirror in a right-to-left page, so the labels positioned over it must not
15
+ // either: inline-start IS the svg's x = 0 here.
16
+ direction: ltr;
17
+ }
18
+
19
+ .box {
20
+ grid-area: box;
21
+ position: relative;
22
+ min-inline-size: 0;
23
+ }
24
+
25
+ .plot {
26
+ display: block;
27
+ inline-size: 100%;
28
+ block-size: auto;
29
+ overflow: visible;
30
+ }
31
+
32
+ .grid {
33
+ stroke: t.role('line', 0.55);
34
+ stroke-width: 1;
35
+ stroke-dasharray: 2 4;
36
+ // Hairlines stay hairlines however wide the chart is stretched.
37
+ vector-effect: non-scaling-stroke;
38
+ }
39
+
40
+ .line {
41
+ fill: none;
42
+ stroke: var(--series);
43
+ stroke-width: 2;
44
+ stroke-linejoin: round;
45
+ stroke-linecap: round;
46
+ vector-effect: non-scaling-stroke;
47
+ }
48
+
49
+ .area {
50
+ fill: var(--series);
51
+ fill-opacity: 0.16;
52
+ stroke: none;
53
+ }
54
+
55
+ .marker {
56
+ fill: var(--series);
57
+ // A ring of the surface around each marker keeps it legible where two lines cross.
58
+ stroke: t.role('surface');
59
+ stroke-width: 1;
60
+ vector-effect: non-scaling-stroke;
61
+ }
62
+
63
+ // --- Labels: HTML text, so it keeps its type step at every chart width (issue 494) -----------
64
+
65
+ .tick,
66
+ .key {
67
+ @include t.data-text;
68
+ font-size: t.text(xs);
69
+ line-height: t.leading(tight);
70
+ color: t.role('fg-muted');
71
+ white-space: nowrap;
72
+ }
73
+
74
+ // Every value label in one cell, each offset to its grid line: the column is as wide as the
75
+ // widest label and exactly as tall as the plot it stands beside.
76
+ .values {
77
+ grid-area: values;
78
+ display: grid;
79
+ }
80
+
81
+ .tick {
82
+ grid-area: 1 / 1;
83
+ align-self: start;
84
+ justify-self: end;
85
+ position: relative;
86
+ inset-block-start: var(--at);
87
+ translate: 0 -50%;
88
+ }
89
+
90
+ .keys {
91
+ grid-area: keys;
92
+ display: grid;
93
+ min-inline-size: 0;
94
+ }
95
+
96
+ // Capped at a third of the axis: three labels on a stride of at least half the axis cannot meet.
97
+ .key {
98
+ @include t.truncate;
99
+ grid-area: 1 / 1;
100
+ max-inline-size: calc(100% / 3);
101
+ }
102
+
103
+ .edge-start {
104
+ justify-self: start;
105
+ margin-inline-start: var(--at);
106
+ }
107
+
108
+ .edge-middle {
109
+ justify-self: start;
110
+ margin-inline-start: var(--at);
111
+ translate: -50% 0;
112
+ }
113
+
114
+ .edge-end {
115
+ justify-self: end;
116
+ margin-inline-end: calc(100% - var(--at));
117
+ }
118
+
119
+ // Narrow first — a 390px phone: three key labels, every other value label.
120
+ .key[data-narrow='false'] {
121
+ display: none;
122
+ }
123
+
124
+ .tick[data-minor='true'] {
125
+ // `visibility`, never `display`: the column keeps the width of its widest label either way.
126
+ visibility: hidden;
127
+ }
128
+
129
+ // A plot this wide holds six key labels (each capped at 2/15 of the axis: ⅔ of a fifth) and
130
+ // every value label.
131
+ @include t.container-query(line-chart, 36rem) {
132
+ .key[data-narrow='false'] {
133
+ display: block;
134
+ }
135
+
136
+ .key[data-wide='false'] {
137
+ display: none;
138
+ }
139
+
140
+ .key {
141
+ max-inline-size: calc(100% * 2 / 15);
142
+ }
143
+
144
+ .tick[data-minor='true'] {
145
+ visibility: visible;
146
+ }
147
+ }
@@ -0,0 +1,169 @@
1
+ // A line chart of one or more series over the same keys — signups per day, p95 per hour — and
2
+ // `AreaChart`, the same chart with each series' area filled to the baseline. SVG lines on a nice
3
+ // 1-2-5 value axis, HTML axis labels (text inside a viewBox scales to a speck on a phone), a
4
+ // legend, and a hidden data table. Every series has its own dash and marker, never colour alone.
5
+
6
+ import type { JSX } from 'solid-js';
7
+ import { cx } from '../cx';
8
+ import { useUi } from '../theme/context';
9
+ import type { ChartHit } from './ChartFrame';
10
+ import { ChartFrame, chartHits, seriesSwatch } from './ChartFrame';
11
+ import type { ChartSeries } from './chart-frame-view';
12
+ import {
13
+ chartNumberFormat,
14
+ chartTable,
15
+ cssPercent,
16
+ hitEdge,
17
+ markerPath,
18
+ pointLabel,
19
+ seriesStyle,
20
+ } from './chart-frame-view';
21
+ import { categoryLabels, minorTicks, tickDecimals } from './chart-ticks-view';
22
+ import styles from './LineChart.module.scss';
23
+ import type { LineLayout } from './line-chart-view';
24
+ import { LINE_CHART, lineLayout, markedPoints } from './line-chart-view';
25
+
26
+ export interface LineChartProps {
27
+ /** The chart's name, already translated: the figure's caption and the svg's accessible name. */
28
+ label: string;
29
+ /** The x axis, oldest first, already formatted for display — "17 Sep", "14:00". */
30
+ keys: readonly string[];
31
+ /** One entry per line. `values[i]` belongs to `keys[i]`; `null` is a gap, never a zero. */
32
+ series: readonly ChartSeries[];
33
+ /** The key column's head in the data table, already translated — "Day". */
34
+ keyLabel?: string | undefined;
35
+ /** Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. */
36
+ format?: ((value: number) => string) | undefined;
37
+ /** Start the value axis at zero. Default on; off fits the axis to the data. */
38
+ zero?: boolean | undefined;
39
+ /** Give every point a tab stop and a hover/focus readout. Default off: no tab stops. */
40
+ focusable?: boolean | undefined;
41
+ /** Show the caption above the plot. Default off — it names the figure either way. */
42
+ showCaption?: boolean | undefined;
43
+ /** Lands on the root `<figure>`. */
44
+ class?: string | undefined;
45
+ }
46
+
47
+ /** The same props: an area chart is a line chart that fills. */
48
+ export type AreaChartProps = LineChartProps;
49
+
50
+ /** Up to six value labels: at 390px a seventh crowds the ones beside it. */
51
+ const MAX_TICKS = 6;
52
+ const MARKER_RADIUS = 4;
53
+
54
+ export function LineChart(props: LineChartProps): JSX.Element {
55
+ return xyChart(props, false);
56
+ }
57
+
58
+ export function AreaChart(props: AreaChartProps): JSX.Element {
59
+ return xyChart(props, true);
60
+ }
61
+
62
+ function xyChart(props: LineChartProps, filled: boolean): JSX.Element {
63
+ const ui = useUi();
64
+ const layout = (): LineLayout =>
65
+ lineLayout({
66
+ keys: props.keys,
67
+ series: props.series,
68
+ zero: props.zero !== false,
69
+ maxTicks: MAX_TICKS,
70
+ });
71
+ const format = (value: number): string => (props.format ?? chartNumberFormat(ui.locale))(value);
72
+ const tickFormat = (value: number): string =>
73
+ (props.format ?? chartNumberFormat(ui.locale, tickDecimals(layout().scale.step)))(value);
74
+ const { width, height } = LINE_CHART;
75
+
76
+ const hits = (): readonly ChartHit[] =>
77
+ props.series.flatMap((series, s) =>
78
+ (layout().series[s]?.points ?? []).map((point) => ({
79
+ x: cssPercent(point.x, width),
80
+ y: cssPercent(point.y, height),
81
+ label: pointLabel(props.keys[point.index] ?? '', series.label, format(point.value)),
82
+ edge: hitEdge(point.x, width),
83
+ })),
84
+ );
85
+
86
+ return (
87
+ <ChartFrame
88
+ label={props.label}
89
+ showCaption={props.showCaption}
90
+ class={props.class}
91
+ legend={
92
+ props.series.length < 2
93
+ ? undefined
94
+ : props.series.map((series, s) => ({
95
+ label: series.label,
96
+ swatch: seriesSwatch(seriesStyle(s)),
97
+ }))
98
+ }
99
+ table={chartTable({
100
+ caption: props.label,
101
+ keyLabel: props.keyLabel,
102
+ keys: props.keys,
103
+ series: props.series,
104
+ format,
105
+ })}
106
+ >
107
+ <div class={styles['chart']}>
108
+ <div class={styles['values']} aria-hidden="true">
109
+ {layout().ticks.map((tick, i) => (
110
+ <span
111
+ class={styles['tick']}
112
+ data-tick="value"
113
+ data-minor={minorTicks(layout().ticks.length)[i] === true ? 'true' : 'false'}
114
+ style={{ '--at': cssPercent(tick.y, height) }}
115
+ >
116
+ {tickFormat(tick.value)}
117
+ </span>
118
+ ))}
119
+ </div>
120
+ <div class={styles['box']}>
121
+ <svg
122
+ role="img"
123
+ aria-label={props.label}
124
+ viewBox={`0 0 ${width} ${height}`}
125
+ class={styles['plot']}
126
+ >
127
+ {layout().ticks.map((tick) => (
128
+ <line class={styles['grid']} x1={0} x2={width} y1={tick.y} y2={tick.y} />
129
+ ))}
130
+ {props.series.map((_, s) => {
131
+ const style = seriesStyle(s);
132
+ const geometry = layout().series[s];
133
+ if (geometry === undefined) return null;
134
+ return (
135
+ <g data-series={style.slot} data-line={String(s)}>
136
+ {filled && geometry.area !== '' ? (
137
+ <path class={styles['area']} d={geometry.area} data-area="true" />
138
+ ) : null}
139
+ <path class={styles['line']} d={geometry.line} stroke-dasharray={style.dash} />
140
+ {markedPoints(geometry.points, props.keys.length).map((point) => (
141
+ <path
142
+ class={styles['marker']}
143
+ d={markerPath(style.marker, point.x, point.y, MARKER_RADIUS)}
144
+ data-marker={style.marker}
145
+ />
146
+ ))}
147
+ </g>
148
+ );
149
+ })}
150
+ </svg>
151
+ {props.focusable === true ? chartHits(hits()) : null}
152
+ </div>
153
+ <div class={styles['keys']} aria-hidden="true">
154
+ {categoryLabels(props.keys.length).map((label) => (
155
+ <span
156
+ class={cx(styles['key'], styles[`edge-${label.edge}`])}
157
+ data-tick="key"
158
+ data-narrow={String(label.narrow)}
159
+ data-wide={String(label.wide)}
160
+ style={{ '--at': cssPercent(layout().keyX[label.index] ?? 0, width) }}
161
+ >
162
+ {props.keys[label.index]}
163
+ </span>
164
+ ))}
165
+ </div>
166
+ </div>
167
+ </ChartFrame>
168
+ );
169
+ }
@@ -2,7 +2,7 @@
2
2
  // Distinct from Select — a menu runs commands, it does not hold a form value.
3
3
 
4
4
  import type { JSX } from 'solid-js';
5
- import { createFocusTrap, createRovingTabindex, useId } from '../a11y';
5
+ import { focusTrap, rovingTabindex, useId } from '../a11y';
6
6
  import { cx } from '../cx';
7
7
  import { MENU_ITEM_SELECTOR, tabStopIndex } from '../roving';
8
8
  import { useUi } from '../theme/context';
@@ -24,11 +24,15 @@ export interface MenuProps {
24
24
  items: readonly MenuItem[];
25
25
  open: boolean;
26
26
  onOpenChange: (open: boolean) => void;
27
+ /**
28
+ * The menu button. `aria-controls` is present only while the list is mounted: an IDREF to an
29
+ * element not in the document names nothing.
30
+ */
27
31
  trigger: (control: {
28
32
  id: string;
29
33
  'aria-haspopup': 'menu';
30
34
  'aria-expanded': boolean;
31
- 'aria-controls': string;
35
+ 'aria-controls': string | undefined;
32
36
  }) => JSX.Element;
33
37
  /** Already-translated accessible name for the menu. */
34
38
  label: string;
@@ -45,7 +49,7 @@ export function Menu(props: MenuProps): JSX.Element {
45
49
 
46
50
  // Disabled items are excluded from BOTH answers — the list arrows walk and the one item that
47
51
  // carries the tab stop — because `focus()` on a disabled button silently does nothing.
48
- const onKeyDown = createRovingTabindex(
52
+ const onKeyDown = rovingTabindex(
49
53
  () =>
50
54
  list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)),
51
55
  { orientation: 'vertical', dir: ui.dir },
@@ -65,7 +69,7 @@ export function Menu(props: MenuProps): JSX.Element {
65
69
  // Closing unmounts the panel with focus inside it, which resets focus to <body> and makes the
66
70
  // next Tab restart at the top of the document. The trap moves focus in on open and hands it
67
71
  // back to the trigger on close, so Escape returns the user where they were.
68
- const trap = list === undefined ? undefined : createFocusTrap(list);
72
+ const trap = list === undefined ? undefined : focusTrap(list);
69
73
  trap?.activate();
70
74
  rt.onCleanup(() => {
71
75
  document.removeEventListener('pointerdown', onPointerDown, true);
@@ -85,7 +89,7 @@ export function Menu(props: MenuProps): JSX.Element {
85
89
  id: `${menuId}-trigger`,
86
90
  'aria-haspopup': 'menu',
87
91
  'aria-expanded': props.open,
88
- 'aria-controls': menuId,
92
+ 'aria-controls': props.open ? menuId : undefined,
89
93
  })}
90
94
  {props.open ? (
91
95
  <div
@@ -6,6 +6,7 @@ import type { JSX } from 'solid-js';
6
6
  import { cx } from '../cx';
7
7
  import { Breadcrumb, type BreadcrumbItem } from './Breadcrumb';
8
8
  import { type HeadingLevel, headingTag } from './heading-level';
9
+ import { headingNode } from './heading-node';
9
10
  import styles from './PageHeader.module.scss';
10
11
 
11
12
  export interface PageHeaderProps {
@@ -24,7 +25,9 @@ export interface PageHeaderProps {
24
25
  }
25
26
 
26
27
  export function PageHeader(props: PageHeaderProps): JSX.Element {
27
- const Heading = headingTag(props.level ?? 1);
28
+ // Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
29
+ const heading = headingTag(props.level ?? 1);
30
+ const title = (): string => props.title;
28
31
 
29
32
  return (
30
33
  <header class={cx(styles['pageHeader'], props.class)}>
@@ -33,7 +36,7 @@ export function PageHeader(props: PageHeaderProps): JSX.Element {
33
36
  <div class={styles['titles']}>
34
37
  {props.media === undefined ? null : <div class={styles['media']}>{props.media}</div>}
35
38
  <div class={styles['text']}>
36
- <Heading class={styles['title']}>{props.title}</Heading>
39
+ {headingNode(heading, { class: styles['title'] }, title)}
37
40
  {props.description === undefined ? null : (
38
41
  <p class={styles['description']}>{props.description}</p>
39
42
  )}
@@ -3,7 +3,7 @@
3
3
  // loop and the panel mirrors under `dir="rtl"` — `rtl-sheets.test.ts` holds that.
4
4
 
5
5
  import type { JSX } from 'solid-js';
6
- import { createFocusTrap, useId } from '../a11y';
6
+ import { focusTrap, useId } from '../a11y';
7
7
  import { cx } from '../cx';
8
8
  import { solid } from '../theme/solid-adapter';
9
9
  import styles from './Popover.module.scss';
@@ -50,7 +50,7 @@ export function Popover(props: PopoverProps): JSX.Element {
50
50
  // Closing unmounts the panel with focus inside it, which drops focus to <body> and makes the
51
51
  // next Tab restart at the top of the document. The trap moves focus into the panel on open and
52
52
  // hands it back to the trigger on close.
53
- const trap = panel === undefined ? undefined : createFocusTrap(panel);
53
+ const trap = panel === undefined ? undefined : focusTrap(panel);
54
54
  trap?.activate();
55
55
  rt.onCleanup(() => {
56
56
  document.removeEventListener('pointerdown', onPointerDown, true);