@ultimat3/ui 22.15.0 → 24.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 (65) hide show
  1. package/CATALOG.md +45 -14
  2. package/CLAUDE.md +16 -52
  3. package/README.md +69 -13
  4. package/package.json +6 -5
  5. package/src/a11y.ts +24 -10
  6. package/src/catalog/parse-component.ts +85 -12
  7. package/src/catalog/render-catalog.ts +21 -1
  8. package/src/components/Avatar.module.scss +1 -1
  9. package/src/components/BarChart.module.scss +12 -1
  10. package/src/components/BarChart.tsx +41 -15
  11. package/src/components/Button.tsx +3 -8
  12. package/src/components/Card.module.scss +1 -1
  13. package/src/components/Checkbox.module.scss +4 -4
  14. package/src/components/Checkbox.tsx +6 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/DataTable.module.scss +2 -0
  17. package/src/components/DataTable.tsx +135 -71
  18. package/src/components/Dialog.module.scss +2 -2
  19. package/src/components/Dropzone.module.scss +6 -4
  20. package/src/components/Dropzone.tsx +9 -8
  21. package/src/components/FileInput.module.scss +4 -2
  22. package/src/components/FileInput.tsx +6 -2
  23. package/src/components/Form.module.scss +2 -2
  24. package/src/components/Input.module.scss +1 -1
  25. package/src/components/Link.tsx +64 -12
  26. package/src/components/Meter.tsx +3 -3
  27. package/src/components/Pagination.tsx +89 -37
  28. package/src/components/Popover.module.scss +17 -20
  29. package/src/components/Popover.tsx +12 -5
  30. package/src/components/Radio.module.scss +2 -2
  31. package/src/components/Select.module.scss +3 -3
  32. package/src/components/Skeleton.module.scss +1 -1
  33. package/src/components/Spinner.module.scss +3 -3
  34. package/src/components/Switch.module.scss +2 -2
  35. package/src/components/Table.module.scss +2 -2
  36. package/src/components/Tabs.module.scss +11 -5
  37. package/src/components/Textarea.tsx +7 -4
  38. package/src/components/ThemeToggle.module.scss +1 -1
  39. package/src/components/Toast.module.scss +2 -2
  40. package/src/components/Tooltip.module.scss +8 -3
  41. package/src/components/bar-chart-view.ts +35 -5
  42. package/src/components/button-classes.ts +25 -0
  43. package/src/components/file-input-view.ts +31 -12
  44. package/src/components/link-classes.ts +38 -0
  45. package/src/components/link-target.ts +34 -1
  46. package/src/components/meter-view.ts +13 -0
  47. package/src/components/qr-matrix.ts +16 -23
  48. package/src/components/qr-reader.ts +187 -0
  49. package/src/components/relative-time-view.ts +11 -4
  50. package/src/form/form-binding.ts +9 -8
  51. package/src/icons/build-icons.ts +96 -20
  52. package/src/index.ts +15 -9
  53. package/src/sass-probe.ts +55 -0
  54. package/src/theme/brand.ts +22 -3
  55. package/src/theme/theme.ts +35 -10
  56. package/src/toast/toast-state.ts +0 -0
  57. package/src/tokens/_index.scss +3 -1
  58. package/src/tokens/_mixins.scss +56 -8
  59. package/src/tokens/_motion.scss +4 -0
  60. package/src/tokens/_stroke.scss +20 -0
  61. package/src/tokens/_units.scss +47 -0
  62. package/src/tokens/reset.scss +4 -4
  63. package/src/tokens/theme.scss +2 -0
  64. package/src/tokens/tokens.ts +6 -0
  65. package/src/theme/inline-script.ts +0 -49
@@ -46,12 +46,64 @@ export function headerComment(source: string): string {
46
46
  return lines.join(' ').trim();
47
47
  }
48
48
 
49
- /** The members of `export interface <name> { … }`, generics and all. */
50
- export function parseProps(source: string, interfaceName: string): PropDoc[] {
51
- const body = interfaceBody(source, interfaceName);
52
- if (body === undefined) return [];
49
+ /**
50
+ * Every prop a caller may write for `<name>`: the members of `interface <name>`, its bases' first,
51
+ * or — where `<name>` is `type <name> = A | B`, one interface per mode — the modes merged.
52
+ */
53
+ export function parseProps(source: string, typeName: string): PropDoc[] {
54
+ const modes = unionMembers(source, typeName);
55
+ if (modes === undefined) return interfaceProps(source, typeName);
56
+ return mergeModes(modes.map((mode) => interfaceProps(source, mode)));
57
+ }
53
58
 
54
- const props: PropDoc[] = [];
59
+ /** `GridLinkProps<Row>` names the interface `GridLinkProps`. */
60
+ const withoutTypeArguments = (name: string): string => name.replace(/<[^>]*>/, '').trim();
61
+
62
+ /** `A`, `B` out of `export type <name> = A | B;`. `undefined` when `<name>` is not such an alias. */
63
+ function unionMembers(source: string, typeName: string): string[] | undefined {
64
+ const alias = new RegExp(`export type ${typeName}(?:<[^>]*>)?\\s*=\\s*([\\w\\s|<>]+);`).exec(
65
+ source,
66
+ );
67
+ return alias?.[1]
68
+ ?.split('|')
69
+ .map((member) => withoutTypeArguments(member))
70
+ .filter((member) => member !== '');
71
+ }
72
+
73
+ /**
74
+ * One row per prop across modes. Required only when EVERY mode requires it — a prop one mode
75
+ * forbids is optional to a reader choosing between them — and typed as each mode types it.
76
+ */
77
+ function mergeModes(modes: readonly (readonly PropDoc[])[]): PropDoc[] {
78
+ const merged = new Map<string, PropDoc>();
79
+ for (const props of modes) {
80
+ for (const prop of props) {
81
+ const seen = merged.get(prop.name);
82
+ if (seen === undefined) {
83
+ merged.set(prop.name, prop);
84
+ continue;
85
+ }
86
+ merged.set(prop.name, {
87
+ name: prop.name,
88
+ type: seen.type === prop.type ? seen.type : `${seen.type} | ${prop.type}`,
89
+ required: seen.required && prop.required,
90
+ doc: seen.doc === '' ? prop.doc : seen.doc,
91
+ });
92
+ }
93
+ }
94
+ return [...merged.values()].map((prop) => ({
95
+ ...prop,
96
+ required: prop.required && modes.every((props) => props.some((p) => p.name === prop.name)),
97
+ }));
98
+ }
99
+
100
+ /** The members of `interface <name> { … }`, generics and all, after those of every base. */
101
+ function interfaceProps(source: string, interfaceName: string): PropDoc[] {
102
+ const declared = interfaceDeclaration(source, interfaceName);
103
+ if (declared === undefined) return [];
104
+ const { body, bases } = declared;
105
+
106
+ const own: PropDoc[] = [];
55
107
  let doc: string[] = [];
56
108
  let buffer = '';
57
109
  let depth = 0;
@@ -68,11 +120,16 @@ export function parseProps(source: string, interfaceName: string): PropDoc[] {
68
120
  // A member ends at a `;` that is not inside a function type or an inline object type.
69
121
  if (depth > 0 || !buffer.endsWith(';')) continue;
70
122
  const prop = toProp(buffer, doc.join(' ').trim());
71
- if (prop !== undefined) props.push(prop);
123
+ if (prop !== undefined) own.push(prop);
72
124
  buffer = '';
73
125
  doc = [];
74
126
  }
75
- return props;
127
+
128
+ const names = new Set(own.map((prop) => prop.name));
129
+ const inherited = bases
130
+ .flatMap((base) => interfaceProps(source, base))
131
+ .filter((prop) => !names.has(prop.name));
132
+ return [...inherited, ...own];
76
133
  }
77
134
 
78
135
  function isComment(line: string): boolean {
@@ -105,9 +162,13 @@ function toProp(declaration: string, doc: string): PropDoc | undefined {
105
162
  const match = /^(?:readonly\s+)?('[^']+'|"[^"]+"|\w+)(\?)?:\s*([\s\S]+);$/.exec(declaration);
106
163
  if (match === null) return undefined;
107
164
  const [, rawName = '', optional, rawType = ''] = match;
165
+ const type = normaliseType(rawType);
166
+ // `hrefFor?: undefined` is how one mode of a union forbids another mode's prop: a member that
167
+ // exists so the prop CANNOT be written is not a prop.
168
+ if (type === '' || type === 'never') return undefined;
108
169
  return {
109
170
  name: rawName.replace(/^['"]|['"]$/g, ''),
110
- type: normaliseType(rawType),
171
+ type,
111
172
  required: optional === undefined,
112
173
  doc,
113
174
  };
@@ -126,17 +187,29 @@ function normaliseType(type: string): string {
126
187
  );
127
188
  }
128
189
 
129
- /** Brace-matched so a nested object type does not end the interface early. */
130
- function interfaceBody(source: string, interfaceName: string): string | undefined {
131
- const header = new RegExp(`export interface ${interfaceName}(?:<[^>]*>)?\\s*\\{`).exec(source);
190
+ /**
191
+ * Brace-matched so a nested object type does not end the interface early. `export` is optional:
192
+ * a base shared by two modes is a detail of the file, and its members are still the component's.
193
+ */
194
+ function interfaceDeclaration(
195
+ source: string,
196
+ interfaceName: string,
197
+ ): { readonly body: string; readonly bases: readonly string[] } | undefined {
198
+ const header = new RegExp(
199
+ `(?:export )?interface ${interfaceName}(?:<[^>]*>)?(?:\\s+extends\\s+([\\w\\s,<>]+?))?\\s*\\{`,
200
+ ).exec(source);
132
201
  if (header === null) return undefined;
202
+ const bases = (header[1] ?? '')
203
+ .split(',')
204
+ .map((base) => withoutTypeArguments(base))
205
+ .filter((base) => base !== '');
133
206
  const start = header.index + header[0].length;
134
207
  let depth = 1;
135
208
  for (let i = start; i < source.length; i += 1) {
136
209
  const char = source[i];
137
210
  if (char === '{') depth += 1;
138
211
  if (char === '}') depth -= 1;
139
- if (depth === 0) return source.slice(start, i);
212
+ if (depth === 0) return { body: source.slice(start, i), bases };
140
213
  }
141
214
  return undefined;
142
215
  }
@@ -13,6 +13,7 @@ import {
13
13
  lineHeightTokens,
14
14
  radiusTokens,
15
15
  spaceTokens,
16
+ strokeTokens,
16
17
  zTokens,
17
18
  } from '../tokens/tokens';
18
19
  import type { ComponentDoc } from './parse-component';
@@ -99,6 +100,8 @@ ${scaleTable('Space — `--space-*`', spaceTokens)}
99
100
 
100
101
  ${scaleTable('Radius — `--radius-*`', radiusTokens)}
101
102
 
103
+ ${scaleTable('Stroke — `--stroke-*`', strokeTokens)}
104
+
102
105
  ${scaleTable('Font size — `--text-*`', fontSizeTokens)}
103
106
 
104
107
  ${scaleTable('Font weight — `--weight-*`', fontWeightTokens)}
@@ -111,6 +114,23 @@ ${scaleTable('Easing — `--easing-*`', easingTokens)}
111
114
 
112
115
  ${scaleTable('Z-index — `--z-*`', zTokens)}
113
116
 
114
- ${scaleTable('Breakpoints — `@include t.respond-to(<name>)`', breakpointTokens)}
117
+ ${scaleTable('Breakpoints — `respond-to` · `respond-down` · `respond-between`', breakpointTokens)}
118
+
119
+ \`@include t.respond-to(md)\` is \`md\` and wider, \`t.respond-down(md)\` is narrower than \`md\`, and
120
+ \`t.respond-between(md, lg)\` is \`md\` up to \`lg\`. A max-width arm stops 0.02px under its rung, so
121
+ no width matches two of them.
122
+
123
+ ### Computed lengths
124
+
125
+ ${table(
126
+ ['Function', 'Answers'],
127
+ [
128
+ ['`t.rem(24px)`', '`1.5rem` — px, rem or a unitless px count in, rem out'],
129
+ [
130
+ '`t.fluid(1rem, 2rem, 20rem, 80rem)`',
131
+ 'one `clamp()`: the first size at a 20rem viewport, the second at 80rem, linear between',
132
+ ],
133
+ ],
134
+ )}
115
135
  `;
116
136
  }
@@ -47,7 +47,7 @@
47
47
  inset-inline-end: 0;
48
48
  inline-size: 28%;
49
49
  block-size: 28%;
50
- border: 2px solid t.role('surface');
50
+ border: t.stroke(thick) solid t.role('surface');
51
51
  border-radius: t.radius(full);
52
52
  content: '';
53
53
  }
@@ -26,6 +26,17 @@
26
26
  }
27
27
  }
28
28
 
29
+ // The second series — what went wrong, stacked on what went right.
30
+ .secondary {
31
+ fill: t.role('danger');
32
+ opacity: 0.6;
33
+ transition: opacity t.duration(fast) t.easing(out);
34
+
35
+ &:hover {
36
+ opacity: 1;
37
+ }
38
+ }
39
+
29
40
  .last {
30
41
  opacity: 1;
31
42
  }
@@ -36,7 +47,7 @@
36
47
  // SVG text scales with the chart: below `sm` these shrink to unreadable specks, and an
37
48
  // unreadable label is noise. Every bar still carries its `<title>`.
38
49
  display: none;
39
- font-size: 8px;
50
+ font-size: t.rem(8px);
40
51
  fill: t.role('fg-muted');
41
52
 
42
53
  @include t.respond-to(sm) {
@@ -1,4 +1,5 @@
1
- // A bar chart of one series — clicks per day, signups per week — as static SVG. No charting
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
2
3
  // library: the framework's own docs use a sparkline pulling one in as the cautionary example, and
3
4
  // a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the
4
5
  // server-rendered shell IS the chart. Geometry lives in `bar-chart-view.ts`.
@@ -7,7 +8,7 @@ import type { JSX } from 'solid-js';
7
8
  import { cx } from '../cx';
8
9
  import styles from './BarChart.module.scss';
9
10
  import type { ChartPoint } from './bar-chart-view';
10
- import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf } from './bar-chart-view';
11
+ import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf, secondaryRects } from './bar-chart-view';
11
12
 
12
13
  export interface BarChartProps {
13
14
  /** The accessible name for the whole chart, already translated. */
@@ -16,11 +17,19 @@ export interface BarChartProps {
16
17
  points: readonly ChartPoint[];
17
18
  /** Draw the last bar at full strength — the eye lands where the live number is. Default on. */
18
19
  highlightLast?: boolean | undefined;
20
+ /**
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.
23
+ */
24
+ seriesLabels?: { readonly primary: string; readonly secondary: string } | undefined;
19
25
  class?: string | undefined;
20
26
  }
21
27
 
22
28
  export function BarChart(props: BarChartProps): JSX.Element {
23
29
  const rects = (): readonly ReturnType<typeof barRects>[number][] => barRects(props.points);
30
+ const tops = (): ReturnType<typeof secondaryRects> => secondaryRects(props.points);
31
+ const named = (label: string | undefined, value: number): string =>
32
+ label === undefined ? String(value) : `${label} ${value}`;
24
33
  const lastIndex = (): number => props.points.length - 1;
25
34
  const first = (): ChartPoint | undefined => props.points[0];
26
35
  const last = (): ChartPoint | undefined => props.points.at(-1);
@@ -44,20 +53,37 @@ export function BarChart(props: BarChartProps): JSX.Element {
44
53
  {props.points.map((point, index) => {
45
54
  const rect = rects()[index];
46
55
  if (rect === undefined) return null;
56
+ const top = tops()[index] ?? null;
47
57
  return (
48
- <rect
49
- class={cx(styles['bar'], highlight() && index === lastIndex() && styles['last'])}
50
- x={rect.x}
51
- y={rect.y}
52
- width={rect.width}
53
- height={rect.height}
54
- rx={2}
55
- data-bar="true"
56
- >
57
- <title>
58
- {point.key}: {point.value}
59
- </title>
60
- </rect>
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>
85
+ )}
86
+ </g>
61
87
  );
62
88
  })}
63
89
  <text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.top - 3} text-anchor="end">
@@ -14,6 +14,7 @@ import type { JSX } from 'solid-js';
14
14
  import { ariaBool } from '../a11y';
15
15
  import { cx } from '../cx';
16
16
  import styles from './Button.module.scss';
17
+ import { buttonClassKeys } from './button-classes';
17
18
  import { Spinner } from './Spinner';
18
19
  import type { ButtonVariant, Size, Tone } from './variants';
19
20
 
@@ -66,14 +67,8 @@ export function Button(props: ButtonProps): JSX.Element {
66
67
  <button
67
68
  id={props.id}
68
69
  type={props.type ?? 'button'}
69
- class={cx(
70
- styles['button'],
71
- styles[`variant-${props.variant ?? 'primary'}`],
72
- styles[`tone-${props.tone ?? 'accent'}`],
73
- styles[`size-${props.size ?? 'md'}`],
74
- props.fullWidth === true && styles['full'],
75
- props.class,
76
- )}
70
+ // The keys are `button-classes.ts`'s: `<Link appearance="button">` asks the same function.
71
+ class={cx(...buttonClassKeys(props).map((key) => styles[key]), props.class)}
77
72
  // Only the caller's explicit `disabled` reaches the attribute. `loading` is a state the user
78
73
  // is meant to read and wait out, not a control taken away from under them.
79
74
  disabled={props.disabled === true ? true : undefined}
@@ -17,7 +17,7 @@
17
17
 
18
18
  &:hover {
19
19
  border-color: t.role('accent', 0.4);
20
- transform: translateY(-2px);
20
+ transform: translateY(t.rem(-2px));
21
21
  box-shadow: t.shadow(md);
22
22
  }
23
23
 
@@ -34,8 +34,8 @@
34
34
  inset-block-start: 0.1rem;
35
35
  inline-size: 0.3rem;
36
36
  block-size: 0.55rem;
37
- border-inline-end: 2px solid t.role('accent-fg');
38
- border-block-end: 2px solid t.role('accent-fg');
37
+ border-inline-end: t.stroke(thick) solid t.role('accent-fg');
38
+ border-block-end: t.stroke(thick) solid t.role('accent-fg');
39
39
  transform: rotate(40deg) scale(0);
40
40
  transform-origin: center;
41
41
  content: '';
@@ -66,8 +66,8 @@
66
66
  }
67
67
 
68
68
  .input:focus-visible + .box {
69
- outline: 2px solid t.role('accent');
70
- outline-offset: 2px;
69
+ outline: t.stroke(thick) solid t.role('accent');
70
+ outline-offset: t.stroke(thick);
71
71
  }
72
72
 
73
73
  .input[aria-invalid='true'] + .box {
@@ -12,6 +12,11 @@ export interface CheckboxProps {
12
12
  id?: string | undefined;
13
13
  name?: string | undefined;
14
14
  value?: string | undefined;
15
+ /**
16
+ * The id of the `<form>` this box submits with, when it cannot sit inside it — a row checkbox in
17
+ * a table whose page already holds another form. The platform's own association, no script.
18
+ */
19
+ form?: string | undefined;
15
20
  checked?: boolean | undefined;
16
21
  /** Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. */
17
22
  indeterminate?: boolean | undefined;
@@ -33,6 +38,7 @@ export function Checkbox(props: CheckboxProps): JSX.Element {
33
38
  id={props.id}
34
39
  name={props.name}
35
40
  value={props.value}
41
+ form={props.form}
36
42
  checked={props.checked === true}
37
43
  disabled={props.disabled === true}
38
44
  required={props.required === true}
@@ -3,10 +3,14 @@
3
3
  .palette {
4
4
  position: fixed;
5
5
  inset-block-start: 12vh;
6
- inset-inline-start: 50%;
6
+ // Centred by auto margins between two zero insets, never by a 50% inset and a -50% translate:
7
+ // the inset is logical and the translate physical, so under `dir="rtl"` that pair puts the
8
+ // palette a whole width off-centre (`rtl-sheets.test.ts`).
9
+ inset-inline: 0;
7
10
  inline-size: min(32rem, calc(100vw - #{t.space(8)}));
8
11
  max-block-size: 60vh;
9
- margin: 0;
12
+ margin-block: 0;
13
+ margin-inline: auto;
10
14
  padding: 0;
11
15
  border: 1px solid t.role('line');
12
16
  border-radius: t.radius(lg);
@@ -20,8 +24,7 @@
20
24
  overflow: hidden;
21
25
  z-index: t.z(dialog);
22
26
 
23
- // Transform-only translate keeps the 50% inline offset from fighting `margin-inline`.
24
- transform: translateX(-50%) translateY(-4px) scale(0.98);
27
+ transform: translateY(calc(-1 * #{t.space(1)})) scale(0.98);
25
28
  opacity: 0;
26
29
  visibility: hidden;
27
30
  transition:
@@ -30,7 +33,7 @@
30
33
  visibility t.duration(fast) t.easing(out);
31
34
 
32
35
  &[open] {
33
- transform: translateX(-50%) translateY(0) scale(1);
36
+ transform: translateY(0) scale(1);
34
37
  opacity: 1;
35
38
  visibility: visible;
36
39
  transition-duration: t.duration(base);
@@ -20,6 +20,8 @@
20
20
  border-radius: t.radius(sm);
21
21
  color: inherit;
22
22
  font: inherit;
23
+ // The link mode renders this class on an anchor: a sort header reads as a header, not a link.
24
+ text-decoration: none;
23
25
  cursor: pointer;
24
26
 
25
27
  &:hover {
@@ -33,13 +33,12 @@ export interface Column<Row> {
33
33
  width?: string | undefined;
34
34
  }
35
35
 
36
- export interface DataTableProps<Row> {
36
+ interface DataTableBaseProps<Row> {
37
37
  caption: string;
38
38
  columns: readonly Column<Row>[];
39
39
  rows: readonly Row[];
40
40
  rowKey: (row: Row) => string;
41
41
  sort?: SortState | undefined;
42
- onSortChange?: (sort: SortState | undefined) => void;
43
42
  loading?: boolean | undefined;
44
43
  /** An UltimateError (or anything shaped like one) from the query. */
45
44
  error?: unknown | undefined;
@@ -49,7 +48,6 @@ export interface DataTableProps<Row> {
49
48
  /** Opaque cursors from the query result; absent means no further page. */
50
49
  nextCursor?: string | undefined;
51
50
  prevCursor?: string | undefined;
52
- onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
53
51
  stickyHeader?: boolean | undefined;
54
52
  density?: 'comfortable' | 'compact' | undefined;
55
53
  /** Placeholder row count while loading. Match the usual page size. */
@@ -57,6 +55,31 @@ export interface DataTableProps<Row> {
57
55
  class?: string | undefined;
58
56
  }
59
57
 
58
+ /** The pager and the sort headers call back: the table sits in an island. */
59
+ export interface DataTableCallbackProps<Row> extends DataTableBaseProps<Row> {
60
+ /** Callback paging. Never beside `hrefFor` — the type refuses the pair. */
61
+ onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
62
+ /** Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. */
63
+ onSortChange?: ((sort: SortState | undefined) => void) | undefined;
64
+ hrefFor?: undefined;
65
+ sortHrefFor?: undefined;
66
+ }
67
+
68
+ /** The pager and the sort headers are anchors: a server-rendered table needs no script. */
69
+ export interface DataTableLinkProps<Row> extends DataTableBaseProps<Row> {
70
+ /** Link paging: the URL of the page a cursor names, handed to `<Pagination hrefFor>`. */
71
+ hrefFor: (cursor: string, direction: 'next' | 'prev') => string;
72
+ /**
73
+ * Link sorting: the URL of the list under the NEXT sort state of a header's cycle (`undefined`
74
+ * is "unsorted"). Absent, a sortable header is plain text — never a button with no handler.
75
+ */
76
+ sortHrefFor?: ((sort: SortState | undefined) => string) | undefined;
77
+ onCursor?: undefined;
78
+ onSortChange?: undefined;
79
+ }
80
+
81
+ export type DataTableProps<Row> = DataTableCallbackProps<Row> | DataTableLinkProps<Row>;
82
+
60
83
  export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
61
84
  const ui = useUi();
62
85
 
@@ -114,76 +137,117 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
114
137
  ));
115
138
  };
116
139
 
117
- const decided = branch();
118
- if (decided.kind === 'failed') {
119
- return <ErrorState error={decided.error} onRetry={props.onRetry} class={props.class} />;
120
- }
140
+ /** One mode per use, exactly as `Pagination` declares it — the table only hands it through. */
141
+ const pager = (): JSX.Element =>
142
+ props.hrefFor === undefined ? (
143
+ <Pagination
144
+ nextCursor={props.nextCursor}
145
+ prevCursor={props.prevCursor}
146
+ onCursor={props.onCursor}
147
+ />
148
+ ) : (
149
+ <Pagination
150
+ nextCursor={props.nextCursor}
151
+ prevCursor={props.prevCursor}
152
+ hrefFor={props.hrefFor}
153
+ />
154
+ );
155
+
156
+ const indicator = (key: string): JSX.Element => (
157
+ <span aria-hidden="true" class={styles['indicator']}>
158
+ {ariaSortFor(props.sort, key) === 'ascending'
159
+ ? '▲'
160
+ : ariaSortFor(props.sort, key) === 'descending'
161
+ ? '▼'
162
+ : '↕'}
163
+ </span>
164
+ );
121
165
 
122
- // Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two ifs:
123
- // "No results" under a first page in flight is the bug that ordering used to be all that stopped.
124
- if (decided.kind === 'empty') {
166
+ /**
167
+ * One mode per table, as the pager has: an anchor at the next state's URL when the table is
168
+ * linked, a button that calls back when it is not. A linked table with no `sortHrefFor` gets the
169
+ * header text alone — a `<button>` on a page that never hydrates is a control that does nothing.
170
+ */
171
+ const sortControl = (column: Column<Row>): JSX.Element => {
172
+ if (column.sortable !== true) return column.header;
173
+ const label = `${column.header}: ${sortLabel(column.key)}`;
174
+ if (props.hrefFor !== undefined) {
175
+ const sortHrefFor = props.sortHrefFor;
176
+ if (sortHrefFor === undefined) return column.header;
177
+ return (
178
+ <a
179
+ class={styles['sortButton']}
180
+ aria-label={label}
181
+ href={sortHrefFor(nextSortState(props.sort, column.key))}
182
+ >
183
+ {column.header}
184
+ {indicator(column.key)}
185
+ </a>
186
+ );
187
+ }
188
+ const onSortChange = props.onSortChange;
125
189
  return (
126
- <EmptyState
127
- title={props.emptyTitle}
128
- description={props.emptyDescription}
129
- class={props.class}
130
- />
190
+ <button
191
+ type="button"
192
+ class={styles['sortButton']}
193
+ aria-label={label}
194
+ onClick={() => onSortChange?.(nextSortState(props.sort, column.key))}
195
+ >
196
+ {column.header}
197
+ {indicator(column.key)}
198
+ </button>
131
199
  );
132
- }
133
-
134
- return (
135
- <div class={cx(styles['wrap'], props.class)}>
136
- <Table
137
- caption={props.caption}
138
- stickyHeader={props.stickyHeader !== false}
139
- density={props.density ?? 'comfortable'}
200
+ };
201
+
202
+ // An array, not a fragment: this package's `.tsx` compiles to two factories (Solid's and render's
203
+ // `h`), and only an array means the same sibling list to both.
204
+ const table = (decided: AsyncBranch<readonly Row[]>): JSX.Element => [
205
+ <Table
206
+ caption={props.caption}
207
+ stickyHeader={props.stickyHeader !== false}
208
+ density={props.density ?? 'comfortable'}
209
+ >
210
+ <thead>
211
+ <tr>
212
+ {props.columns.map((column) => (
213
+ <th
214
+ scope="col"
215
+ style={column.width === undefined ? undefined : { 'inline-size': column.width }}
216
+ aria-sort={ariaSortFor(props.sort, column.key)}
217
+ class={column.numeric === true ? styles['numeric'] : undefined}
218
+ >
219
+ {sortControl(column)}
220
+ </th>
221
+ ))}
222
+ </tr>
223
+ </thead>
224
+ <tbody
225
+ class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
226
+ aria-busy={ariaBool(isBusyBranch(decided))}
140
227
  >
141
- <thead>
142
- <tr>
143
- {props.columns.map((column) => (
144
- <th
145
- scope="col"
146
- style={column.width === undefined ? undefined : { 'inline-size': column.width }}
147
- aria-sort={ariaSortFor(props.sort, column.key)}
148
- class={column.numeric === true ? styles['numeric'] : undefined}
149
- >
150
- {column.sortable === true ? (
151
- <button
152
- type="button"
153
- class={styles['sortButton']}
154
- aria-label={`${column.header}: ${sortLabel(column.key)}`}
155
- onClick={() => props.onSortChange?.(nextSortState(props.sort, column.key))}
156
- >
157
- {column.header}
158
- <span aria-hidden="true" class={styles['indicator']}>
159
- {ariaSortFor(props.sort, column.key) === 'ascending'
160
- ? '▲'
161
- : ariaSortFor(props.sort, column.key) === 'descending'
162
- ? '▼'
163
- : '↕'}
164
- </span>
165
- </button>
166
- ) : (
167
- column.header
168
- )}
169
- </th>
170
- ))}
171
- </tr>
172
- </thead>
173
- <tbody
174
- class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
175
- aria-busy={ariaBool(isBusyBranch(decided))}
176
- >
177
- {body()}
178
- </tbody>
179
- </Table>
180
- {props.nextCursor === undefined && props.prevCursor === undefined ? null : (
181
- <Pagination
182
- nextCursor={props.nextCursor}
183
- prevCursor={props.prevCursor}
184
- onCursor={props.onCursor}
185
- />
186
- )}
187
- </div>
188
- );
228
+ {body()}
229
+ </tbody>
230
+ </Table>,
231
+ props.nextCursor === undefined && props.prevCursor === undefined ? null : pager(),
232
+ ];
233
+
234
+ /**
235
+ * The branch is decided INSIDE the returned JSX, as `AsyncRegion` does: a component body runs
236
+ * once, so a decision taken there is the decision for the island's whole life — a table that
237
+ * first rendered its error state kept it after the query recovered.
238
+ */
239
+ const content = (): JSX.Element => {
240
+ const decided = branch();
241
+ if (decided.kind === 'failed') {
242
+ return <ErrorState error={decided.error} onRetry={props.onRetry} />;
243
+ }
244
+ // Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two
245
+ // ifs: "No results" under a first page in flight is the bug that ordering used to stop.
246
+ if (decided.kind === 'empty') {
247
+ return <EmptyState title={props.emptyTitle} description={props.emptyDescription} />;
248
+ }
249
+ return table(decided);
250
+ };
251
+
252
+ return <div class={cx(styles['wrap'], props.class)}>{content()}</div>;
189
253
  }