@ultimat3/ui 23.0.0 → 25.0.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 (100) hide show
  1. package/CATALOG.md +119 -15
  2. package/CLAUDE.md +17 -8
  3. package/README.md +119 -33
  4. package/package.json +6 -6
  5. package/src/a11y.ts +27 -13
  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/CommandPalette.module.scss +8 -5
  16. package/src/components/Container.tsx +46 -9
  17. package/src/components/DataTable.module.scss +190 -0
  18. package/src/components/DataTable.tsx +161 -51
  19. package/src/components/Divider.tsx +0 -2
  20. package/src/components/DonutChart.module.scss +73 -0
  21. package/src/components/DonutChart.tsx +154 -0
  22. package/src/components/Dropzone.module.scss +3 -1
  23. package/src/components/Dropzone.tsx +17 -8
  24. package/src/components/ErrorState.tsx +39 -12
  25. package/src/components/FileInput.module.scss +3 -1
  26. package/src/components/FileInput.tsx +6 -2
  27. package/src/components/Form.tsx +17 -4
  28. package/src/components/Gauge.module.scss +48 -0
  29. package/src/components/Gauge.tsx +67 -0
  30. package/src/components/Grid.tsx +34 -13
  31. package/src/components/Image.tsx +2 -2
  32. package/src/components/InlineBar.module.scss +23 -0
  33. package/src/components/InlineBar.tsx +31 -0
  34. package/src/components/LineChart.module.scss +147 -0
  35. package/src/components/LineChart.tsx +169 -0
  36. package/src/components/Menu.tsx +9 -5
  37. package/src/components/Meter.tsx +3 -3
  38. package/src/components/PageHeader.tsx +5 -2
  39. package/src/components/Popover.module.scss +17 -20
  40. package/src/components/Popover.tsx +14 -7
  41. package/src/components/Section.tsx +45 -26
  42. package/src/components/Stack.tsx +59 -18
  43. package/src/components/Table.module.scss +30 -0
  44. package/src/components/Table.tsx +14 -3
  45. package/src/components/Tabs.module.scss +6 -0
  46. package/src/components/Tabs.tsx +2 -2
  47. package/src/components/Text.tsx +41 -15
  48. package/src/components/Textarea.tsx +7 -4
  49. package/src/components/Toaster.tsx +1 -1
  50. package/src/components/Toolbar.tsx +7 -6
  51. package/src/components/Tooltip.module.scss +8 -3
  52. package/src/components/app-shell-view.ts +6 -4
  53. package/src/components/bar-chart-view.ts +11 -6
  54. package/src/components/chart-frame-view.ts +219 -0
  55. package/src/components/chart-ticks-view.ts +173 -0
  56. package/src/components/data-table-view.ts +68 -0
  57. package/src/components/donut-chart-view.ts +120 -0
  58. package/src/components/file-input-view.ts +32 -13
  59. package/src/components/gauge-view.ts +43 -0
  60. package/src/components/heading-node.tsx +62 -0
  61. package/src/components/image-source.ts +3 -3
  62. package/src/components/line-chart-view.ts +153 -0
  63. package/src/components/link-target.ts +21 -1
  64. package/src/components/meter-view.ts +13 -0
  65. package/src/components/money-view.ts +2 -7
  66. package/src/components/qr-matrix.ts +16 -23
  67. package/src/components/relative-time-view.ts +11 -4
  68. package/src/form/form-binding.ts +32 -14
  69. package/src/form/use-form.ts +3 -3
  70. package/src/global.scss +9 -0
  71. package/src/icons/build-icons.ts +109 -24
  72. package/src/index.ts +47 -15
  73. package/src/jsx-probe.ts +5 -4
  74. package/src/theme/brand-declarations.ts +113 -0
  75. package/src/theme/brand.ts +104 -96
  76. package/src/theme/context.ts +1 -1
  77. package/src/theme/preset-scifi.ts +99 -0
  78. package/src/theme/preset-shape.ts +18 -0
  79. package/src/theme/presets.ts +15 -0
  80. package/src/theme/shadow-value.ts +39 -0
  81. package/src/theme/theme.ts +36 -11
  82. package/src/toast/toast-state.ts +0 -0
  83. package/src/toast/toast-store.ts +1 -1
  84. package/src/toast/use-toasts.ts +1 -1
  85. package/src/tokens/_colors.scss +20 -0
  86. package/src/tokens/_effects.scss +59 -0
  87. package/src/tokens/_index.scss +2 -0
  88. package/src/tokens/_mixins.scss +6 -4
  89. package/src/tokens/_shadow.scss +16 -0
  90. package/src/tokens/_touch.scss +39 -0
  91. package/src/tokens/_typography.scss +14 -0
  92. package/src/tokens/color-tokens.ts +133 -0
  93. package/src/tokens/colour-vision.ts +106 -0
  94. package/src/tokens/contrast-pairs.ts +6 -1
  95. package/src/tokens/contrast.ts +1 -1
  96. package/src/tokens/theme.scss +2 -0
  97. package/src/tokens/tokens.ts +41 -87
  98. package/src/fake-dom.ts +0 -206
  99. package/src/sass-probe.ts +0 -55
  100. package/src/theme/inline-script.ts +0 -49
@@ -51,8 +51,10 @@
51
51
  border-color: t.role('accent');
52
52
  }
53
53
 
54
+ // Dimmed directly: a pseudo-element is never itself `:disabled`, so `@include t.disabled` here
55
+ // compiled to a selector nothing matches (`disabled-dim.test.ts`).
54
56
  &:disabled::file-selector-button {
55
- @include t.disabled;
57
+ opacity: 0.55;
56
58
  cursor: not-allowed;
57
59
  }
58
60
  }
@@ -10,7 +10,7 @@ import { UI_KEYS } from '../i18n-keys';
10
10
  import { useUi } from '../theme/context';
11
11
  import styles from './FileInput.module.scss';
12
12
  import type { FileSelection } from './file-input-view';
13
- import { progressPercent, selectFiles } from './file-input-view';
13
+ import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
14
14
  import type { Size } from './variants';
15
15
 
16
16
  export interface FileInputProps {
@@ -61,7 +61,11 @@ export function FileInput(props: FileInputProps): JSX.Element {
61
61
  aria-invalid={ariaBool(props['aria-invalid'])}
62
62
  onBlur={props.onBlur}
63
63
  onChange={(event) => {
64
- props.onSelect?.(selectFiles([...(event.currentTarget.files ?? [])], props));
64
+ const selection = selectFiles([...(event.currentTarget.files ?? [])], props);
65
+ // `accept` only steers the picker; "All files" gets past it, and the form would post
66
+ // what this control just refused.
67
+ adoptAcceptedFiles(event.currentTarget, event.currentTarget.files, selection, 'pick');
68
+ props.onSelect?.(selection);
65
69
  }}
66
70
  />
67
71
  {props.progress === undefined ? null : (
@@ -58,8 +58,7 @@ export function Form(props: FormProps): JSX.Element {
58
58
  * the path grammar does not accept, which is the allowlist that keeps a caller's string out of a
59
59
  * selector — no accepted name can carry a quote or close the attribute.
60
60
  */
61
- const invalidControl = (): HTMLElement | null => {
62
- const name = props.invalidField;
61
+ const invalidControl = (name: string | undefined): HTMLElement | null => {
63
62
  if (name === undefined || element === undefined) return null;
64
63
  const selector = fieldSelector(name);
65
64
  return selector === null ? null : element.querySelector<HTMLElement>(selector);
@@ -68,13 +67,27 @@ export function Form(props: FormProps): JSX.Element {
68
67
  // `tabindex="-1"` alone was a focus target nothing ever aimed at: `summaryId` is internal, so no
69
68
  // caller could move focus here, and the component never did either. A failed submit that leaves
70
69
  // focus on the button leaves a keyboard user to hunt for what went wrong.
70
+ //
71
+ // Focus moves when the FAILURE changes, never merely when the effect re-runs. Bound to
72
+ // `useForm`, both props read the form's state signal, which `touch()`/`edit()` republish on every
73
+ // keystroke — so an effect that focused on each run dragged the user back to the first invalid
74
+ // field while they were fixing the second. A new submit clears both props while it is in flight,
75
+ // so a second failure naming the same field is still a change, and still moves focus.
76
+ let aimed: { readonly field: string | undefined; readonly error: string | undefined } = {
77
+ field: undefined,
78
+ error: undefined,
79
+ };
71
80
  rt.createEffect(() => {
72
- const control = invalidControl();
81
+ const field = props.invalidField;
82
+ const error = props.error;
83
+ if (field === aimed.field && error === aimed.error) return;
84
+ aimed = { field, error };
85
+ const control = invalidControl(field);
73
86
  if (control !== null) {
74
87
  control.focus();
75
88
  return;
76
89
  }
77
- if (props.error !== undefined) summary?.focus();
90
+ if (error !== undefined) summary?.focus();
78
91
  });
79
92
 
80
93
  /**
@@ -0,0 +1,48 @@
1
+ @use '../tokens' as t;
2
+
3
+ @include t.tone-classes;
4
+
5
+ // A gauge is a tile, not a row: its caption sits centred under the dial.
6
+ .gauge {
7
+ text-align: center;
8
+
9
+ // ChartFrame writes the caption first, for the reading order; a gauge SHOWS it under the dial.
10
+ > figcaption {
11
+ order: 1;
12
+ }
13
+ }
14
+
15
+ .dial {
16
+ position: relative;
17
+ inline-size: 100%;
18
+ max-inline-size: t.rem(192px);
19
+ margin-inline: auto;
20
+ }
21
+
22
+ .plot {
23
+ display: block;
24
+ inline-size: 100%;
25
+ block-size: auto;
26
+ }
27
+
28
+ .track {
29
+ fill: t.role('line', 0.4);
30
+ }
31
+
32
+ .fill {
33
+ fill: var(--tone);
34
+ }
35
+
36
+ // HTML over the dial's centre, so the figure keeps its type step at every gauge size. The ring's
37
+ // centre is not the box's — the open quarter is cropped — so `--centre` carries where it is.
38
+ .readout {
39
+ @include t.data-text;
40
+ position: absolute;
41
+ inset-block-start: var(--centre);
42
+ inset-inline: 0;
43
+ translate: 0 -50%;
44
+ font-size: t.text(xl);
45
+ font-weight: t.weight(semibold);
46
+ line-height: t.leading(tight);
47
+ color: t.role('fg-strong');
48
+ }
@@ -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
+ }