@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
@@ -15,8 +15,10 @@ import { ariaBool } from '../a11y';
15
15
  import { cx } from '../cx';
16
16
  import { UI_KEYS } from '../i18n-keys';
17
17
  import { useUi } from '../theme/context';
18
+ import { type Accessor, solid } from '../theme/solid-adapter';
18
19
  import { type AsyncBranch, asyncBranch, isBusyBranch } from './async-branch';
19
20
  import styles from './DataTable.module.scss';
21
+ import { keyedRows } from './data-table-rows';
20
22
  import {
21
23
  type ColumnPriority,
22
24
  columnPriority,
@@ -58,6 +60,12 @@ interface DataTableBaseProps<Row> {
58
60
  caption: string;
59
61
  columns: readonly Column<Row>[];
60
62
  rows: readonly Row[];
63
+ /**
64
+ * The row's identity: its primary key — unique within `rows` and stable across updates, never an
65
+ * index or a value that can change. A live update keeps the `<tr>` of every key it still holds:
66
+ * the same object runs nothing, so focus and an open "more" stay put; a new object redraws that
67
+ * row's cells. Two rows under one key are `X_UI_INVALID_VALUE`.
68
+ */
61
69
  rowKey: (row: Row) => string;
62
70
  sort?: SortState | undefined;
63
71
  loading?: boolean | undefined;
@@ -66,9 +74,12 @@ interface DataTableBaseProps<Row> {
66
74
  onRetry?: (() => void) | undefined;
67
75
  emptyTitle?: string | undefined;
68
76
  emptyDescription?: string | undefined;
69
- /** Opaque cursors from the query result; absent means no further page. */
70
- nextCursor?: string | undefined;
71
- prevCursor?: string | undefined;
77
+ /**
78
+ * Opaque cursors from the query result; `null` or absent means no further page, so a framework
79
+ * `Page`'s `nextCursor` is handed straight through. A keyset `Page` has no `prevCursor`.
80
+ */
81
+ nextCursor?: string | null | undefined;
82
+ prevCursor?: string | null | undefined;
72
83
  stickyHeader?: boolean | undefined;
73
84
  /** Pins the first column while the table scrolls sideways. */
74
85
  stickyFirstColumn?: boolean | undefined;
@@ -86,8 +97,11 @@ export interface DataTableCallbackProps<Row> extends DataTableBaseProps<Row> {
86
97
  onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
87
98
  /** Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. */
88
99
  onSortChange?: ((sort: SortState | undefined) => void) | undefined;
100
+ /** Back to the first page: the pager's "First" button. Pass it only past the first page. */
101
+ onFirst?: (() => void) | undefined;
89
102
  hrefFor?: undefined;
90
103
  sortHrefFor?: undefined;
104
+ firstHref?: undefined;
91
105
  }
92
106
 
93
107
  /** The pager and the sort headers are anchors: a server-rendered table needs no script. */
@@ -99,14 +113,18 @@ export interface DataTableLinkProps<Row> extends DataTableBaseProps<Row> {
99
113
  * is "unsorted"). Absent, a sortable header is plain text — never a button with no handler.
100
114
  */
101
115
  sortHrefFor?: ((sort: SortState | undefined) => string) | undefined;
116
+ /** The first page's URL, for the pager's "First" anchor — `listUrl().firstHref`. */
117
+ firstHref?: string | undefined;
102
118
  onCursor?: undefined;
103
119
  onSortChange?: undefined;
120
+ onFirst?: undefined;
104
121
  }
105
122
 
106
123
  export type DataTableProps<Row> = DataTableCallbackProps<Row> | DataTableLinkProps<Row>;
107
124
 
108
125
  export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
109
126
  const ui = useUi();
127
+ const rt = solid();
110
128
 
111
129
  const sortLabel = (key: string): string =>
112
130
  ariaSortFor(props.sort, key) === 'ascending'
@@ -152,9 +170,10 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
152
170
 
153
171
  /**
154
172
  * The hidden columns' values, each entry shown only over the widths its own cell is hidden —
155
- * a `<details>`, so it opens with scripting off.
173
+ * a `<details>`, so it opens with scripting off. Only the values read the row: a new object
174
+ * under the same key redraws them and leaves the `<details>` — and whether it is open — alone.
156
175
  */
157
- const disclosure = (more: MoreColumn<Column<Row>>, row: Row): JSX.Element => (
176
+ const disclosure = (more: MoreColumn<Column<Row>>, row: Accessor<Row>): JSX.Element => (
158
177
  <td role={TABLE_ROLES.cell} class={styles['more']} data-more={more.until}>
159
178
  <details class={styles['disclosure']}>
160
179
  <summary class={styles['summary']}>{ui.t(UI_KEYS.more)}</summary>
@@ -162,7 +181,7 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
162
181
  {more.columns.map((column) => (
163
182
  <div class={styles['entry']} data-priority={priorityOf(column)}>
164
183
  <dt>{column.header}</dt>
165
- <dd>{column.cell(row)}</dd>
184
+ <dd>{column.cell(row())}</dd>
166
185
  </div>
167
186
  ))}
168
187
  </dl>
@@ -170,6 +189,29 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
170
189
  </td>
171
190
  );
172
191
 
192
+ /** One `<tr>` per key, built once; only what reads `row()` redraws when its object changes. */
193
+ const rowNode = (row: Accessor<Row>, key: string): JSX.Element => {
194
+ const more = moreColumn(props.columns);
195
+ return (
196
+ <tr role={TABLE_ROLES.row} class={styles['row']} data-row={key}>
197
+ {props.columns.map((column) => (
198
+ <td
199
+ role={TABLE_ROLES.cell}
200
+ class={cx(styles['cell'], column.numeric === true && styles['numeric'])}
201
+ data-priority={priorityOf(column)}
202
+ >
203
+ {cellContent(column, row())}
204
+ </td>
205
+ ))}
206
+ {more === undefined ? null : disclosure(more, row)}
207
+ </tr>
208
+ );
209
+ };
210
+
211
+ // Built in the component body, once: the cache must outlive every run of `body()`, and the
212
+ // owner here is what disposes every row with the table.
213
+ const rowNodes = keyedRows<Row, JSX.Element>(rt, (row) => props.rowKey(row), rowNode);
214
+
173
215
  const body = (): JSX.Element => {
174
216
  if (branch().kind === 'pending') {
175
217
  // `Array.from({ length: NaN })` is `[]`, so a busy tbody with no placeholders in it is the
@@ -192,23 +234,16 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
192
234
  </tr>
193
235
  ));
194
236
  }
195
- const more = moreColumn(props.columns);
196
- return props.rows.map((row) => (
197
- <tr role={TABLE_ROLES.row} class={styles['row']} data-row={props.rowKey(row)}>
198
- {props.columns.map((column) => (
199
- <td
200
- role={TABLE_ROLES.cell}
201
- class={cx(styles['cell'], column.numeric === true && styles['numeric'])}
202
- data-priority={priorityOf(column)}
203
- >
204
- {cellContent(column, row)}
205
- </td>
206
- ))}
207
- {more === undefined ? null : disclosure(more, row)}
208
- </tr>
209
- ));
237
+ return rowNodes(props.rows);
210
238
  };
211
239
 
240
+ /** No cursor either way and no way back is a single page: no pager at all. */
241
+ const hasPager = (): boolean =>
242
+ props.nextCursor != null ||
243
+ props.prevCursor != null ||
244
+ props.firstHref !== undefined ||
245
+ props.onFirst !== undefined;
246
+
212
247
  /** One mode per use, exactly as `Pagination` declares it — the table only hands it through. */
213
248
  const pager = (): JSX.Element =>
214
249
  props.hrefFor === undefined ? (
@@ -216,12 +251,14 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
216
251
  nextCursor={props.nextCursor}
217
252
  prevCursor={props.prevCursor}
218
253
  onCursor={props.onCursor}
254
+ onFirst={props.onFirst}
219
255
  />
220
256
  ) : (
221
257
  <Pagination
222
258
  nextCursor={props.nextCursor}
223
259
  prevCursor={props.prevCursor}
224
260
  hrefFor={props.hrefFor}
261
+ firstHref={props.firstHref}
225
262
  />
226
263
  );
227
264
 
@@ -314,7 +351,13 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
314
351
  );
315
352
  };
316
353
 
317
- const table = (decided: AsyncBranch<readonly Row[]>): JSX.Element => [
354
+ /** Read inside the attributes, so a refresh restyles the tbody instead of rebuilding it. */
355
+ const stale = (): boolean => {
356
+ const decided = branch();
357
+ return decided.kind === 'ready' && decided.busy;
358
+ };
359
+
360
+ const table = (): JSX.Element => (
318
361
  <Table
319
362
  caption={props.caption}
320
363
  stickyHeader={props.stickyHeader !== false}
@@ -325,36 +368,52 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
325
368
  {header()}
326
369
  <tbody
327
370
  role={TABLE_ROLES.rowgroup}
328
- class={cx(styles['body'], decided.kind === 'ready' && decided.busy && styles['stale'])}
329
- aria-busy={ariaBool(isBusyBranch(decided))}
371
+ class={cx(styles['body'], stale() && styles['stale'])}
372
+ aria-busy={ariaBool(isBusyBranch(branch()))}
330
373
  >
331
374
  {body()}
332
375
  </tbody>
333
- </Table>,
334
- props.nextCursor === undefined && props.prevCursor === undefined ? null : pager(),
335
- ];
376
+ </Table>
377
+ );
336
378
 
337
379
  /**
338
380
  * The branch is decided INSIDE the returned JSX, as `AsyncRegion` does: a component body runs
339
381
  * once, so a decision taken there is the decision for the island's whole life — a table that
340
382
  * first rendered its error state kept it after the query recovered.
383
+ *
384
+ * And only the KIND is decided here, through a memo: a live update that stays `ready` must not
385
+ * rebuild the `<table>`, or every kept `<tr>` is detached and re-attached — and a detached node
386
+ * drops focus, which is the defect keyed rows exist to fix. The rows, the busy state and the
387
+ * pager each track the data on their own.
341
388
  */
342
- const content = (): JSX.Element => {
389
+ const view = rt.createMemo((): 'failed' | 'empty' | 'table' => {
390
+ const kind = branch().kind;
391
+ return kind === 'failed' || kind === 'empty' ? kind : 'table';
392
+ });
393
+
394
+ const failure = (): unknown => {
343
395
  const decided = branch();
344
- if (decided.kind === 'failed') {
345
- return <ErrorState error={decided.error} onRetry={props.onRetry} />;
346
- }
396
+ return decided.kind === 'failed' ? decided.error : undefined;
397
+ };
398
+
399
+ const content = (): JSX.Element => {
400
+ const kind = view();
401
+ if (kind === 'failed') return <ErrorState error={failure()} onRetry={props.onRetry} />;
347
402
  // Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two
348
403
  // ifs: "No results" under a first page in flight is the bug that ordering used to stop.
349
- if (decided.kind === 'empty') {
404
+ if (kind === 'empty') {
350
405
  return <EmptyState title={props.emptyTitle} description={props.emptyDescription} />;
351
406
  }
352
- return table(decided);
407
+ return table();
353
408
  };
354
409
 
410
+ /** Beside the table and outside its memo: a new cursor redraws the pager and nothing else. */
411
+ const pagerSlot = (): JSX.Element => (view() === 'table' && hasPager() ? pager() : null);
412
+
355
413
  return (
356
414
  <div class={cx(styles['wrap'], props.class)} data-narrow={props.narrow ?? 'cards'}>
357
415
  {content()}
416
+ {pagerSlot()}
358
417
  </div>
359
418
  );
360
419
  }
@@ -0,0 +1,37 @@
1
+ @use '../tokens' as t;
2
+
3
+ .picker {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ gap: t.space(1);
7
+ max-inline-size: 100%;
8
+ }
9
+
10
+ .field {
11
+ flex: 1;
12
+ min-inline-size: 0;
13
+ }
14
+
15
+ .open {
16
+ @include t.touch-target;
17
+ display: inline-grid;
18
+ place-items: center;
19
+ border: 1px solid t.role('line');
20
+ border-radius: t.radius(md);
21
+ color: t.role('fg');
22
+ background: t.role('surface-raised');
23
+ cursor: pointer;
24
+
25
+ &:hover {
26
+ background: t.role('bg-soft');
27
+ }
28
+
29
+ &:focus-visible {
30
+ outline: t.stroke(thick) solid t.role('accent');
31
+ outline-offset: t.stroke(thick);
32
+ }
33
+
34
+ &:disabled {
35
+ @include t.disabled;
36
+ }
37
+ }
@@ -0,0 +1,134 @@
1
+ // A date field that is a native `<input type="date">` first, always: on the server, with scripting
2
+ // off and on a coarse pointer it is all there is, and it is the value either way, so the form posts
3
+ // the same thing. A fine pointer with a script also gets a button opening a `Calendar` popover.
4
+
5
+ import type { PlainDate } from '@ultimat3/time';
6
+ import type { JSX } from 'solid-js';
7
+ import { ariaBool, useId } from '../a11y';
8
+ import { cx } from '../cx';
9
+ import { UI_KEYS } from '../i18n-keys';
10
+ import { iconCalendar } from '../icons/glyphs/calendar';
11
+ import { useUi } from '../theme/context';
12
+ import { solid } from '../theme/solid-adapter';
13
+ import { Calendar } from './Calendar';
14
+ import { assertBounds, optionalDateProp, type Weekday } from './calendar-view';
15
+ import styles from './DatePicker.module.scss';
16
+ import { enhancesWithCalendar, inputDate, opensPicker, writeInputDate } from './date-picker-view';
17
+ import { Icon } from './Icon';
18
+ import inputStyles from './Input.module.scss';
19
+ import { Popover } from './Popover';
20
+
21
+ export interface DatePickerProps {
22
+ /** The form field name: the post is the native input's, `YYYY-MM-DD`. */
23
+ name: string;
24
+ id?: string | undefined;
25
+ /** `YYYY-MM-DD`; anything else is `X_UI_INVALID_VALUE`. */
26
+ value?: string | undefined;
27
+ min?: string | undefined;
28
+ max?: string | undefined;
29
+ /** Days the calendar refuses. The native input cannot express it; validate on the server too. */
30
+ isDisabled?: ((date: PlainDate) => boolean) | undefined;
31
+ weekStart?: Weekday | undefined;
32
+ required?: boolean | undefined;
33
+ disabled?: boolean | undefined;
34
+ /** The month the calendar opens on with no value; else today's in `useUi().timeZone`. */
35
+ month?: string | undefined;
36
+ 'aria-label'?: string | undefined;
37
+ 'aria-describedby'?: string | undefined;
38
+ 'aria-invalid'?: boolean | undefined;
39
+ onChange?: ((date: PlainDate | undefined) => void) | undefined;
40
+ class?: string | undefined;
41
+ }
42
+
43
+ export function DatePicker(props: DatePickerProps): JSX.Element {
44
+ const rt = solid();
45
+ const ui = useUi();
46
+ const fallbackId = useId('date');
47
+ assertBounds('DatePicker', {
48
+ min: optionalDateProp('DatePicker min', props.min),
49
+ max: optionalDateProp('DatePicker max', props.max),
50
+ });
51
+ const [enhanced, setEnhanced] = rt.createSignal(false);
52
+ const [open, setOpen] = rt.createSignal(false);
53
+ const [current, setCurrent] = rt.createSignal<PlainDate | undefined>(
54
+ optionalDateProp('DatePicker value', props.value),
55
+ );
56
+ let input: HTMLInputElement | undefined;
57
+
58
+ // After mount, never during render: the first client render equals the server's input.
59
+ rt.createEffect(() => {
60
+ setEnhanced(enhancesWithCalendar(typeof matchMedia === 'function' ? matchMedia : undefined));
61
+ });
62
+
63
+ const pick = (date: PlainDate): void => {
64
+ if (input !== undefined) writeInputDate(input, date);
65
+ setCurrent(date);
66
+ setOpen(false);
67
+ };
68
+
69
+ return (
70
+ <span class={cx(styles['picker'], props.class)}>
71
+ <span class={cx(inputStyles['wrap'], inputStyles['size-md'], styles['field'])}>
72
+ <input
73
+ ref={(el: HTMLInputElement) => {
74
+ input = el;
75
+ }}
76
+ class={inputStyles['input']}
77
+ type="date"
78
+ id={props.id ?? fallbackId}
79
+ name={props.name}
80
+ value={props.value ?? ''}
81
+ min={props.min}
82
+ max={props.max}
83
+ required={props.required === true}
84
+ disabled={props.disabled === true}
85
+ aria-label={props['aria-label']}
86
+ aria-describedby={props['aria-describedby']}
87
+ aria-invalid={ariaBool(props['aria-invalid'])}
88
+ onKeyDown={(event: KeyboardEvent) => {
89
+ if (!enhanced() || !opensPicker(event)) return;
90
+ event.preventDefault();
91
+ setOpen(true);
92
+ }}
93
+ onChange={(event: Event) => {
94
+ const date = inputDate((event.currentTarget as HTMLInputElement).value);
95
+ setCurrent(date);
96
+ props.onChange?.(date);
97
+ }}
98
+ />
99
+ </span>
100
+ {enhanced() ? (
101
+ <Popover
102
+ open={open()}
103
+ onOpenChange={setOpen}
104
+ label={ui.t(UI_KEYS.dateOpen)}
105
+ trigger={(control) => (
106
+ <button
107
+ type="button"
108
+ class={styles['open']}
109
+ id={control.id}
110
+ aria-expanded={ariaBool(control['aria-expanded'])}
111
+ aria-controls={control['aria-controls']}
112
+ aria-haspopup="dialog"
113
+ aria-label={ui.t(UI_KEYS.dateOpen)}
114
+ disabled={props.disabled === true}
115
+ onClick={() => setOpen(!open())}
116
+ >
117
+ <Icon glyph={iconCalendar} size="sm" />
118
+ </button>
119
+ )}
120
+ >
121
+ <Calendar
122
+ value={current()}
123
+ month={current() ?? props.month ?? props.min}
124
+ min={props.min}
125
+ max={props.max}
126
+ isDisabled={props.isDisabled}
127
+ weekStart={props.weekStart}
128
+ onSelect={pick}
129
+ />
130
+ </Popover>
131
+ ) : null}
132
+ </span>
133
+ );
134
+ }
@@ -0,0 +1,54 @@
1
+ @use '../tokens' as t;
2
+
3
+ .range {
4
+ margin: 0;
5
+ padding: 0;
6
+ border: 0;
7
+ min-inline-size: 0;
8
+ }
9
+
10
+ .legend {
11
+ margin-block-end: t.space(2);
12
+ color: t.role('fg-strong');
13
+ font-size: t.text(sm);
14
+ font-weight: t.weight(medium);
15
+ }
16
+
17
+ // Two inputs side by side, wrapping onto two lines when the row cannot hold them.
18
+ .ends {
19
+ display: flex;
20
+ flex-wrap: wrap;
21
+ align-items: flex-end;
22
+ gap: t.space(3);
23
+ }
24
+
25
+ .end {
26
+ display: grid;
27
+ gap: t.space(1);
28
+ min-inline-size: 0;
29
+ }
30
+
31
+ .label {
32
+ color: t.role('fg-muted');
33
+ font-size: t.text(xs);
34
+ }
35
+
36
+ .open {
37
+ @include t.touch-target;
38
+ display: inline-grid;
39
+ place-items: center;
40
+ border: 1px solid t.role('line');
41
+ border-radius: t.radius(md);
42
+ color: t.role('fg');
43
+ background: t.role('surface-raised');
44
+ cursor: pointer;
45
+
46
+ &:hover {
47
+ background: t.role('bg-soft');
48
+ }
49
+
50
+ &:focus-visible {
51
+ outline: t.stroke(thick) solid t.role('accent');
52
+ outline-offset: t.stroke(thick);
53
+ }
54
+ }
@@ -0,0 +1,156 @@
1
+ // A date range as two native date inputs — `fromName` and `toName` — so a GET filter or a POST
2
+ // works with scripting off and on a phone. A fine pointer with a script also gets one calendar:
3
+ // the first pick sets the start, the second completes the range. Rules: `date-range-view.ts`.
4
+
5
+ import type { PlainDate } from '@ultimat3/time';
6
+ import type { JSX } from 'solid-js';
7
+ import { ariaBool, useId } from '../a11y';
8
+ import { cx } from '../cx';
9
+ import { UI_KEYS } from '../i18n-keys';
10
+ import { iconCalendarRange } from '../icons/glyphs/calendar-range';
11
+ import { useUi } from '../theme/context';
12
+ import { solid } from '../theme/solid-adapter';
13
+ import { Calendar } from './Calendar';
14
+ import { assertBounds, optionalDateProp, type Weekday } from './calendar-view';
15
+ import styles from './DateRangePicker.module.scss';
16
+ import { enhancesWithCalendar, inputDate, opensPicker, writeInputDate } from './date-picker-view';
17
+ import { type DateRange, orderedRange, pickRange } from './date-range-view';
18
+ import { Icon } from './Icon';
19
+ import inputStyles from './Input.module.scss';
20
+ import { Popover } from './Popover';
21
+
22
+ export interface DateRangePickerProps {
23
+ /** Already-translated name for the pair, rendered as the `<legend>`. */
24
+ legend: string;
25
+ fromName: string;
26
+ toName: string;
27
+ /** `YYYY-MM-DD` each; anything else is `X_UI_INVALID_VALUE`. */
28
+ from?: string | undefined;
29
+ to?: string | undefined;
30
+ min?: string | undefined;
31
+ max?: string | undefined;
32
+ isDisabled?: ((date: PlainDate) => boolean) | undefined;
33
+ weekStart?: Weekday | undefined;
34
+ /** The month the calendar opens on with no `from`; else today's in `useUi().timeZone`. */
35
+ month?: string | undefined;
36
+ /** Called with the range in order whenever either end changes. */
37
+ onChange?: ((range: DateRange) => void) | undefined;
38
+ class?: string | undefined;
39
+ }
40
+
41
+ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
42
+ const rt = solid();
43
+ const ui = useUi();
44
+ const id = useId('range');
45
+ assertBounds('DateRangePicker', {
46
+ min: optionalDateProp('DateRangePicker min', props.min),
47
+ max: optionalDateProp('DateRangePicker max', props.max),
48
+ });
49
+ const [enhanced, setEnhanced] = rt.createSignal(false);
50
+ const [open, setOpen] = rt.createSignal(false);
51
+ const [range, setRange] = rt.createSignal<DateRange>(
52
+ orderedRange({
53
+ from: optionalDateProp('DateRangePicker from', props.from),
54
+ to: optionalDateProp('DateRangePicker to', props.to),
55
+ }),
56
+ );
57
+ let fromInput: HTMLInputElement | undefined;
58
+ let toInput: HTMLInputElement | undefined;
59
+
60
+ rt.createEffect(() => {
61
+ setEnhanced(enhancesWithCalendar(typeof matchMedia === 'function' ? matchMedia : undefined));
62
+ });
63
+
64
+ const commit = (next: DateRange): void => {
65
+ setRange(next);
66
+ props.onChange?.(next);
67
+ };
68
+
69
+ const pick = (date: PlainDate): void => {
70
+ const next = pickRange(range(), date);
71
+ if (fromInput !== undefined && next.range.from !== undefined) {
72
+ writeInputDate(fromInput, next.range.from);
73
+ }
74
+ if (toInput !== undefined) {
75
+ if (next.range.to === undefined) toInput.value = '';
76
+ else writeInputDate(toInput, next.range.to);
77
+ }
78
+ commit(next.range);
79
+ if (next.complete) setOpen(false);
80
+ };
81
+
82
+ const onKeyDown = (event: KeyboardEvent): void => {
83
+ if (!enhanced() || !opensPicker(event)) return;
84
+ event.preventDefault();
85
+ setOpen(true);
86
+ };
87
+
88
+ const field = (end: 'from' | 'to'): JSX.Element => (
89
+ <span class={styles['end']}>
90
+ <label class={styles['label']} for={`${id}-${end}`}>
91
+ {ui.t(end === 'from' ? UI_KEYS.rangeFrom : UI_KEYS.rangeTo)}
92
+ </label>
93
+ <span class={cx(inputStyles['wrap'], inputStyles['size-md'])}>
94
+ <input
95
+ ref={(el: HTMLInputElement) => {
96
+ if (end === 'from') fromInput = el;
97
+ else toInput = el;
98
+ }}
99
+ class={inputStyles['input']}
100
+ type="date"
101
+ id={`${id}-${end}`}
102
+ name={end === 'from' ? props.fromName : props.toName}
103
+ value={(end === 'from' ? props.from : props.to) ?? ''}
104
+ min={props.min}
105
+ max={props.max}
106
+ onKeyDown={onKeyDown}
107
+ onChange={(event: Event) => {
108
+ const date = inputDate((event.currentTarget as HTMLInputElement).value);
109
+ commit(orderedRange({ ...range(), [end]: date }));
110
+ }}
111
+ />
112
+ </span>
113
+ </span>
114
+ );
115
+
116
+ return (
117
+ <fieldset class={cx(styles['range'], props.class)}>
118
+ <legend class={styles['legend']}>{props.legend}</legend>
119
+ <div class={styles['ends']}>
120
+ {field('from')}
121
+ {field('to')}
122
+ {enhanced() ? (
123
+ <Popover
124
+ open={open()}
125
+ onOpenChange={setOpen}
126
+ label={ui.t(UI_KEYS.rangeOpen)}
127
+ trigger={(control) => (
128
+ <button
129
+ type="button"
130
+ class={styles['open']}
131
+ id={control.id}
132
+ aria-expanded={ariaBool(control['aria-expanded'])}
133
+ aria-controls={control['aria-controls']}
134
+ aria-haspopup="dialog"
135
+ aria-label={ui.t(UI_KEYS.rangeOpen)}
136
+ onClick={() => setOpen(!open())}
137
+ >
138
+ <Icon glyph={iconCalendarRange} size="sm" />
139
+ </button>
140
+ )}
141
+ >
142
+ <Calendar
143
+ range={range()}
144
+ month={range().from ?? props.month ?? props.min}
145
+ min={props.min}
146
+ max={props.max}
147
+ isDisabled={props.isDisabled}
148
+ weekStart={props.weekStart}
149
+ onSelect={pick}
150
+ />
151
+ </Popover>
152
+ ) : null}
153
+ </div>
154
+ </fieldset>
155
+ );
156
+ }
@@ -0,0 +1,46 @@
1
+ @use 'sass:map';
2
+ @use '../tokens' as t;
3
+
4
+ // The list's own width decides whether a term sits beside its detail: the same facts are a column
5
+ // in a sidebar and two columns in a wide main.
6
+ $sm: map.get(t.$breakpoints, 'sm');
7
+
8
+ .wrap {
9
+ @include t.container(descriptionlist);
10
+ }
11
+
12
+ .list {
13
+ display: grid;
14
+ gap: t.space(3);
15
+ margin: 0;
16
+ }
17
+
18
+ .group {
19
+ display: grid;
20
+ gap: t.space(1);
21
+ }
22
+
23
+ .layout-columns .group {
24
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
25
+ gap: t.space(4);
26
+ align-items: baseline;
27
+ }
28
+
29
+ @container descriptionlist (width < #{$sm}) {
30
+ .layout-columns .group {
31
+ grid-template-columns: minmax(0, 1fr);
32
+ gap: t.space(1);
33
+ }
34
+ }
35
+
36
+ .term {
37
+ color: t.role('fg-muted');
38
+ font-size: t.text(sm);
39
+ font-weight: t.weight(medium);
40
+ }
41
+
42
+ .detail {
43
+ margin: 0;
44
+ color: t.role('fg');
45
+ overflow-wrap: anywhere;
46
+ }