@ultimat3/ui 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CATALOG.md +491 -24
- package/CLAUDE.md +23 -16
- package/README.md +338 -33
- package/package.json +16 -5
- package/src/components/AppShell.module.scss +23 -7
- package/src/components/AppShell.tsx +9 -0
- package/src/components/BarChart.module.scss +26 -0
- package/src/components/BarChart.tsx +242 -93
- package/src/components/Board.module.scss +30 -0
- package/src/components/Board.tsx +47 -0
- package/src/components/BottomNav.module.scss +49 -0
- package/src/components/BottomNav.tsx +57 -0
- package/src/components/Calendar.module.scss +118 -0
- package/src/components/Calendar.tsx +246 -0
- package/src/components/ChartFrame.module.scss +40 -3
- package/src/components/ChartFrame.tsx +84 -28
- package/src/components/CodeBlock.module.scss +101 -0
- package/src/components/CodeBlock.tsx +76 -0
- package/src/components/CommandPalette.module.scss +25 -0
- package/src/components/CommandPalette.tsx +50 -22
- package/src/components/ConfirmDialog.module.scss +36 -0
- package/src/components/ConfirmDialog.tsx +188 -0
- package/src/components/DataTable.tsx +92 -33
- package/src/components/DatePicker.module.scss +37 -0
- package/src/components/DatePicker.tsx +134 -0
- package/src/components/DateRangePicker.module.scss +54 -0
- package/src/components/DateRangePicker.tsx +156 -0
- package/src/components/DescriptionList.module.scss +46 -0
- package/src/components/DescriptionList.tsx +38 -0
- package/src/components/DiffView.module.scss +124 -0
- package/src/components/DiffView.tsx +169 -0
- package/src/components/DonutChart.tsx +13 -4
- package/src/components/Drawer.module.scss +40 -0
- package/src/components/Drawer.tsx +56 -0
- package/src/components/Gauge.module.scss +8 -0
- package/src/components/Gauge.tsx +40 -13
- package/src/components/HeatStrip.module.scss +133 -0
- package/src/components/HeatStrip.tsx +64 -0
- package/src/components/JsonView.module.scss +64 -0
- package/src/components/JsonView.tsx +68 -0
- package/src/components/LineChart.tsx +45 -18
- package/src/components/LiveIndicator.module.scss +44 -0
- package/src/components/LiveIndicator.tsx +45 -0
- package/src/components/LogViewer.module.scss +102 -0
- package/src/components/LogViewer.tsx +123 -0
- package/src/components/Markdown.module.scss +87 -0
- package/src/components/Markdown.tsx +84 -0
- package/src/components/Meter.module.scss +7 -0
- package/src/components/Meter.tsx +16 -0
- package/src/components/NavList.module.scss +66 -0
- package/src/components/NavList.tsx +95 -0
- package/src/components/Pagination.module.scss +6 -2
- package/src/components/Pagination.tsx +57 -13
- package/src/components/Popover.tsx +12 -2
- package/src/components/PullToRefresh.module.scss +41 -0
- package/src/components/PullToRefresh.tsx +108 -0
- package/src/components/Radio.module.scss +51 -0
- package/src/components/Radio.tsx +12 -1
- package/src/components/SearchableSelect.module.scss +216 -0
- package/src/components/SearchableSelect.tsx +331 -0
- package/src/components/SearchableSelectChips.tsx +41 -0
- package/src/components/SearchableSelectNative.tsx +89 -0
- package/src/components/ShortcutSheet.module.scss +33 -0
- package/src/components/ShortcutSheet.tsx +67 -0
- package/src/components/Slider.module.scss +50 -0
- package/src/components/Slider.tsx +68 -0
- package/src/components/SortableList.module.scss +72 -0
- package/src/components/SortableList.tsx +42 -0
- package/src/components/Sparkline.module.scss +10 -2
- package/src/components/Sparkline.tsx +27 -10
- package/src/components/SplitPane.module.scss +39 -0
- package/src/components/SplitPane.tsx +96 -0
- package/src/components/StatTile.module.scss +29 -3
- package/src/components/StatTile.tsx +75 -16
- package/src/components/Stepper.module.scss +78 -0
- package/src/components/Stepper.tsx +73 -0
- package/src/components/Tabs.module.scss +13 -1
- package/src/components/Tabs.tsx +77 -1
- package/src/components/TerminalView.module.scss +68 -0
- package/src/components/TerminalView.tsx +97 -0
- package/src/components/TimeRangePicker.module.scss +85 -0
- package/src/components/TimeRangePicker.tsx +91 -0
- package/src/components/Timeline.module.scss +74 -0
- package/src/components/Timeline.tsx +60 -0
- package/src/components/Tree.module.scss +98 -0
- package/src/components/Tree.tsx +185 -0
- package/src/components/VirtualList.module.scss +60 -0
- package/src/components/VirtualList.tsx +251 -0
- package/src/components/app-shell-view.ts +4 -1
- package/src/components/bar-chart-view.ts +121 -39
- package/src/components/board-view.ts +25 -0
- package/src/components/bottom-nav-view.ts +18 -0
- package/src/components/calendar-view.ts +226 -0
- package/src/components/chart-checks.ts +59 -0
- package/src/components/chart-frame-view.ts +33 -2
- package/src/components/chart-reference-view.ts +41 -0
- package/src/components/command-palette-view.ts +47 -6
- package/src/components/confirm-dialog-view.ts +47 -0
- package/src/components/data-table-rows.ts +93 -0
- package/src/components/date-picker-view.ts +38 -0
- package/src/components/date-range-view.ts +46 -0
- package/src/components/diff-view-view.ts +85 -0
- package/src/components/drawer-gesture-view.ts +33 -0
- package/src/components/gauge-view.ts +29 -0
- package/src/components/heat-strip-view.ts +68 -0
- package/src/components/json-view-view.ts +54 -0
- package/src/components/line-chart-view.ts +87 -25
- package/src/components/log-viewer-view.ts +51 -0
- package/src/components/markdown-nodes.tsx +150 -0
- package/src/components/nav-list-view.ts +37 -0
- package/src/components/pull-refresh-view.ts +93 -0
- package/src/components/rank-options.ts +61 -0
- package/src/components/searchable-select-keys.ts +118 -0
- package/src/components/searchable-select-view.ts +187 -0
- package/src/components/slider-view.ts +47 -0
- package/src/components/sort-pointer.ts +141 -0
- package/src/components/sort-surface.tsx +231 -0
- package/src/components/sparkline-view.ts +74 -14
- package/src/components/split-pane-view.ts +69 -0
- package/src/components/stat-delta.ts +54 -11
- package/src/components/stepper-view.ts +38 -0
- package/src/components/thunk.ts +9 -0
- package/src/components/timeline-view.ts +29 -0
- package/src/components/tree-node.tsx +133 -0
- package/src/components/tree-view.ts +185 -0
- package/src/components/use-command-palette.ts +153 -0
- package/src/components/virtual-list-anchor.ts +35 -0
- package/src/components/virtual-list-observer.ts +58 -0
- package/src/components/virtual-list-view.ts +145 -0
- package/src/content/ansi.ts +144 -0
- package/src/content/heading-slug.ts +32 -0
- package/src/content/highlight.ts +129 -0
- package/src/content/languages/all.ts +21 -0
- package/src/content/languages/diff.ts +14 -0
- package/src/content/languages/html.ts +16 -0
- package/src/content/languages/json.ts +15 -0
- package/src/content/languages/shell.ts +20 -0
- package/src/content/languages/sql.ts +27 -0
- package/src/content/languages/ts.ts +26 -0
- package/src/content/languages/yaml.ts +18 -0
- package/src/content/line-diff.ts +191 -0
- package/src/content/markdown-inline.ts +368 -0
- package/src/content/markdown-parse.ts +267 -0
- package/src/content/markdown-tree.ts +58 -0
- package/src/content/unified-patch.ts +53 -0
- package/src/drag/pointer-drag.ts +185 -0
- package/src/drag/sort-controller.ts +196 -0
- package/src/drag/sort-view.ts +201 -0
- package/src/errors.ts +1 -1
- package/src/form/field-path.ts +7 -33
- package/src/form/form-binding.ts +5 -8
- package/src/form/form-issue.ts +30 -36
- package/src/form/form-values.ts +3 -2
- package/src/hotkeys-view.ts +150 -0
- package/src/i18n-keys.ts +7 -50
- package/src/index.ts +214 -15
- package/src/list-url.ts +123 -0
- package/src/live-window.ts +23 -0
- package/src/roving.ts +2 -0
- package/src/theme/inert-runtime.ts +4 -0
- package/src/theme/runtime-slot.ts +2 -2
- package/src/theme/solid-adapter.ts +11 -5
- package/src/time-range-url.ts +200 -0
- package/src/tokens/_mixins.scss +1 -1
- package/src/tokens/_touch.scss +30 -1
- package/src/tokens/contrast-pairs.ts +12 -0
- package/src/ui-keys.ts +126 -0
- 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
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'],
|
|
329
|
-
aria-busy={ariaBool(isBusyBranch(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
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 (
|
|
404
|
+
if (kind === 'empty') {
|
|
350
405
|
return <EmptyState title={props.emptyTitle} description={props.emptyDescription} />;
|
|
351
406
|
}
|
|
352
|
-
return table(
|
|
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
|
+
}
|