@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.
- 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
|
@@ -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
|
+
}
|
package/src/components/Meter.tsx
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
{
|
|
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'
|
|
72
|
-
|
|
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
|