@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
@@ -6,6 +6,7 @@ import type { JSX } from 'solid-js';
6
6
  import { useId } from '../a11y';
7
7
  import { cx } from '../cx';
8
8
  import { type HeadingLevel, headingTag } from './heading-level';
9
+ import { headingNode } from './heading-node';
9
10
  import styles from './Section.module.scss';
10
11
 
11
12
  export interface SectionProps {
@@ -23,33 +24,51 @@ export interface SectionProps {
23
24
  }
24
25
 
25
26
  export function Section(props: SectionProps): JSX.Element {
26
- const Tag = props.as ?? 'section';
27
- const Heading = headingTag(props.level ?? 2);
27
+ // Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
28
+ const heading = headingTag(props.level ?? 2);
28
29
  const titleId = useId('section');
29
-
30
- return (
31
- <Tag
32
- class={cx(styles['section'], props.class)}
33
- aria-labelledby={props.title === undefined ? undefined : titleId}
34
- >
35
- {props.title === undefined && props.actions === undefined ? null : (
36
- <div class={styles['head']}>
37
- <div class={styles['text']}>
38
- {props.title === undefined ? null : (
39
- <Heading id={titleId} class={styles['title']}>
40
- {props.title}
41
- </Heading>
42
- )}
43
- {props.description === undefined ? null : (
44
- <p class={styles['description']}>{props.description}</p>
45
- )}
46
- </div>
47
- {props.actions === undefined ? null : (
48
- <div class={styles['actions']}>{props.actions}</div>
30
+ const cls = (): string => cx(styles['section'], props.class);
31
+ const labelledBy = (): string | undefined => (props.title === undefined ? undefined : titleId);
32
+ const title = (): string | undefined => props.title;
33
+ const head = (): JSX.Element =>
34
+ props.title === undefined && props.actions === undefined ? null : (
35
+ <div class={styles['head']}>
36
+ <div class={styles['text']}>
37
+ {props.title === undefined
38
+ ? null
39
+ : headingNode(heading, { id: titleId, class: styles['title'] }, title)}
40
+ {props.description === undefined ? null : (
41
+ <p class={styles['description']}>{props.description}</p>
49
42
  )}
50
43
  </div>
51
- )}
52
- <div class={styles['body']}>{props.children}</div>
53
- </Tag>
54
- );
44
+ {props.actions === undefined ? null : <div class={styles['actions']}>{props.actions}</div>}
45
+ </div>
46
+ );
47
+ const body = (): JSX.Element => <div class={styles['body']}>{props.children}</div>;
48
+
49
+ // One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
50
+ // capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
51
+ switch (props.as ?? 'section') {
52
+ case 'article':
53
+ return (
54
+ <article class={cls()} aria-labelledby={labelledBy()}>
55
+ {head()}
56
+ {body()}
57
+ </article>
58
+ );
59
+ case 'aside':
60
+ return (
61
+ <aside class={cls()} aria-labelledby={labelledBy()}>
62
+ {head()}
63
+ {body()}
64
+ </aside>
65
+ );
66
+ case 'section':
67
+ return (
68
+ <section class={cls()} aria-labelledby={labelledBy()}>
69
+ {head()}
70
+ {body()}
71
+ </section>
72
+ );
73
+ }
55
74
  }
@@ -28,22 +28,63 @@ const ALIGN: Readonly<Record<Align, string>> = {
28
28
  };
29
29
 
30
30
  export function Stack(props: StackProps): JSX.Element {
31
- const Tag = props.as ?? 'div';
32
- return (
33
- <Tag
34
- class={cx(
35
- styles['stack'],
36
- styles[`direction-${props.direction ?? 'column'}`],
37
- props.wrap === true && styles['wrap'],
38
- props.class,
39
- )}
40
- style={{
41
- '--stack-gap': `var(--space-${props.gap ?? 4})`,
42
- '--stack-align': ALIGN[props.align ?? 'stretch'],
43
- '--stack-justify': ALIGN[props.justify ?? 'start'],
44
- }}
45
- >
46
- {props.children}
47
- </Tag>
48
- );
31
+ const cls = (): string =>
32
+ cx(
33
+ styles['stack'],
34
+ styles[`direction-${props.direction ?? 'column'}`],
35
+ props.wrap === true && styles['wrap'],
36
+ props.class,
37
+ );
38
+ const style = (): JSX.CSSProperties => ({
39
+ '--stack-gap': `var(--space-${props.gap ?? 4})`,
40
+ '--stack-align': ALIGN[props.align ?? 'stretch'],
41
+ '--stack-justify': ALIGN[props.justify ?? 'start'],
42
+ });
43
+
44
+ // One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
45
+ // capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
46
+ switch (props.as ?? 'div') {
47
+ case 'ul':
48
+ return (
49
+ <ul class={cls()} style={style()}>
50
+ {props.children}
51
+ </ul>
52
+ );
53
+ case 'ol':
54
+ return (
55
+ <ol class={cls()} style={style()}>
56
+ {props.children}
57
+ </ol>
58
+ );
59
+ case 'nav':
60
+ return (
61
+ <nav class={cls()} style={style()}>
62
+ {props.children}
63
+ </nav>
64
+ );
65
+ case 'section':
66
+ return (
67
+ <section class={cls()} style={style()}>
68
+ {props.children}
69
+ </section>
70
+ );
71
+ case 'header':
72
+ return (
73
+ <header class={cls()} style={style()}>
74
+ {props.children}
75
+ </header>
76
+ );
77
+ case 'footer':
78
+ return (
79
+ <footer class={cls()} style={style()}>
80
+ {props.children}
81
+ </footer>
82
+ );
83
+ case 'div':
84
+ return (
85
+ <div class={cls()} style={style()}>
86
+ {props.children}
87
+ </div>
88
+ );
89
+ }
49
90
  }
@@ -75,3 +75,33 @@
75
75
  .striped :global(tbody tr:nth-child(even) td) {
76
76
  background: t.role('bg-soft', 0.6);
77
77
  }
78
+
79
+ // The first column stays at the inline start while the scroller moves the rest. A sticky cell
80
+ // needs an opaque background or the scrolled cells show through it; the edge line is a border,
81
+ // not an inset shadow, because a border is logical and mirrors under RTL by itself.
82
+ .table[data-sticky-first] :global(:is(th, td):first-child) {
83
+ position: sticky;
84
+ inset-inline-start: 0;
85
+ z-index: t.z(raised);
86
+ background-color: t.role('surface');
87
+ border-inline-end: 1px solid t.role('line');
88
+ }
89
+
90
+ .table[data-sticky-first] :global(th:first-child) {
91
+ background-color: t.role('bg-soft');
92
+ }
93
+
94
+ // The corner cell is sticky on both axes, so it must sit above both the header row and the column.
95
+ .sticky[data-sticky-first] :global(thead th:first-child) {
96
+ z-index: t.z(sticky);
97
+ }
98
+
99
+ .sticky[data-sticky-first] :global(thead th:not(:first-child)) {
100
+ z-index: t.z(raised);
101
+ }
102
+
103
+ // A striped row keeps its stripe on the pinned cell: the translucent stripe laid over the opaque
104
+ // surface, so the stripe shows and the scrolled cells still do not.
105
+ .striped[data-sticky-first] :global(tbody tr:nth-child(even) td:first-child) {
106
+ background: linear-gradient(t.role('bg-soft', 0.6), t.role('bg-soft', 0.6)), t.role('surface');
107
+ }
@@ -1,6 +1,6 @@
1
- // Presentational table. Owns two things every hand-rolled table gets wrong:
2
- // a sticky header, and horizontal overflow contained inside the table's own
3
- // scroll container so the page body never scrolls sideways.
1
+ // Presentational table. Owns what every hand-rolled table gets wrong: a sticky header, a first
2
+ // column that stays put while the rest scrolls, and horizontal overflow contained inside the
3
+ // table's own scroll container so the page body never scrolls sideways.
4
4
 
5
5
  import type { JSX } from 'solid-js';
6
6
  import { cx } from '../cx';
@@ -13,6 +13,14 @@ export interface TableProps {
13
13
  /** Hide the caption visually while keeping it for assistive tech. */
14
14
  hideCaption?: boolean | undefined;
15
15
  stickyHeader?: boolean | undefined;
16
+ /** Pins the first column at the inline start while the table scrolls sideways. Off by default. */
17
+ stickyFirstColumn?: boolean | undefined;
18
+ /**
19
+ * Declares `role="table"` on the element itself. For a caller that restyles the table's
20
+ * `display` (DataTable's card view): WebKit drops a table's semantics once `display` changes,
21
+ * and the explicit role is what keeps it a table. The rows and cells are the caller's to mark.
22
+ */
23
+ explicitRoles?: boolean | undefined;
16
24
  density?: 'comfortable' | 'compact' | undefined;
17
25
  /** Zebra striping. Off by default — a border is usually enough. */
18
26
  striped?: boolean | undefined;
@@ -27,6 +35,9 @@ export function Table(props: TableProps): JSX.Element {
27
35
  // inside it — and a <section> with no name is generic, so the table keeps its own caption.
28
36
  <section class={cx(styles['scroller'], props.class)} tabindex="0">
29
37
  <table
38
+ role={props.explicitRoles === true ? 'table' : undefined}
39
+ // An attribute, not a class: it is state the stylesheet and a test both read.
40
+ data-sticky-first={props.stickyFirstColumn === true ? '' : undefined}
30
41
  class={cx(
31
42
  styles['table'],
32
43
  styles[`density-${props.density ?? 'comfortable'}`],
@@ -2,7 +2,7 @@
2
2
  // between them, Home/End jump to the ends, and arrow direction follows `dir`.
3
3
 
4
4
  import type { JSX } from 'solid-js';
5
- import { ariaBool, createRovingTabindex, useId } from '../a11y';
5
+ import { ariaBool, rovingTabindex, useId } from '../a11y';
6
6
  import { cx } from '../cx';
7
7
  import { TAB_SELECTOR, tabStopIndex } from '../roving';
8
8
  import { useUi } from '../theme/context';
@@ -35,7 +35,7 @@ export function Tabs(props: TabsProps): JSX.Element {
35
35
  // A disabled tab is excluded from BOTH answers — the list arrows walk and the one tab that
36
36
  // carries the tab stop — because `focus()` on a disabled button silently does nothing, so a
37
37
  // disabled tab left in the list pins the reducer on its index and hides every tab after it.
38
- const onKeyDown = createRovingTabindex(
38
+ const onKeyDown = rovingTabindex(
39
39
  () => (list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(TAB_SELECTOR))),
40
40
  { orientation: props.orientation ?? 'horizontal', dir: ui.dir },
41
41
  );
@@ -37,21 +37,47 @@ export interface TextProps {
37
37
  }
38
38
 
39
39
  export function Text(props: TextProps): JSX.Element {
40
- const Tag = props.as ?? 'span';
41
-
40
+ const cls = (): string =>
41
+ cx(styles['text'], styles[`tone-${props.tone ?? 'default'}`], props.class);
42
42
  // Size and weight are set only when asked for: unset means inherit, so <Text>
43
43
  // inside a heading or a caption does not silently reset to the body scale.
44
- return (
45
- <Tag
46
- class={cx(styles['text'], styles[`tone-${props.tone ?? 'default'}`], props.class)}
47
- style={{
48
- ...(props.size === undefined ? {} : { '--text-scale': `var(--text-${props.size})` }),
49
- ...(props.weight === undefined
50
- ? {}
51
- : { '--text-strength': `var(--weight-${props.weight})` }),
52
- }}
53
- >
54
- {props.children}
55
- </Tag>
56
- );
44
+ const style = (): JSX.CSSProperties => ({
45
+ ...(props.size === undefined ? {} : { '--text-scale': `var(--text-${props.size})` }),
46
+ ...(props.weight === undefined ? {} : { '--text-strength': `var(--weight-${props.weight})` }),
47
+ });
48
+
49
+ // One intrinsic per case, never `const Tag = props.as; <Tag>`: the island build compiles a
50
+ // capitalised tag to `createComponent(Tag)` and calls the string (#488, `intrinsic-root.test.ts`).
51
+ switch (props.as ?? 'span') {
52
+ case 'p':
53
+ return (
54
+ <p class={cls()} style={style()}>
55
+ {props.children}
56
+ </p>
57
+ );
58
+ case 'div':
59
+ return (
60
+ <div class={cls()} style={style()}>
61
+ {props.children}
62
+ </div>
63
+ );
64
+ case 'strong':
65
+ return (
66
+ <strong class={cls()} style={style()}>
67
+ {props.children}
68
+ </strong>
69
+ );
70
+ case 'em':
71
+ return (
72
+ <em class={cls()} style={style()}>
73
+ {props.children}
74
+ </em>
75
+ );
76
+ case 'span':
77
+ return (
78
+ <span class={cls()} style={style()}>
79
+ {props.children}
80
+ </span>
81
+ );
82
+ }
57
83
  }
@@ -15,7 +15,7 @@ import { Button } from './Button';
15
15
  import { Toast, type ToastPlacement, ToastRegion } from './Toast';
16
16
 
17
17
  export interface ToasterProps {
18
- /** The queue. One per app — `createToastStore()` in the island that mounts this. */
18
+ /** The queue. One per app — `toastStore()` in the island that mounts this. */
19
19
  store: ToastStore;
20
20
  /** Already-translated landmark name, e.g. "Notifications". */
21
21
  label: string;
@@ -5,7 +5,7 @@
5
5
  // own arrow keys, so making the strip a single stop would strand every control past it.
6
6
 
7
7
  import type { JSX } from 'solid-js';
8
- import { createRovingTabindex, focusableWithin } from '../a11y';
8
+ import { focusableWithin, rovingTabindex } from '../a11y';
9
9
  import { cx } from '../cx';
10
10
  import { useUi } from '../theme/context';
11
11
  import styles from './Toolbar.module.scss';
@@ -27,12 +27,13 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
27
27
  let strip: HTMLDivElement | undefined;
28
28
 
29
29
  // Arrows follow the writing direction; `focusableWithin` skips anything hidden or disabled, and
30
- // `createRovingTabindex` declines outright while a control that answers arrows itself — the
30
+ // `rovingTabindex` declines outright while a control that answers arrows itself — the
31
31
  // search Input this strip exists to hold — has focus.
32
- const onKeyDown = createRovingTabindex(
33
- () => (strip === undefined ? [] : focusableWithin(strip)),
34
- { orientation: 'horizontal', dir: ui.dir, loop: false },
35
- );
32
+ const onKeyDown = rovingTabindex(() => (strip === undefined ? [] : focusableWithin(strip)), {
33
+ orientation: 'horizontal',
34
+ dir: ui.dir,
35
+ loop: false,
36
+ });
36
37
 
37
38
  return (
38
39
  <div
@@ -1,6 +1,6 @@
1
- // AppShell's two rules, kept away from the markup: the id the skip link points at is derived
2
- // from the same place the <main> gets it, and which landmarks the frame emits in which order.
3
- // Both are silent failures when wrong — a skip link to nowhere, or two <main> elements.
1
+ // AppShell's rules, kept away from the markup: the ids the skip link and the menu button point at
2
+ // are derived from the same place the <main> and the <nav> get them, and which landmarks the frame
3
+ // emits in which order. All are silent failures when wrong — a skip link or a menu to nowhere.
4
4
 
5
5
  export type ShellLandmark = 'banner' | 'navigation' | 'main' | 'contentinfo';
6
6
 
@@ -8,6 +8,8 @@ export interface ShellIds {
8
8
  readonly mainId: string;
9
9
  /** Always `#` + `mainId`. One derivation, so the link and the target cannot drift. */
10
10
  readonly skipHref: string;
11
+ /** The sidebar `<nav>`, which the menu button's `popovertarget` names below `md`. */
12
+ readonly navId: string;
11
13
  }
12
14
 
13
15
  export interface ShellSlots {
@@ -18,7 +20,7 @@ export interface ShellSlots {
18
20
 
19
21
  export function shellIds(base: string): ShellIds {
20
22
  const mainId = `${base}-main`;
21
- return { mainId, skipHref: `#${mainId}` };
23
+ return { mainId, skipHref: `#${mainId}`, navId: `${base}-nav` };
22
24
  }
23
25
 
24
26
  /**
@@ -20,11 +20,12 @@ export interface BarRect {
20
20
  }
21
21
 
22
22
  /**
23
- * 600 × 128 at a 5:1-ish ratio, not 3:1: at dashboard width a taller box pushed the table — the
24
- * thing people came for — below the fold. `plot` is where bars live; the strip under it belongs
25
- * to the axis labels, which drawn ON the first and last bars were unreadable there.
23
+ * 600 × 108, nearly 6:1, not 3:1: at dashboard width a taller box pushed the table — the thing
24
+ * people came for — below the fold. `plot` is where bars live; `top` and the matching strip under
25
+ * it only keep the outer grid hairlines off the edge. The axis labels are HTML OUTSIDE the svg
26
+ * (issue 494) — inside a viewBox they scaled with it — so the box spends no strip on them.
26
27
  */
27
- export const BAR_CHART = { width: 600, top: 12, plot: 104, height: 128, gap: 3 } as const;
28
+ export const BAR_CHART = { width: 600, top: 2, plot: 104, height: 108, gap: 3 } as const;
28
29
 
29
30
  /** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
30
31
  export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
@@ -0,0 +1,219 @@
1
+ // What every chart shares apart from its geometry: which colour slot, dash and marker a series
2
+ // gets (so no series is told apart by colour alone), the data table a screen reader and a
3
+ // no-colour reader get instead of the picture, and how a number becomes text — always in a locale
4
+ // the caller passed, never the runtime's default.
5
+
6
+ /** The chart colour roles, `chart-1` … `chart-8` (`--color-chart-N`). */
7
+ export const CHART_SERIES_SLOTS = 8;
8
+
9
+ export type MarkerShape =
10
+ | 'circle'
11
+ | 'square'
12
+ | 'triangle'
13
+ | 'diamond'
14
+ | 'plus'
15
+ | 'triangle-down'
16
+ | 'cross'
17
+ | 'star';
18
+
19
+ const MARKERS: readonly MarkerShape[] = [
20
+ 'circle',
21
+ 'square',
22
+ 'triangle',
23
+ 'diamond',
24
+ 'plus',
25
+ 'triangle-down',
26
+ 'cross',
27
+ 'star',
28
+ ];
29
+
30
+ /**
31
+ * Five dash patterns against eight slots and markers: 5 and 8 share no factor, so the whole look
32
+ * of a series (slot, dash, marker) first repeats at the 41st — a ninth series reuses a colour,
33
+ * never a line. Units are screen px: every chart stroke is `non-scaling-stroke`.
34
+ */
35
+ const DASHES: readonly (string | undefined)[] = [undefined, '6 4', '2 3', '10 3 2 3', '1 4'];
36
+
37
+ export interface SeriesStyle {
38
+ /** 1-based: the `N` of `--color-chart-N`, and the `data-series` a sheet keys the colour on. */
39
+ readonly slot: number;
40
+ /** `stroke-dasharray`; `undefined` is a solid line. */
41
+ readonly dash: string | undefined;
42
+ readonly marker: MarkerShape;
43
+ }
44
+
45
+ const indexOf = (index: number): number =>
46
+ Number.isFinite(index) && index > 0 ? Math.floor(index) : 0;
47
+
48
+ /** The look of the `index`th series (0-based). */
49
+ export function seriesStyle(index: number): SeriesStyle {
50
+ const i = indexOf(index);
51
+ return {
52
+ slot: (i % CHART_SERIES_SLOTS) + 1,
53
+ dash: DASHES[i % DASHES.length],
54
+ marker: MARKERS[i % MARKERS.length] ?? 'circle',
55
+ };
56
+ }
57
+
58
+ /** A coordinate as SVG text: two decimals at most, no trailing zeros, never `-0`. */
59
+ export function svgNumber(value: number): string {
60
+ const text = value.toFixed(2).replace(/\.?0+$/, '');
61
+ return text === '-0' ? '0' : text;
62
+ }
63
+
64
+ /** A closed polygon through `points` (unit offsets scaled by `r` around `x, y`). */
65
+ function polygon(x: number, y: number, r: number, points: readonly (readonly [number, number])[]) {
66
+ const [head, ...rest] = points.map(
67
+ ([dx, dy]) => `${svgNumber(x + dx * r)},${svgNumber(y + dy * r)}`,
68
+ );
69
+ return `M${head} ${rest.map((p) => `L${p}`).join(' ')} Z`;
70
+ }
71
+
72
+ const ring = (count: number, radius: number, phase = 0): [number, number][] =>
73
+ Array.from({ length: count }, (_, i) => {
74
+ const angle = phase + (i * 2 * Math.PI) / count;
75
+ return [Math.sin(angle) * radius, -Math.cos(angle) * radius];
76
+ });
77
+
78
+ /** Polygons only, so a marker is one path a sheet fills — a 12-gon IS a circle at 4px. */
79
+ const SHAPES: Record<MarkerShape, readonly (readonly [number, number])[]> = {
80
+ circle: ring(12, 1),
81
+ square: [
82
+ [-0.85, -0.85],
83
+ [0.85, -0.85],
84
+ [0.85, 0.85],
85
+ [-0.85, 0.85],
86
+ ],
87
+ triangle: [
88
+ [0, -1],
89
+ [1, 0.85],
90
+ [-1, 0.85],
91
+ ],
92
+ diamond: [
93
+ [0, -1],
94
+ [1, 0],
95
+ [0, 1],
96
+ [-1, 0],
97
+ ],
98
+ plus: [
99
+ [-0.33, -1],
100
+ [0.33, -1],
101
+ [0.33, -0.33],
102
+ [1, -0.33],
103
+ [1, 0.33],
104
+ [0.33, 0.33],
105
+ [0.33, 1],
106
+ [-0.33, 1],
107
+ [-0.33, 0.33],
108
+ [-1, 0.33],
109
+ [-1, -0.33],
110
+ [-0.33, -0.33],
111
+ ],
112
+ 'triangle-down': [
113
+ [-1, -0.85],
114
+ [1, -0.85],
115
+ [0, 1],
116
+ ],
117
+ cross: [
118
+ [-1, -0.6],
119
+ [-0.6, -1],
120
+ [0, -0.4],
121
+ [0.6, -1],
122
+ [1, -0.6],
123
+ [0.4, 0],
124
+ [1, 0.6],
125
+ [0.6, 1],
126
+ [0, 0.4],
127
+ [-0.6, 1],
128
+ [-1, 0.6],
129
+ [-0.4, 0],
130
+ ],
131
+ star: ring(10, 1).map(([dx, dy], i) => (i % 2 === 0 ? [dx, dy] : [dx * 0.45, dy * 0.45])),
132
+ };
133
+
134
+ /** The `d` of a marker of `shape` centred on `x, y`, inside a box of half-width `r`. */
135
+ export function markerPath(shape: MarkerShape, x: number, y: number, r: number): string {
136
+ return polygon(x, y, r, SHAPES[shape]);
137
+ }
138
+
139
+ /** A value a chart can place, or `null` for a gap: missing, NaN and ±∞ are never drawn as 0. */
140
+ export function finiteOrNull(value: number | null | undefined): number | null {
141
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
142
+ }
143
+
144
+ export interface ChartSeries {
145
+ /** The series' name, already translated: the legend entry and the table's column head. */
146
+ readonly label: string;
147
+ /** One value per key, in key order; `null` is a gap in the line and an empty table cell. */
148
+ readonly values: readonly (number | null)[];
149
+ }
150
+
151
+ export interface ChartTableRow {
152
+ readonly key: string;
153
+ readonly cells: readonly string[];
154
+ }
155
+
156
+ export interface ChartTable {
157
+ readonly caption: string;
158
+ /** The corner cell (the key column's head, `''` when unnamed), then one head per series. */
159
+ readonly head: readonly string[];
160
+ readonly rows: readonly ChartTableRow[];
161
+ }
162
+
163
+ export interface ChartTableInput {
164
+ readonly caption: string;
165
+ readonly keyLabel: string | undefined;
166
+ readonly keys: readonly string[];
167
+ readonly series: readonly ChartSeries[];
168
+ readonly format: (value: number) => string;
169
+ }
170
+
171
+ /** The same numbers the picture draws, as rows a screen reader walks cell by cell. */
172
+ export function chartTable(input: ChartTableInput): ChartTable {
173
+ return {
174
+ caption: input.caption,
175
+ head: [input.keyLabel ?? '', ...input.series.map((series) => series.label)],
176
+ rows: input.keys.map((key, index) => ({
177
+ key,
178
+ cells: input.series.map((series) => {
179
+ const value = finiteOrNull(series.values[index]);
180
+ return value === null ? '' : input.format(value);
181
+ }),
182
+ })),
183
+ };
184
+ }
185
+
186
+ /** `Intl.NumberFormat` in the locale passed — the page's, read by the component from `useUi()`. */
187
+ export function chartNumberFormat(
188
+ locale: string,
189
+ maxFractionDigits = 2,
190
+ ): (value: number) => string {
191
+ const format = new Intl.NumberFormat(locale, { maximumFractionDigits: maxFractionDigits });
192
+ return (value) => format.format(value);
193
+ }
194
+
195
+ /** A point's accessible name: key, series (when there is more than one thing to name), value. */
196
+ export function pointLabel(key: string, series: string | undefined, value: string): string {
197
+ return series === undefined ? `${key}: ${value}` : `${key}, ${series}: ${value}`;
198
+ }
199
+
200
+ /** `part` of `whole` as a CSS percentage for a `--x`/`--y` custom property, clamped to the box. */
201
+ export function cssPercent(part: number, whole: number): string {
202
+ if (!(whole > 0) || !Number.isFinite(part)) return '0%';
203
+ const share = Math.min(1, Math.max(0, part / whole));
204
+ return `${svgNumber(share * 100)}%`;
205
+ }
206
+
207
+ /** A share (0..1) as a percentage in the locale passed: a donut's legend, a gauge's readout. */
208
+ export function chartPercentFormat(locale: string): (share: number) => string {
209
+ const format = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 });
210
+ return (share) => format.format(share);
211
+ }
212
+
213
+ /** Which way a point's readout opens: inward from the outer fifth of the plot, centred elsewhere. */
214
+ export function hitEdge(x: number, width: number): 'start' | 'middle' | 'end' {
215
+ const share = width > 0 ? x / width : 0.5;
216
+ if (share < 0.2) return 'start';
217
+ if (share > 0.8) return 'end';
218
+ return 'middle';
219
+ }