@ultimat3/ui 29.3.2 → 30.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 (168) hide show
  1. package/CATALOG.md +491 -24
  2. package/CLAUDE.md +23 -16
  3. package/README.md +338 -33
  4. package/package.json +16 -5
  5. package/src/components/AppShell.module.scss +23 -7
  6. package/src/components/AppShell.tsx +9 -0
  7. package/src/components/BarChart.module.scss +26 -0
  8. package/src/components/BarChart.tsx +242 -93
  9. package/src/components/Board.module.scss +30 -0
  10. package/src/components/Board.tsx +47 -0
  11. package/src/components/BottomNav.module.scss +49 -0
  12. package/src/components/BottomNav.tsx +57 -0
  13. package/src/components/Calendar.module.scss +118 -0
  14. package/src/components/Calendar.tsx +246 -0
  15. package/src/components/ChartFrame.module.scss +40 -3
  16. package/src/components/ChartFrame.tsx +84 -28
  17. package/src/components/CodeBlock.module.scss +101 -0
  18. package/src/components/CodeBlock.tsx +76 -0
  19. package/src/components/CommandPalette.module.scss +25 -0
  20. package/src/components/CommandPalette.tsx +50 -22
  21. package/src/components/ConfirmDialog.module.scss +36 -0
  22. package/src/components/ConfirmDialog.tsx +188 -0
  23. package/src/components/DataTable.tsx +92 -33
  24. package/src/components/DatePicker.module.scss +37 -0
  25. package/src/components/DatePicker.tsx +134 -0
  26. package/src/components/DateRangePicker.module.scss +54 -0
  27. package/src/components/DateRangePicker.tsx +156 -0
  28. package/src/components/DescriptionList.module.scss +46 -0
  29. package/src/components/DescriptionList.tsx +38 -0
  30. package/src/components/DiffView.module.scss +124 -0
  31. package/src/components/DiffView.tsx +169 -0
  32. package/src/components/DonutChart.tsx +13 -4
  33. package/src/components/Drawer.module.scss +40 -0
  34. package/src/components/Drawer.tsx +56 -0
  35. package/src/components/Gauge.module.scss +8 -0
  36. package/src/components/Gauge.tsx +40 -13
  37. package/src/components/HeatStrip.module.scss +133 -0
  38. package/src/components/HeatStrip.tsx +64 -0
  39. package/src/components/JsonView.module.scss +64 -0
  40. package/src/components/JsonView.tsx +68 -0
  41. package/src/components/LineChart.tsx +45 -18
  42. package/src/components/LiveIndicator.module.scss +44 -0
  43. package/src/components/LiveIndicator.tsx +45 -0
  44. package/src/components/LogViewer.module.scss +102 -0
  45. package/src/components/LogViewer.tsx +123 -0
  46. package/src/components/Markdown.module.scss +87 -0
  47. package/src/components/Markdown.tsx +84 -0
  48. package/src/components/Meter.module.scss +7 -0
  49. package/src/components/Meter.tsx +16 -0
  50. package/src/components/NavList.module.scss +66 -0
  51. package/src/components/NavList.tsx +95 -0
  52. package/src/components/Pagination.module.scss +6 -2
  53. package/src/components/Pagination.tsx +57 -13
  54. package/src/components/Popover.tsx +12 -2
  55. package/src/components/PullToRefresh.module.scss +41 -0
  56. package/src/components/PullToRefresh.tsx +108 -0
  57. package/src/components/Radio.module.scss +51 -0
  58. package/src/components/Radio.tsx +12 -1
  59. package/src/components/SearchableSelect.module.scss +216 -0
  60. package/src/components/SearchableSelect.tsx +331 -0
  61. package/src/components/SearchableSelectChips.tsx +41 -0
  62. package/src/components/SearchableSelectNative.tsx +89 -0
  63. package/src/components/ShortcutSheet.module.scss +33 -0
  64. package/src/components/ShortcutSheet.tsx +67 -0
  65. package/src/components/Slider.module.scss +50 -0
  66. package/src/components/Slider.tsx +68 -0
  67. package/src/components/SortableList.module.scss +72 -0
  68. package/src/components/SortableList.tsx +42 -0
  69. package/src/components/Sparkline.module.scss +10 -2
  70. package/src/components/Sparkline.tsx +27 -10
  71. package/src/components/SplitPane.module.scss +39 -0
  72. package/src/components/SplitPane.tsx +96 -0
  73. package/src/components/StatTile.module.scss +29 -3
  74. package/src/components/StatTile.tsx +75 -16
  75. package/src/components/Stepper.module.scss +78 -0
  76. package/src/components/Stepper.tsx +73 -0
  77. package/src/components/Tabs.module.scss +13 -1
  78. package/src/components/Tabs.tsx +77 -1
  79. package/src/components/TerminalView.module.scss +68 -0
  80. package/src/components/TerminalView.tsx +97 -0
  81. package/src/components/TimeRangePicker.module.scss +85 -0
  82. package/src/components/TimeRangePicker.tsx +91 -0
  83. package/src/components/Timeline.module.scss +74 -0
  84. package/src/components/Timeline.tsx +60 -0
  85. package/src/components/Tree.module.scss +98 -0
  86. package/src/components/Tree.tsx +185 -0
  87. package/src/components/VirtualList.module.scss +60 -0
  88. package/src/components/VirtualList.tsx +251 -0
  89. package/src/components/app-shell-view.ts +4 -1
  90. package/src/components/bar-chart-view.ts +121 -39
  91. package/src/components/board-view.ts +25 -0
  92. package/src/components/bottom-nav-view.ts +18 -0
  93. package/src/components/calendar-view.ts +226 -0
  94. package/src/components/chart-checks.ts +59 -0
  95. package/src/components/chart-frame-view.ts +33 -2
  96. package/src/components/chart-reference-view.ts +41 -0
  97. package/src/components/command-palette-view.ts +47 -6
  98. package/src/components/confirm-dialog-view.ts +47 -0
  99. package/src/components/data-table-rows.ts +93 -0
  100. package/src/components/date-picker-view.ts +38 -0
  101. package/src/components/date-range-view.ts +46 -0
  102. package/src/components/diff-view-view.ts +85 -0
  103. package/src/components/drawer-gesture-view.ts +33 -0
  104. package/src/components/gauge-view.ts +29 -0
  105. package/src/components/heat-strip-view.ts +68 -0
  106. package/src/components/json-view-view.ts +54 -0
  107. package/src/components/line-chart-view.ts +87 -25
  108. package/src/components/log-viewer-view.ts +51 -0
  109. package/src/components/markdown-nodes.tsx +150 -0
  110. package/src/components/nav-list-view.ts +37 -0
  111. package/src/components/pull-refresh-view.ts +93 -0
  112. package/src/components/rank-options.ts +61 -0
  113. package/src/components/searchable-select-keys.ts +118 -0
  114. package/src/components/searchable-select-view.ts +187 -0
  115. package/src/components/slider-view.ts +47 -0
  116. package/src/components/sort-pointer.ts +141 -0
  117. package/src/components/sort-surface.tsx +231 -0
  118. package/src/components/sparkline-view.ts +74 -14
  119. package/src/components/split-pane-view.ts +69 -0
  120. package/src/components/stat-delta.ts +54 -11
  121. package/src/components/stepper-view.ts +38 -0
  122. package/src/components/thunk.ts +9 -0
  123. package/src/components/timeline-view.ts +29 -0
  124. package/src/components/tree-node.tsx +133 -0
  125. package/src/components/tree-view.ts +185 -0
  126. package/src/components/use-command-palette.ts +153 -0
  127. package/src/components/virtual-list-anchor.ts +35 -0
  128. package/src/components/virtual-list-observer.ts +58 -0
  129. package/src/components/virtual-list-view.ts +145 -0
  130. package/src/content/ansi.ts +144 -0
  131. package/src/content/heading-slug.ts +32 -0
  132. package/src/content/highlight.ts +129 -0
  133. package/src/content/languages/all.ts +21 -0
  134. package/src/content/languages/diff.ts +14 -0
  135. package/src/content/languages/html.ts +16 -0
  136. package/src/content/languages/json.ts +15 -0
  137. package/src/content/languages/shell.ts +20 -0
  138. package/src/content/languages/sql.ts +27 -0
  139. package/src/content/languages/ts.ts +26 -0
  140. package/src/content/languages/yaml.ts +18 -0
  141. package/src/content/line-diff.ts +191 -0
  142. package/src/content/markdown-inline.ts +368 -0
  143. package/src/content/markdown-parse.ts +267 -0
  144. package/src/content/markdown-tree.ts +58 -0
  145. package/src/content/unified-patch.ts +53 -0
  146. package/src/drag/pointer-drag.ts +185 -0
  147. package/src/drag/sort-controller.ts +196 -0
  148. package/src/drag/sort-view.ts +201 -0
  149. package/src/errors.ts +1 -1
  150. package/src/form/field-path.ts +7 -33
  151. package/src/form/form-binding.ts +5 -8
  152. package/src/form/form-issue.ts +30 -36
  153. package/src/form/form-values.ts +3 -2
  154. package/src/hotkeys-view.ts +150 -0
  155. package/src/i18n-keys.ts +7 -50
  156. package/src/index.ts +214 -15
  157. package/src/list-url.ts +123 -0
  158. package/src/live-window.ts +23 -0
  159. package/src/roving.ts +2 -0
  160. package/src/theme/inert-runtime.ts +4 -0
  161. package/src/theme/runtime-slot.ts +2 -2
  162. package/src/theme/solid-adapter.ts +11 -5
  163. package/src/time-range-url.ts +200 -0
  164. package/src/tokens/_mixins.scss +1 -1
  165. package/src/tokens/_touch.scss +30 -1
  166. package/src/tokens/contrast-pairs.ts +12 -0
  167. package/src/ui-keys.ts +126 -0
  168. package/src/use-hotkeys.ts +145 -0
@@ -0,0 +1,123 @@
1
+ // Structured log rows on `VirtualList`: a 100,000-line run mounts a screenful, follows the end
2
+ // while the reader is there, and keeps only the newest `cap` lines. With script, a text filter, a
3
+ // level floor and a follow toggle; without it, every line given is in the document.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { UI_KEYS } from '../i18n-keys';
8
+ import { useUi } from '../theme/context';
9
+ import { solid } from '../theme/solid-adapter';
10
+ import { DateTime } from './DateTime';
11
+ import { Link } from './Link';
12
+ import styles from './LogViewer.module.scss';
13
+ import { LOG_VIEWER_LEVELS, type LogLine, type LogViewerLevel, logWindow } from './log-viewer-view';
14
+ import { VirtualList } from './VirtualList';
15
+
16
+ export interface LogViewerProps {
17
+ lines: readonly LogLine[];
18
+ /** Already-translated name of the log; defaults to `ui.log.region`. */
19
+ label?: string | undefined;
20
+ /** Start following the end. Defaults to true; the reader can turn it off. */
21
+ follow?: boolean | undefined;
22
+ /** The newest lines kept. Defaults to 10,000. */
23
+ cap?: number | undefined;
24
+ class?: string | undefined;
25
+ }
26
+
27
+ const isLevel = (value: string): value is LogViewerLevel =>
28
+ (LOG_VIEWER_LEVELS as readonly string[]).includes(value);
29
+
30
+ export function LogViewer(props: LogViewerProps): JSX.Element {
31
+ const ui = useUi();
32
+ const rt = solid();
33
+ const [enhanced, setEnhanced] = rt.createSignal(false);
34
+ rt.createEffect(() => setEnhanced(true));
35
+ const [query, setQuery] = rt.createSignal('');
36
+ const [minimum, setMinimum] = rt.createSignal<LogViewerLevel | undefined>(undefined);
37
+ const [follow, setFollow] = rt.createSignal(props.follow !== false);
38
+ const shown = rt.createMemo(() =>
39
+ logWindow(props.lines, {
40
+ query: query(),
41
+ minimum: minimum(),
42
+ cap: props.cap,
43
+ locale: ui.locale,
44
+ }),
45
+ );
46
+ const label = (): string => props.label ?? ui.t(UI_KEYS.logRegion);
47
+
48
+ const row = (line: LogLine): JSX.Element => (
49
+ <div class={styles['line']} data-level={line.level}>
50
+ {line.at === undefined ? (
51
+ <span />
52
+ ) : (
53
+ <DateTime
54
+ value={line.at}
55
+ timeZone={ui.timeZone}
56
+ timeStyle="medium"
57
+ class={styles['time']}
58
+ />
59
+ )}
60
+ <span class={styles['level']}>{line.level ?? ''}</span>
61
+ <span class={styles['text']}>
62
+ {line.href === undefined ? line.text : <Link href={line.href}>{line.text}</Link>}
63
+ </span>
64
+ </div>
65
+ );
66
+
67
+ return (
68
+ <section
69
+ class={cx(styles['log'], props.class)}
70
+ aria-label={label()}
71
+ data-enhanced={enhanced() ? 'true' : undefined}
72
+ >
73
+ <div class={styles['bar']}>
74
+ <input
75
+ type="search"
76
+ class={styles['filter']}
77
+ aria-label={ui.t(UI_KEYS.logFilter)}
78
+ placeholder={ui.t(UI_KEYS.logFilter)}
79
+ value={query()}
80
+ onInput={(event: InputEvent) => setQuery((event.currentTarget as HTMLInputElement).value)}
81
+ />
82
+ <select
83
+ class={styles['levels']}
84
+ aria-label={ui.t(UI_KEYS.logLevel)}
85
+ onChange={(event: Event) => {
86
+ const value = (event.currentTarget as HTMLSelectElement).value;
87
+ setMinimum(isLevel(value) ? value : undefined);
88
+ }}
89
+ >
90
+ <option value="" selected={minimum() === undefined}>
91
+ {ui.t(UI_KEYS.logAll)}
92
+ </option>
93
+ {LOG_VIEWER_LEVELS.map((level) => (
94
+ <option value={level} selected={minimum() === level}>
95
+ {level}
96
+ </option>
97
+ ))}
98
+ </select>
99
+ <button
100
+ type="button"
101
+ class={styles['follow']}
102
+ aria-pressed={follow() ? 'true' : 'false'}
103
+ onClick={() => setFollow(!follow())}
104
+ >
105
+ {ui.t(UI_KEYS.logFollow)}
106
+ </button>
107
+ </div>
108
+ {shown().dropped > 0 ? (
109
+ <p class={styles['capped']}>{ui.t(UI_KEYS.logCapped, { count: shown().lines.length })}</p>
110
+ ) : null}
111
+ {/* The box is this component's: its height is fixed here, whatever order the sheets load in. */}
112
+ <div class={styles['box']}>
113
+ <VirtualList
114
+ items={shown().lines}
115
+ itemKey={(line: LogLine) => line.id}
116
+ renderItem={row}
117
+ follow={follow()}
118
+ label={label()}
119
+ />
120
+ </div>
121
+ </section>
122
+ );
123
+ }
@@ -0,0 +1,87 @@
1
+ @use '../tokens' as t;
2
+
3
+ // Body copy never below 16 px on a phone (`text(md)` floors at 1rem); a wide table scrolls in its
4
+ // own box so the page never scrolls sideways.
5
+ .markdown {
6
+ @include t.column(t.space(3));
7
+ font-size: t.text(md);
8
+ line-height: t.leading(normal);
9
+ color: t.role('fg');
10
+ overflow-wrap: anywhere;
11
+
12
+ p,
13
+ ul,
14
+ ol {
15
+ margin: 0;
16
+ }
17
+
18
+ ul,
19
+ ol {
20
+ padding-inline-start: t.space(6);
21
+ }
22
+
23
+ hr {
24
+ inline-size: 100%;
25
+ border: 0;
26
+ border-block-start: t.stroke(hairline) solid t.role('line');
27
+ }
28
+ }
29
+
30
+ .heading {
31
+ margin: 0;
32
+ color: t.role('fg-strong');
33
+ line-height: t.leading(tight);
34
+ }
35
+
36
+ .code {
37
+ padding-inline: t.space(1);
38
+ border-radius: t.radius(sm);
39
+ background: t.role('bg-soft');
40
+ font-family: var(--font-mono);
41
+ font-size: t.text(sm);
42
+ }
43
+
44
+ .quote {
45
+ @include t.column(t.space(2));
46
+ @include t.border-inline-start(t.stroke(heavy), 'line');
47
+ margin: 0;
48
+ padding-inline-start: t.space(3);
49
+ color: t.role('fg-muted');
50
+ }
51
+
52
+ .task {
53
+ list-style: none;
54
+
55
+ input {
56
+ margin-inline-end: t.space(2);
57
+ }
58
+ }
59
+
60
+ .table {
61
+ max-inline-size: 100%;
62
+ overflow-x: auto;
63
+
64
+ table {
65
+ border-collapse: collapse;
66
+ }
67
+
68
+ th,
69
+ td {
70
+ padding-block: t.space(1);
71
+ padding-inline: t.space(2);
72
+ border: t.stroke(hairline) solid t.role('line');
73
+ text-align: start;
74
+ }
75
+
76
+ [data-align='center'] {
77
+ text-align: center;
78
+ }
79
+
80
+ [data-align='end'] {
81
+ text-align: end;
82
+ }
83
+ }
84
+
85
+ .rest {
86
+ white-space: pre-wrap;
87
+ }
@@ -0,0 +1,84 @@
1
+ // What a model or a person wrote, rendered: text → tree → elements, never HTML. Raw HTML, a
2
+ // `javascript:` link and a remote image all print inert. Re-parsed per change and keyed by block
3
+ // index, so a streaming body keeps every finished block's node and only the open one redraws.
4
+
5
+ import { finiteCount } from '@ultimat3/core';
6
+ import type { JSX } from 'solid-js';
7
+ import { headingIds } from '../content/heading-slug';
8
+ import type { Language } from '../content/highlight';
9
+ import { parseMarkdown } from '../content/markdown-parse';
10
+ import type { MarkdownBlock } from '../content/markdown-tree';
11
+ import { cx } from '../cx';
12
+ import { solid } from '../theme/solid-adapter';
13
+ import { keyedRows } from './data-table-rows';
14
+ import { type HeadingLevel, headingTag } from './heading-level';
15
+ import styles from './Markdown.module.scss';
16
+ import { blockNode, type ResolvedImage } from './markdown-nodes';
17
+
18
+ export type { ResolvedImage };
19
+
20
+ export interface MarkdownProps {
21
+ /** The body. Malformed markdown is never an error: what cannot parse prints as text. */
22
+ text: string;
23
+ /** The level a `#` renders at. Defaults to 2: a body never adds a second `h1` to a page. */
24
+ baseLevel?: 1 | 2 | 3 | 4 | 5 | 6 | undefined;
25
+ /** Vouch for an image: its served source and box, or `null` to print its alt as a link. */
26
+ resolveImage?: ((src: string) => ResolvedImage | null) | undefined;
27
+ /**
28
+ * Give each heading the id the GitHub wiki would (`headingSlug`), de-duplicated `-1`, `-2` in
29
+ * order, so a `#anchor` link scrolls. Off by default: two bodies on one page would collide.
30
+ */
31
+ headingIds?: boolean | undefined;
32
+ /** Languages fences may highlight with. Absent: fences are plain text, and an island pays 0 B. */
33
+ languages?: readonly Language[] | undefined;
34
+ class?: string | undefined;
35
+ }
36
+
37
+ export function Markdown(props: MarkdownProps): JSX.Element {
38
+ const rt = solid();
39
+ // A level off the 1–6 scale (NaN included) is `X_UI_INVALID_VALUE` from `headingTag`.
40
+ const base = (): number => {
41
+ const level = finiteCount('Markdown', 'baseLevel', props.baseLevel ?? 2, 1);
42
+ headingTag(level as HeadingLevel);
43
+ return level;
44
+ };
45
+ // A block equal to the last parse's block at its index is that same object, so its node and
46
+ // everything under it stay put while a later block grows.
47
+ let previous: readonly MarkdownBlock[] = [];
48
+ let previousJson: readonly string[] = [];
49
+ const blocks = rt.createMemo((): readonly MarkdownBlock[] => {
50
+ const parsed = parseMarkdown(props.text);
51
+ const json = parsed.map((block) => JSON.stringify(block));
52
+ const stable = parsed.map((block, index) =>
53
+ json[index] === previousJson[index] ? (previous[index] ?? block) : block,
54
+ );
55
+ previous = stable;
56
+ previousJson = json;
57
+ return stable;
58
+ });
59
+ const ids = rt.createMemo(() => headingIds(blocks()));
60
+ // Keyed by position: the block at an index is the same object until its content changes.
61
+ let position = new Map<MarkdownBlock, number>();
62
+ const nodes = keyedRows<MarkdownBlock, JSX.Element>(
63
+ rt,
64
+ (block) => String(position.get(block) ?? -1),
65
+ (block) => (
66
+ <>
67
+ {blockNode(block(), {
68
+ baseLevel: base(),
69
+ resolveImage: props.resolveImage,
70
+ languages: props.languages,
71
+ ids: props.headingIds === true ? ids() : undefined,
72
+ })}
73
+ </>
74
+ ),
75
+ );
76
+
77
+ const rendered = rt.createMemo(() => {
78
+ const current = blocks();
79
+ position = new Map(current.map((block, index) => [block, index]));
80
+ return nodes(current);
81
+ });
82
+
83
+ return <div class={cx(styles['markdown'], props.class)}>{rendered()}</div>;
84
+ }
@@ -16,3 +16,10 @@
16
16
  .fill {
17
17
  fill: var(--tone);
18
18
  }
19
+
20
+ // The target tick stands proud of the bar both ways, in the strongest text colour, every tone.
21
+ .target {
22
+ stroke: t.role('fg-strong');
23
+ stroke-width: 2;
24
+ vector-effect: non-scaling-stroke;
25
+ }
@@ -18,6 +18,8 @@ export interface MeterProps {
18
18
  /** The accessible name. Omit for a purely decorative bar beside a visible number. */
19
19
  label?: string | undefined;
20
20
  tone?: Tone | undefined;
21
+ /** A tick at this value — a bullet bar: the fill read against its goal. */
22
+ target?: number | undefined;
21
23
  class?: string | undefined;
22
24
  }
23
25
 
@@ -25,9 +27,23 @@ export function Meter(props: MeterProps): JSX.Element {
25
27
  const share = (): number => meterShare(props.value, props.max);
26
28
  const classes = (): string =>
27
29
  cx(styles['meter'], styles[`tone-${props.tone ?? 'accent'}`], props.class);
30
+ const target = (): number | null =>
31
+ props.target !== undefined && Number.isFinite(props.target) && props.max > 0
32
+ ? meterShare(props.target, props.max)
33
+ : null;
28
34
  const bars = (): JSX.Element => [
29
35
  <rect class={styles['track']} width={100} height={4} rx={2} />,
30
36
  <rect class={styles['fill']} width={meterWidth(share())} height={4} rx={2} />,
37
+ target() === null ? null : (
38
+ <line
39
+ class={styles['target']}
40
+ data-target="true"
41
+ x1={meterWidth(target() ?? 0)}
42
+ x2={meterWidth(target() ?? 0)}
43
+ y1={-2}
44
+ y2={6}
45
+ />
46
+ ),
31
47
  ];
32
48
  // Two literal elements rather than one with conditional ARIA: the `meter` role's value
33
49
  // attributes are only valid ON that role, and a lint that reads roles statically has to see it.
@@ -0,0 +1,66 @@
1
+ @use '../tokens' as t;
2
+
3
+ .list {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: t.space(1);
7
+ margin: 0;
8
+ padding: 0;
9
+ list-style: none;
10
+ }
11
+
12
+ .group {
13
+ margin-block-start: t.space(3);
14
+ }
15
+
16
+ .heading {
17
+ @include t.focus-ring;
18
+ padding-block: t.space(1);
19
+ padding-inline: t.space(3);
20
+ color: t.role('fg-muted');
21
+ font-size: t.text(xs);
22
+ font-weight: t.weight(semibold);
23
+ cursor: default;
24
+ }
25
+
26
+ summary.heading {
27
+ cursor: pointer;
28
+ }
29
+
30
+ .link {
31
+ @include t.focus-ring;
32
+ @include t.touch-target;
33
+ @include t.transition(background, color);
34
+ display: flex;
35
+ align-items: center;
36
+ gap: t.space(2);
37
+ padding-inline: t.space(3);
38
+ border-radius: t.radius(md);
39
+ color: t.role('fg');
40
+ text-decoration: none;
41
+
42
+ &:hover {
43
+ background: t.role('surface');
44
+ color: t.role('fg-strong');
45
+ }
46
+
47
+ &[aria-current='page'] {
48
+ background: t.role('surface');
49
+ color: t.role('accent');
50
+ font-weight: t.weight(semibold);
51
+ }
52
+ }
53
+
54
+ .icon {
55
+ display: inline-flex;
56
+ flex: none;
57
+ }
58
+
59
+ .label {
60
+ flex: 1;
61
+ min-inline-size: 0;
62
+ }
63
+
64
+ .badge {
65
+ flex: none;
66
+ }
@@ -0,0 +1,95 @@
1
+ // The ways around an app: links only (a command is `Menu`'s), in groups, one marked current by
2
+ // `currentOf` — the longest href covering the page. Server-rendered and scriptless; a collapsible
3
+ // group is a `<details>`, open when it holds the current page.
4
+ //
5
+ // Renders lists, not a `<nav>`: it sits in `AppShell`'s sidebar, which is already the navigation
6
+ // landmark, and a second one inside it would announce the same region twice.
7
+
8
+ import type { JSX } from 'solid-js';
9
+ import { useId } from '../a11y';
10
+ import { cx } from '../cx';
11
+ import { linkTarget } from './link-target';
12
+ import styles from './NavList.module.scss';
13
+ import { currentOf } from './nav-list-view';
14
+
15
+ export interface NavListLink {
16
+ /** Already translated. */
17
+ label: string;
18
+ href: string;
19
+ /** An `<Icon>`, decorative: the label is the name. */
20
+ icon?: JSX.Element | undefined;
21
+ /** A count or state beside the label — a `<Badge>`. */
22
+ badge?: JSX.Element | undefined;
23
+ }
24
+
25
+ export interface NavListGroup {
26
+ /** Already-translated group heading. */
27
+ label: string;
28
+ items: readonly NavListLink[];
29
+ /** A `<details>` the reader can fold; always open while it holds the current page. */
30
+ collapsible?: boolean | undefined;
31
+ }
32
+
33
+ export type NavListEntry = NavListLink | NavListGroup;
34
+
35
+ export interface NavListProps {
36
+ items: readonly NavListEntry[];
37
+ /** The page's own path — `currentOf` decides which one link carries `aria-current="page"`. */
38
+ currentHref: string;
39
+ class?: string | undefined;
40
+ }
41
+
42
+ const isGroup = (entry: NavListEntry): entry is NavListGroup => 'items' in entry;
43
+
44
+ export function NavList(props: NavListProps): JSX.Element {
45
+ const base = useId('nav-group');
46
+ const links = props.items.flatMap((entry) => (isGroup(entry) ? entry.items : [entry]));
47
+ const current = links[currentOf(links, props.currentHref)];
48
+
49
+ const link = (item: NavListLink): JSX.Element => (
50
+ <li>
51
+ <a
52
+ class={styles['link']}
53
+ href={linkTarget(item.href).href}
54
+ aria-current={item === current ? 'page' : undefined}
55
+ >
56
+ {item.icon === undefined ? null : <span class={styles['icon']}>{item.icon}</span>}
57
+ <span class={styles['label']}>{item.label}</span>
58
+ {item.badge === undefined ? null : <span class={styles['badge']}>{item.badge}</span>}
59
+ </a>
60
+ </li>
61
+ );
62
+
63
+ return (
64
+ <ul class={cx(styles['list'], props.class)}>
65
+ {props.items.map((entry, index) => {
66
+ if (!isGroup(entry)) return link(entry);
67
+ const headingId = `${base}-${index}`;
68
+ const list = (
69
+ <ul class={styles['list']} aria-labelledby={headingId}>
70
+ {entry.items.map(link)}
71
+ </ul>
72
+ );
73
+ return (
74
+ <li class={styles['group']}>
75
+ {entry.collapsible === true ? (
76
+ <details
77
+ open={current !== undefined && entry.items.includes(current) ? true : undefined}
78
+ >
79
+ <summary class={styles['heading']} id={headingId}>
80
+ {entry.label}
81
+ </summary>
82
+ {list}
83
+ </details>
84
+ ) : (
85
+ <p class={styles['heading']} id={headingId}>
86
+ {entry.label}
87
+ </p>
88
+ )}
89
+ {entry.collapsible === true ? null : list}
90
+ </li>
91
+ );
92
+ })}
93
+ </ul>
94
+ );
95
+ }
@@ -4,8 +4,12 @@
4
4
  display: flex;
5
5
  align-items: center;
6
6
  gap: t.space(2);
7
- // Trailing edge in both writing directions.
8
- justify-content: flex-end;
7
+ }
8
+
9
+ // Next holds the trailing edge in both writing directions, so it stays put whether Previous or
10
+ // First is drawn beside it or not.
11
+ .next {
12
+ margin-inline-start: auto;
9
13
  }
10
14
 
11
15
  .status {
@@ -1,7 +1,7 @@
1
- // Cursor-first pagination — the only shape a Postgres-backed list should use.
2
- // Numbered mode exists for prerendered archives where the total is known.
3
- // One mode per use: callbacks (`onCursor` / `onPage`) for an island, or `hrefFor` for a
4
- // server-rendered list — anchors the browser follows, with no script on the page.
1
+ // Cursor-first pagination — the only shape a Postgres-backed list should use; numbered mode is for
2
+ // prerendered archives with a known total. One mode per use: callbacks for an island, or `hrefFor`
3
+ // anchors with no script. Keyset paging has no way back, so Previous is drawn only when there is
4
+ // one, and `firstHref`/`onFirst` restart the list.
5
5
 
6
6
  import type { JSX } from 'solid-js';
7
7
  import { cx } from '../cx';
@@ -14,12 +14,17 @@ import styles from './Pagination.module.scss';
14
14
  type PageDirection = 'next' | 'prev';
15
15
 
16
16
  interface PaginationBaseProps {
17
- /** Opaque cursor for the following page; absent disables "next". */
18
- nextCursor?: string | undefined;
19
- prevCursor?: string | undefined;
17
+ /**
18
+ * Opaque cursor for the following page; `null` or absent disables "next" — one state, so a
19
+ * framework `Page`'s `nextCursor` is handed straight through.
20
+ */
21
+ nextCursor?: string | null | undefined;
22
+ /** The cursor back. A keyset `Page` has none, and with none no Previous is drawn at all. */
23
+ prevCursor?: string | null | undefined;
20
24
  /** Already-translated; falls back to the ui.* catalog keys. */
21
25
  labelPrevious?: string | undefined;
22
26
  labelNext?: string | undefined;
27
+ labelFirst?: string | undefined;
23
28
  class?: string | undefined;
24
29
  }
25
30
 
@@ -31,7 +36,10 @@ export interface PaginationCallbackProps extends PaginationBaseProps {
31
36
  page?: number | undefined;
32
37
  totalPages?: number | undefined;
33
38
  onPage?: ((page: number) => void) | undefined;
39
+ /** Back to the start: a "First" button. Callback mode's `firstHref`. */
40
+ onFirst?: (() => void) | undefined;
34
41
  hrefFor?: undefined;
42
+ firstHref?: undefined;
35
43
  }
36
44
 
37
45
  /** Link mode: each cursor is a URL, so the pager works with scripting off. */
@@ -42,10 +50,13 @@ export interface PaginationLinkProps extends PaginationBaseProps {
42
50
  * `page` or `totalPages`.
43
51
  */
44
52
  hrefFor: (cursor: string, direction: 'next' | 'prev') => string;
53
+ /** The URL of the first page: a "First" anchor. Absent — already there — draws none. */
54
+ firstHref?: string | undefined;
45
55
  onCursor?: undefined;
46
56
  page?: undefined;
47
57
  totalPages?: undefined;
48
58
  onPage?: undefined;
59
+ onFirst?: undefined;
49
60
  }
50
61
 
51
62
  export type PaginationProps = PaginationCallbackProps | PaginationLinkProps;
@@ -56,14 +67,15 @@ export function Pagination(props: PaginationProps): JSX.Element {
56
67
  direction === 'prev'
57
68
  ? (props.labelPrevious ?? ui.t(UI_KEYS.previous))
58
69
  : (props.labelNext ?? ui.t(UI_KEYS.next));
70
+ // `null` and `undefined` are one state: a `Page` says "no next page" with `null`.
59
71
  const cursor = (direction: PageDirection): string | undefined =>
60
- direction === 'prev' ? props.prevCursor : props.nextCursor;
72
+ (direction === 'prev' ? props.prevCursor : props.nextCursor) ?? undefined;
61
73
  // A cursor present means cursor mode, exactly as `page`'s own doc says. The inverted form —
62
74
  // "cursor mode when a number is MISSING" — silently dropped the cursor and paged by number the
63
75
  // moment a caller passed both, which is the shape a list that knows its total naturally has.
64
76
  const cursorMode = (): boolean =>
65
- props.nextCursor !== undefined ||
66
- props.prevCursor !== undefined ||
77
+ cursor('next') !== undefined ||
78
+ cursor('prev') !== undefined ||
67
79
  props.page === undefined ||
68
80
  props.totalPages === undefined;
69
81
 
@@ -88,11 +100,12 @@ export function Pagination(props: PaginationProps): JSX.Element {
88
100
  /**
89
101
  * One side of the pager. In link mode a side WITH a cursor is an anchor and a side without one
90
102
  * is the same disabled button callback mode shows: an anchor with nowhere to go is a dead
91
- * control that still takes a tab stop, and dropping the side would move the other one between
92
- * the first page and the second.
103
+ * control that still takes a tab stop. Next stays put whether or not Previous is drawn —
104
+ * `.next` pins it to the trailing edge, so it never moves between the first page and the second.
93
105
  */
94
106
  const side = (direction: PageDirection): JSX.Element => {
95
107
  const to = cursor(direction);
108
+ const place = direction === 'next' ? styles['next'] : undefined;
96
109
  if (props.hrefFor !== undefined && to !== undefined) {
97
110
  return (
98
111
  <Link
@@ -102,6 +115,7 @@ export function Pagination(props: PaginationProps): JSX.Element {
102
115
  tone="neutral"
103
116
  rel={direction}
104
117
  href={props.hrefFor(to, direction)}
118
+ class={place}
105
119
  >
106
120
  {label(direction)}
107
121
  </Link>
@@ -114,15 +128,45 @@ export function Pagination(props: PaginationProps): JSX.Element {
114
128
  tone="neutral"
115
129
  disabled={exhausted(direction)}
116
130
  onClick={props.hrefFor === undefined ? () => go(direction) : undefined}
131
+ class={place}
117
132
  >
118
133
  {label(direction)}
119
134
  </Button>
120
135
  );
121
136
  };
122
137
 
138
+ /** A keyset page has no previous cursor: a Previous for it would be disabled forever. */
139
+ const hasPrevious = (): boolean => !cursorMode() || cursor('prev') !== undefined;
140
+
141
+ /** Back to the start — the one way back a keyset list has. Drawn only when it is given. */
142
+ const first = (): JSX.Element => {
143
+ const text = props.labelFirst ?? ui.t(UI_KEYS.first);
144
+ if (props.firstHref !== undefined) {
145
+ return (
146
+ <Link
147
+ appearance="button"
148
+ variant="secondary"
149
+ size="sm"
150
+ tone="neutral"
151
+ href={props.firstHref}
152
+ >
153
+ {text}
154
+ </Link>
155
+ );
156
+ }
157
+ const onFirst = props.onFirst;
158
+ if (onFirst === undefined) return null;
159
+ return (
160
+ <Button variant="secondary" size="sm" tone="neutral" onClick={() => onFirst()}>
161
+ {text}
162
+ </Button>
163
+ );
164
+ };
165
+
123
166
  return (
124
167
  <nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
125
- {side('prev')}
168
+ {first()}
169
+ {hasPrevious() ? side('prev') : null}
126
170
 
127
171
  {cursorMode() ? null : (
128
172
  <span class={styles['status']} aria-live="polite">
@@ -66,10 +66,20 @@ export function Popover(props: PopoverProps): JSX.Element {
66
66
  root = el;
67
67
  }}
68
68
  >
69
+ {/*
70
+ Getters, so the trigger is built ONCE: a snapshot of `props.open` read here made every open
71
+ and close rebuild the trigger, and the node the trap hands focus back to on close was the
72
+ one just thrown away — focus landed on <body>. Read inside the trigger's own attributes,
73
+ `open` updates those attributes and nothing else.
74
+ */}
69
75
  {props.trigger({
70
76
  id: triggerId,
71
- 'aria-expanded': props.open,
72
- 'aria-controls': props.open ? panelId : undefined,
77
+ get 'aria-expanded'() {
78
+ return props.open;
79
+ },
80
+ get 'aria-controls'() {
81
+ return props.open ? panelId : undefined;
82
+ },
73
83
  })}
74
84
  {props.open ? (
75
85
  <div