@ultimat3/ui 24.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 (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
@@ -5,8 +5,8 @@ import type { ComponentDoc } from './parse-component';
5
5
  import { parseComponents } from './parse-component';
6
6
  import { renderCatalog } from './render-catalog';
7
7
 
8
- const COMPONENTS_DIR = new URL('../components/', import.meta.url).pathname;
9
- export const CATALOG_PATH = new URL('../../CATALOG.md', import.meta.url).pathname;
8
+ const COMPONENTS_DIR = Bun.fileURLToPath(new URL('../components/', import.meta.url));
9
+ export const CATALOG_PATH = Bun.fileURLToPath(new URL('../../CATALOG.md', import.meta.url));
10
10
 
11
11
  /** Alphabetical by file, so the page's order is the order an agent scans for a name. */
12
12
  export async function collectComponents(): Promise<ComponentDoc[]> {
@@ -11,6 +11,7 @@ import type { AccordionSection } from './accordion-view';
11
11
  import { accordionOpenIds } from './accordion-view';
12
12
  import type { HeadingLevel } from './heading-level';
13
13
  import { headingTag } from './heading-level';
14
+ import { headingNode } from './heading-node';
14
15
  import { Icon } from './Icon';
15
16
 
16
17
  export interface AccordionItem extends AccordionSection {
@@ -39,7 +40,8 @@ export interface AccordionProps {
39
40
  export function Accordion(props: AccordionProps): JSX.Element {
40
41
  const base = useId('accordion');
41
42
  const open = accordionOpenIds(props.items, props.exclusive === true);
42
- const Title = props.level === undefined ? 'span' : headingTag(props.level);
43
+ // Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
44
+ const heading = props.level === undefined ? undefined : headingTag(props.level);
43
45
 
44
46
  return (
45
47
  <div class={cx(styles['accordion'], props.class)}>
@@ -53,7 +55,11 @@ export function Accordion(props: AccordionProps): JSX.Element {
53
55
  >
54
56
  <summary class={styles['summary']}>
55
57
  <Icon glyph={iconChevronDown} class={styles['marker']} size="sm" />
56
- <Title class={styles['title']}>{item.title}</Title>
58
+ {heading === undefined ? (
59
+ <span class={styles['title']}>{item.title}</span>
60
+ ) : (
61
+ headingNode(heading, { class: styles['title'] }, () => item.title)
62
+ )}
57
63
  </summary>
58
64
  <div class={styles['panel']}>{item.panel}</div>
59
65
  </details>
@@ -4,6 +4,9 @@
4
4
  // writing mode is handled by the grid itself, so RTL needs no second rule.
5
5
  .shell {
6
6
  display: grid;
7
+ // `minmax(0, …)`, never the implicit `auto`: an auto track is at least its content's min-content
8
+ // width, so a header that did not shrink laid a 390px phone out 429px wide.
9
+ grid-template-columns: minmax(0, 1fr);
7
10
  grid-template-areas: 'header' 'sidebar' 'main' 'footer';
8
11
  grid-template-rows: auto auto 1fr auto;
9
12
  min-block-size: 100dvh;
@@ -51,13 +54,66 @@
51
54
  }
52
55
  }
53
56
 
57
+ // The bar the banner sits in: the menu button (below `md`) and the app's header, side by side.
58
+ .top {
59
+ display: flex;
60
+ min-inline-size: 0;
61
+ align-items: center;
62
+ gap: t.space(2);
63
+ grid-area: header;
64
+ padding-inline-start: t.space(2);
65
+ border-block-end: 1px solid t.role('line');
66
+ background: t.role('surface');
67
+
68
+ @include t.respond-to(md) {
69
+ padding-inline-start: 0;
70
+ }
71
+ }
72
+
73
+ .menuOnly {
74
+ display: none;
75
+ }
76
+
54
77
  .header {
55
78
  @include t.row(t.space(4), center, space-between);
56
- grid-area: header;
79
+ flex: 1;
80
+ min-inline-size: 0;
57
81
  padding-block: t.space(3);
58
82
  padding-inline: t.space(4);
59
- border-block-end: 1px solid t.role('line');
60
- background: t.role('surface');
83
+ }
84
+
85
+ // Hidden unless the engine can open the panel it names: a button that does nothing is worse than
86
+ // the band an engine without popovers still shows. A touch target: this bar exists for a phone.
87
+ .menuButton,
88
+ .close {
89
+ @include t.focus-ring;
90
+ @include t.touch-target;
91
+ display: none;
92
+ align-items: center;
93
+ justify-content: center;
94
+ gap: t.space(2);
95
+ padding-inline: t.space(3);
96
+ border: 0;
97
+ border-radius: t.radius(md);
98
+ background: none;
99
+ color: t.role('fg-strong');
100
+ font: inherit;
101
+ cursor: pointer;
102
+
103
+ &:hover {
104
+ background: t.role('bg-soft');
105
+ }
106
+ }
107
+
108
+ .close {
109
+ margin-inline-start: auto;
110
+ }
111
+
112
+ // On a phone the menu button is its icon; the label stays in the accessibility tree as its name.
113
+ .menuLabel {
114
+ @include t.respond-down(sm) {
115
+ @include t.visually-hidden;
116
+ }
61
117
  }
62
118
 
63
119
  .sticky {
@@ -66,13 +122,23 @@
66
122
  inset-block-start: 0;
67
123
  }
68
124
 
69
- // Below `md` the sidebar is a band above the content; from `md` it is the inline-start column.
125
+ // From `md` the sidebar is the inline-start column; below it, the popover panel further down.
126
+ // Every `[popover]` gets a UA style — fixed, centred, bordered, hidden while closed — so the
127
+ // in-flow box undoes each, and is what an engine without popovers shows at every width.
70
128
  .sidebar {
71
129
  @include t.custom-scrollbar;
130
+ display: block;
131
+ position: static;
132
+ inset: auto;
133
+ inline-size: auto;
134
+ block-size: auto;
135
+ margin: 0;
72
136
  grid-area: sidebar;
73
137
  padding: t.space(4);
138
+ border: 0;
74
139
  border-block-end: 1px solid t.role('line');
75
140
  background: t.role('bg-soft');
141
+ color: t.role('fg');
76
142
  overflow: auto;
77
143
 
78
144
  // Deliberately not sticky: a sticky sidebar and a sticky header both anchored at 0 overlap,
@@ -108,3 +174,38 @@
108
174
  color: t.role('fg-muted');
109
175
  font-size: t.text(sm);
110
176
  }
177
+
178
+ // --- Below `md`, where the engine has popovers: the sidebar is an edge panel ----------------------
179
+ // The same look as `Drawer`'s inline-start side — raised surface, line edge, scrim — so the two
180
+ // off-canvas surfaces in an app read as one.
181
+ @include t.respond-down(md) {
182
+ @supports selector(:popover-open) {
183
+ .sidebar:not(:popover-open) {
184
+ display: none;
185
+ }
186
+
187
+ .sidebar:popover-open {
188
+ @include t.scrim-backdrop;
189
+ position: fixed;
190
+ inset-block: 0;
191
+ inset-inline-start: 0;
192
+ inset-inline-end: auto;
193
+ inline-size: min(var(--shell-sidebar, 16rem), calc(100vw - #{t.space(12)}));
194
+ block-size: 100dvh;
195
+ max-block-size: 100dvh;
196
+ border-block-end: 0;
197
+ border-inline-end: 1px solid t.role('line');
198
+ background: t.role('surface-raised');
199
+ box-shadow: t.shadow(xl);
200
+ }
201
+
202
+ .menuButton,
203
+ .close {
204
+ display: inline-flex;
205
+ }
206
+
207
+ .menuOnly {
208
+ display: flex;
209
+ }
210
+ }
211
+ }
@@ -1,28 +1,35 @@
1
1
  // The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo.
2
- // Stateless on purpose — below `md` the sidebar becomes a band above the content instead of
3
- // growing an open/closed flag, because an off-canvas menu is already `Drawer` and axiom 1 allows
4
- // exactly one of those.
2
+ // Stateless, and scriptless: below `md` the sidebar `<nav>` is a native `popover` panel at the
3
+ // inline-start edge, opened by a `popovertarget` menu button — the browser holds the open state,
4
+ // Esc and light dismiss, so the frame on every page costs no island. Not `Drawer`: that is a modal
5
+ // `<dialog>` opened from an effect, so it needs a hydrated island, and a `<dialog>` cannot also be
6
+ // the in-flow `navigation` landmark at `md` and up. An engine with no popovers keeps the old band.
5
7
 
6
8
  import type { JSX } from 'solid-js';
7
9
  import { LIVE_REGION_LEVELS, liveRegionAttrs, useId } from '../a11y';
8
10
  import { cx } from '../cx';
9
11
  import { UI_KEYS } from '../i18n-keys';
12
+ import { iconMenu } from '../icons/glyphs/menu';
13
+ import { iconX } from '../icons/glyphs/x';
10
14
  import { useUi } from '../theme/context';
11
15
  import styles from './AppShell.module.scss';
12
16
  import { shellIds } from './app-shell-view';
17
+ import { Icon } from './Icon';
13
18
 
14
19
  export interface AppShellProps {
15
20
  /** The page. Rendered inside the one `<main>`, which is the skip link's target. */
16
21
  children: JSX.Element;
17
22
  header?: JSX.Element | undefined;
18
- /** Rendered inside a `<nav>` landmark at the inline start. */
23
+ /** Rendered inside a `<nav>` landmark: the inline-start column at `md` and up, a panel below. */
19
24
  sidebar?: JSX.Element | undefined;
20
25
  footer?: JSX.Element | undefined;
21
26
  /** Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. */
22
27
  sidebarLabel?: string | undefined;
23
28
  /** Skip-link text. Defaults to the translated `ui.skip`. */
24
29
  skipLabel?: string | undefined;
25
- /** Sidebar track width at `md` and up. Any CSS length. */
30
+ /** The menu button's text below `md`. Defaults to the translated `ui.menu`. */
31
+ menuLabel?: string | undefined;
32
+ /** Sidebar track width at `md` and up, and the panel's width below. Any CSS length. */
26
33
  sidebarWidth?: string | undefined;
27
34
  /** Keeps the header pinned while the main region scrolls. */
28
35
  stickyHeader?: boolean | undefined;
@@ -41,13 +48,44 @@ export function AppShell(props: AppShellProps): JSX.Element {
41
48
  <a class={styles['skip']} href={ids.skipHref}>
42
49
  {props.skipLabel ?? ui.t(UI_KEYS.skip)}
43
50
  </a>
44
- {props.header === undefined ? null : (
45
- <header class={cx(styles['header'], props.stickyHeader !== false && styles['sticky'])}>
46
- {props.header}
47
- </header>
51
+ {props.header === undefined && props.sidebar === undefined ? null : (
52
+ // The menu button sits beside the banner, not inside it: the header holds exactly what
53
+ // the app handed it, and the bar is what sticks.
54
+ <div
55
+ class={cx(
56
+ styles['top'],
57
+ // A bar holding only the menu button is shown only where that button is.
58
+ props.header === undefined && styles['menuOnly'],
59
+ props.stickyHeader !== false && styles['sticky'],
60
+ )}
61
+ >
62
+ {props.sidebar === undefined ? null : (
63
+ <button type="button" class={styles['menuButton']} popovertarget={ids.navId}>
64
+ <Icon glyph={iconMenu} size="md" />
65
+ <span class={styles['menuLabel']}>{props.menuLabel ?? ui.t(UI_KEYS.menu)}</span>
66
+ </button>
67
+ )}
68
+ {props.header === undefined ? null : (
69
+ <header class={styles['header']}>{props.header}</header>
70
+ )}
71
+ </div>
48
72
  )}
49
73
  {props.sidebar === undefined ? null : (
50
- <nav class={styles['sidebar']} aria-label={props.sidebarLabel ?? ui.t(UI_KEYS.navigation)}>
74
+ <nav
75
+ id={ids.navId}
76
+ class={styles['sidebar']}
77
+ popover="auto"
78
+ aria-label={props.sidebarLabel ?? ui.t(UI_KEYS.navigation)}
79
+ >
80
+ <button
81
+ type="button"
82
+ class={styles['close']}
83
+ popovertarget={ids.navId}
84
+ popovertargetaction="hide"
85
+ aria-label={ui.t(UI_KEYS.close)}
86
+ >
87
+ <Icon glyph={iconX} size="md" />
88
+ </button>
51
89
  {props.sidebar}
52
90
  </nav>
53
91
  )}
@@ -1,6 +1,18 @@
1
1
  @use '../tokens' as t;
2
2
 
3
3
  .chart {
4
+ display: grid;
5
+ gap: t.space(1);
6
+ inline-size: 100%;
7
+ // The svg does not mirror in a right-to-left page; the keyboard layer over it must not either.
8
+ direction: ltr;
9
+ }
10
+
11
+ .box {
12
+ position: relative;
13
+ }
14
+
15
+ .plot {
4
16
  display: block;
5
17
  inline-size: 100%;
6
18
  block-size: auto;
@@ -26,10 +38,15 @@
26
38
  }
27
39
  }
28
40
 
29
- // The second series — what went wrong, stacked on what went right.
41
+ // The second series — what went wrong, stacked on what went right. Its dashed outline is the cue
42
+ // that does not depend on telling red from blue; the legend's swatch carries the same edge.
30
43
  .secondary {
31
44
  fill: t.role('danger');
32
45
  opacity: 0.6;
46
+ stroke: t.role('fg-strong');
47
+ stroke-width: 1;
48
+ stroke-dasharray: 3 2;
49
+ vector-effect: non-scaling-stroke;
33
50
  transition: opacity t.duration(fast) t.easing(out);
34
51
 
35
52
  &:hover {
@@ -44,13 +61,34 @@
44
61
  .axis {
45
62
  @include t.data-text;
46
63
 
47
- // SVG text scales with the chart: below `sm` these shrink to unreadable specks, and an
48
- // unreadable label is noise. Every bar still carries its `<title>`.
49
- display: none;
50
- font-size: t.rem(8px);
51
- fill: t.role('fg-muted');
64
+ // HTML text, outside the viewBox: it keeps this step at every chart width (issue 494).
65
+ font-size: t.text(xs);
66
+ line-height: t.leading(tight);
67
+ color: t.role('fg-muted');
68
+ }
69
+
70
+ // The busiest value, over the top grid line's end.
71
+ .max {
72
+ justify-self: end;
73
+ }
52
74
 
53
- @include t.respond-to(sm) {
54
- display: block;
55
- }
75
+ // The first and last keys, under the first and last bars.
76
+ .keys {
77
+ display: flex;
78
+ justify-content: space-between;
79
+ gap: t.space(2);
80
+ }
81
+
82
+ // Legend keys: the bar's own fill, and the second series' fill with its dashed edge.
83
+ .swatch {
84
+ display: block;
85
+ inline-size: t.space(3);
86
+ block-size: t.space(3);
87
+ border-radius: t.radius(sm);
88
+ background: t.role('accent');
89
+ }
90
+
91
+ .swatchSecondary {
92
+ border: t.stroke(hairline) dashed t.role('fg-strong');
93
+ background: t.role('danger', 0.6);
56
94
  }
@@ -1,14 +1,20 @@
1
1
  // A bar chart of one series — clicks per day, signups per week — or two stacked (done, and failed
2
- // on top), as static SVG. No charting
3
- // library: the framework's own docs use a sparkline pulling one in as the cautionary example, and
4
- // a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the
5
- // server-rendered shell IS the chart. Geometry lives in `bar-chart-view.ts`.
2
+ // on top): SVG bars, HTML axis labels. No charting library: the framework's own docs use a
3
+ // sparkline pulling one in as the cautionary example, and a route's JS budget counts raw minified
4
+ // bytes. `<rect>` bars cost nothing to hydrate, so the server-rendered shell IS the chart. The
5
+ // labels sit outside the svg because text inside a viewBox scales with it — a speck at phone width.
6
+ // It stands in a `ChartFrame`: a legend for two series, and a hidden table of the same numbers.
6
7
 
7
8
  import type { JSX } from 'solid-js';
8
9
  import { cx } from '../cx';
10
+ import { useUi } from '../theme/context';
9
11
  import styles from './BarChart.module.scss';
10
12
  import type { ChartPoint } from './bar-chart-view';
11
13
  import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf, secondaryRects } from './bar-chart-view';
14
+ import type { ChartHit, ChartLegendItem } from './ChartFrame';
15
+ import { ChartFrame, chartHits } from './ChartFrame';
16
+ import type { ChartSeries } from './chart-frame-view';
17
+ import { chartNumberFormat, chartTable, cssPercent, hitEdge, pointLabel } from './chart-frame-view';
12
18
 
13
19
  export interface BarChartProps {
14
20
  /** The accessible name for the whole chart, already translated. */
@@ -18,87 +24,161 @@ export interface BarChartProps {
18
24
  /** Draw the last bar at full strength — the eye lands where the live number is. Default on. */
19
25
  highlightLast?: boolean | undefined;
20
26
  /**
21
- * Names of the two series, already translated, read by each rect's `<title>`. Give them when a
22
- * point carries `secondary`: a stacked bar with no name for its top is two numbers and no key.
27
+ * Names of the two series, already translated, read by each rect's `<title>`, the legend and
28
+ * the data table. Give them when a point carries `secondary`: a stacked bar with no name for its
29
+ * top is two numbers and no key.
23
30
  */
24
31
  seriesLabels?: { readonly primary: string; readonly secondary: string } | undefined;
32
+ /** The key column's head in the data table, already translated — "Day". */
33
+ keyLabel?: string | undefined;
34
+ /** Formats every value. Default: `Intl.NumberFormat` in the page's locale. */
35
+ format?: ((value: number) => string) | undefined;
36
+ /** Give every bar a tab stop and a hover/focus readout. Default off: no tab stops. */
37
+ focusable?: boolean | undefined;
38
+ /** Show the caption above the plot. Default off — it names the figure either way. */
39
+ showCaption?: boolean | undefined;
40
+ /** Lands on the root `<figure>`, which holds the svg and its HTML axis labels. */
25
41
  class?: string | undefined;
26
42
  }
27
43
 
28
44
  export function BarChart(props: BarChartProps): JSX.Element {
45
+ const ui = useUi();
46
+ const format = (value: number): string => (props.format ?? chartNumberFormat(ui.locale))(value);
29
47
  const rects = (): readonly ReturnType<typeof barRects>[number][] => barRects(props.points);
30
48
  const tops = (): ReturnType<typeof secondaryRects> => secondaryRects(props.points);
31
49
  const named = (label: string | undefined, value: number): string =>
32
- label === undefined ? String(value) : `${label} ${value}`;
50
+ label === undefined ? format(value) : `${label} ${format(value)}`;
33
51
  const lastIndex = (): number => props.points.length - 1;
34
- const first = (): ChartPoint | undefined => props.points[0];
35
- const last = (): ChartPoint | undefined => props.points.at(-1);
36
52
  const highlight = (): boolean => props.highlightLast !== false;
53
+ const stacked = (): boolean => props.points.some((point) => (point.secondary ?? 0) > 0);
54
+
55
+ const series = (): readonly ChartSeries[] => [
56
+ { label: props.seriesLabels?.primary ?? props.label, values: props.points.map((p) => p.value) },
57
+ ...(props.seriesLabels !== undefined || stacked()
58
+ ? [
59
+ {
60
+ label: props.seriesLabels?.secondary ?? '',
61
+ values: props.points.map((p) => p.secondary ?? 0),
62
+ },
63
+ ]
64
+ : []),
65
+ ];
66
+
67
+ const legend = (): readonly ChartLegendItem[] | undefined =>
68
+ props.seriesLabels === undefined
69
+ ? undefined
70
+ : [
71
+ { label: props.seriesLabels.primary, swatch: <span class={styles['swatch']} /> },
72
+ {
73
+ label: props.seriesLabels.secondary,
74
+ swatch: <span class={cx(styles['swatch'], styles['swatchSecondary'])} />,
75
+ },
76
+ ];
77
+
78
+ const hits = (): readonly ChartHit[] =>
79
+ props.points.flatMap((point, index) => {
80
+ const rect = rects()[index];
81
+ if (rect === undefined) return [];
82
+ const top = tops()[index] ?? null;
83
+ const x = rect.x + rect.width / 2;
84
+ const own = (y: number, label: string | undefined, value: number): ChartHit => ({
85
+ x: cssPercent(x, BAR_CHART.width),
86
+ y: cssPercent(y, BAR_CHART.height),
87
+ label: pointLabel(point.key, label, format(value)),
88
+ edge: hitEdge(x, BAR_CHART.width),
89
+ });
90
+ const first = own(rect.y, props.seriesLabels?.primary, point.value);
91
+ return top === null
92
+ ? [first]
93
+ : [first, own(top.y, props.seriesLabels?.secondary, point.secondary ?? 0)];
94
+ });
95
+
37
96
  return (
38
- <svg
39
- role="img"
40
- aria-label={props.label}
41
- viewBox={`0 0 ${BAR_CHART.width} ${BAR_CHART.height}`}
42
- class={cx(styles['chart'], props.class)}
97
+ <ChartFrame
98
+ label={props.label}
99
+ showCaption={props.showCaption}
100
+ class={props.class}
101
+ legend={legend()}
102
+ table={chartTable({
103
+ caption: props.label,
104
+ keyLabel: props.keyLabel,
105
+ keys: props.points.map((point) => point.key),
106
+ series: series(),
107
+ format,
108
+ })}
43
109
  >
44
- {GRID_STEPS.map((step) => (
45
- <line
46
- class={styles['grid']}
47
- x1={0}
48
- x2={BAR_CHART.width}
49
- y1={gridY(step)}
50
- y2={gridY(step)}
51
- />
52
- ))}
53
- {props.points.map((point, index) => {
54
- const rect = rects()[index];
55
- if (rect === undefined) return null;
56
- const top = tops()[index] ?? null;
57
- return (
58
- <g>
59
- <rect
60
- class={cx(styles['bar'], highlight() && index === lastIndex() && styles['last'])}
61
- x={rect.x}
62
- y={rect.y}
63
- width={rect.width}
64
- height={rect.height}
65
- rx={2}
66
- data-bar="true"
67
- >
68
- <title>{`${point.key}: ${named(props.seriesLabels?.primary, point.value)}`}</title>
69
- </rect>
70
- {top === null ? null : (
71
- <rect
72
- class={cx(
73
- styles['secondary'],
74
- highlight() && index === lastIndex() && styles['last'],
75
- )}
76
- x={top.x}
77
- y={top.y}
78
- width={top.width}
79
- height={top.height}
80
- rx={2}
81
- data-series="secondary"
82
- >
83
- <title>{`${point.key}: ${named(props.seriesLabels?.secondary, point.secondary ?? 0)}`}</title>
84
- </rect>
110
+ <div class={styles['chart']}>
111
+ {/* aria-hidden: the svg is the image and names itself; a bare "9" read aloud is noise. */}
112
+ <span class={cx(styles['axis'], styles['max'])} data-axis="max" aria-hidden="true">
113
+ {format(maxOf(props.points))}
114
+ </span>
115
+ <div class={styles['box']}>
116
+ <svg
117
+ role="img"
118
+ aria-label={props.label}
119
+ viewBox={`0 0 ${BAR_CHART.width} ${BAR_CHART.height}`}
120
+ class={styles['plot']}
121
+ >
122
+ {GRID_STEPS.map((step) => (
123
+ <line
124
+ class={styles['grid']}
125
+ x1={0}
126
+ x2={BAR_CHART.width}
127
+ y1={gridY(step)}
128
+ y2={gridY(step)}
129
+ />
130
+ ))}
131
+ {props.points.map((point, index) => {
132
+ const rect = rects()[index];
133
+ if (rect === undefined) return null;
134
+ const top = tops()[index] ?? null;
135
+ const last = highlight() && index === lastIndex();
136
+ return (
137
+ <g>
138
+ <rect
139
+ class={cx(styles['bar'], last && styles['last'])}
140
+ x={rect.x}
141
+ y={rect.y}
142
+ width={rect.width}
143
+ height={rect.height}
144
+ rx={2}
145
+ data-bar="true"
146
+ >
147
+ <title>{`${point.key}: ${named(props.seriesLabels?.primary, point.value)}`}</title>
148
+ </rect>
149
+ {top === null ? null : (
150
+ <rect
151
+ class={cx(styles['secondary'], last && styles['last'])}
152
+ x={top.x}
153
+ y={top.y}
154
+ width={top.width}
155
+ height={top.height}
156
+ rx={2}
157
+ data-series="secondary"
158
+ >
159
+ <title>{`${point.key}: ${named(props.seriesLabels?.secondary, point.secondary ?? 0)}`}</title>
160
+ </rect>
161
+ )}
162
+ </g>
163
+ );
164
+ })}
165
+ </svg>
166
+ {props.focusable === true ? chartHits(hits()) : null}
167
+ </div>
168
+ {props.points.length === 0 ? null : (
169
+ <div class={styles['keys']} aria-hidden="true">
170
+ <span class={styles['axis']} data-axis="first">
171
+ {props.points[0]?.key}
172
+ </span>
173
+ {/* One point: its bar is both first and last, so its key is named once. */}
174
+ {props.points.length === 1 ? null : (
175
+ <span class={styles['axis']} data-axis="last">
176
+ {props.points.at(-1)?.key}
177
+ </span>
85
178
  )}
86
- </g>
87
- );
88
- })}
89
- <text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.top - 3} text-anchor="end">
90
- {maxOf(props.points)}
91
- </text>
92
- {first() === undefined ? null : (
93
- <text class={styles['axis']} x={0} y={BAR_CHART.height - 3} text-anchor="start">
94
- {first()?.key}
95
- </text>
96
- )}
97
- {last() === undefined ? null : (
98
- <text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.height - 3} text-anchor="end">
99
- {last()?.key}
100
- </text>
101
- )}
102
- </svg>
179
+ </div>
180
+ )}
181
+ </div>
182
+ </ChartFrame>
103
183
  );
104
184
  }