@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
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
.tab {
|
|
41
|
+
display: inline-block;
|
|
41
42
|
@include t.focus-ring('accent', t.stroke(thick), calc(-1 * #{t.stroke(thick)}));
|
|
42
43
|
@include t.transition(color, box-shadow, background);
|
|
43
44
|
padding-block: t.space(2);
|
|
@@ -57,7 +58,11 @@
|
|
|
57
58
|
|
|
58
59
|
@include t.disabled(0.5);
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
// Link mode: a link is never disabled, and the current page reads as the selected tab.
|
|
62
|
+
text-decoration: none;
|
|
63
|
+
|
|
64
|
+
&[aria-selected='true'],
|
|
65
|
+
&[aria-current='page'] {
|
|
61
66
|
color: t.role('accent');
|
|
62
67
|
// Inset shadow instead of a border, so the tab does not shift on select.
|
|
63
68
|
box-shadow: inset 0 calc(-1 * #{t.stroke(thick)}) 0 0 t.role('accent');
|
|
@@ -72,3 +77,10 @@
|
|
|
72
77
|
outline-offset: t.stroke(thick);
|
|
73
78
|
}
|
|
74
79
|
}
|
|
80
|
+
|
|
81
|
+
// Link mode's `<ul>`: the list look without the bullets, scrolling sideways in its own box.
|
|
82
|
+
.links {
|
|
83
|
+
margin: 0;
|
|
84
|
+
padding: 0;
|
|
85
|
+
list-style: none;
|
|
86
|
+
}
|
package/src/components/Tabs.tsx
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// Tablist with roving tabindex: one Tab in the page tab order, arrows move
|
|
2
2
|
// between them, Home/End jump to the ends, and arrow direction follows `dir`.
|
|
3
|
+
// Link mode (`hrefFor`, no `onChange`) is a `<nav>` of links with `aria-current`, never
|
|
4
|
+
// `role="tab"`: ARIA tabs promise panels on this page, and a link tab's panel is the next page.
|
|
3
5
|
|
|
4
6
|
import type { JSX } from 'solid-js';
|
|
5
7
|
import { ariaBool, rovingTabindex, useId } from '../a11y';
|
|
6
8
|
import { cx } from '../cx';
|
|
9
|
+
import { invalidValueError } from '../errors';
|
|
7
10
|
import { TAB_SELECTOR, tabStopIndex } from '../roving';
|
|
8
11
|
import { useUi } from '../theme/context';
|
|
12
|
+
import { solid } from '../theme/solid-adapter';
|
|
13
|
+
import { linkTarget } from './link-target';
|
|
9
14
|
import styles from './Tabs.module.scss';
|
|
10
15
|
|
|
11
16
|
export interface TabItem {
|
|
@@ -16,7 +21,7 @@ export interface TabItem {
|
|
|
16
21
|
disabled?: boolean | undefined;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
export interface
|
|
24
|
+
export interface TabsButtonProps {
|
|
20
25
|
items: readonly TabItem[];
|
|
21
26
|
/** Selected tab id. Controlled — the caller owns the state. */
|
|
22
27
|
value: string;
|
|
@@ -24,10 +29,81 @@ export interface TabsProps {
|
|
|
24
29
|
/** Already-translated accessible name for the tablist. */
|
|
25
30
|
label: string;
|
|
26
31
|
orientation?: 'horizontal' | 'vertical' | undefined;
|
|
32
|
+
hrefFor?: undefined;
|
|
33
|
+
children?: undefined;
|
|
27
34
|
class?: string | undefined;
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/** A tab that is a URL: the page the link opens renders its own content. */
|
|
38
|
+
export interface TabLinkItem {
|
|
39
|
+
id: string;
|
|
40
|
+
/** Already-translated tab label. */
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TabsLinkProps {
|
|
45
|
+
items: readonly TabLinkItem[];
|
|
46
|
+
/** The current tab's id — the page being rendered. */
|
|
47
|
+
value: string;
|
|
48
|
+
/** Each tab's URL. Server-rendered links: no script, and the back button works. */
|
|
49
|
+
hrefFor: (id: string) => string;
|
|
50
|
+
/** The `<nav>`'s accessible name. */
|
|
51
|
+
label: string;
|
|
52
|
+
/** The current tab's content, rendered under the links. */
|
|
53
|
+
children?: JSX.Element | undefined;
|
|
54
|
+
onChange?: undefined;
|
|
55
|
+
orientation?: undefined;
|
|
56
|
+
class?: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type TabsProps = TabsButtonProps | TabsLinkProps;
|
|
60
|
+
|
|
30
61
|
export function Tabs(props: TabsProps): JSX.Element {
|
|
62
|
+
// A mode is fixed for a use, so the branch is decided once.
|
|
63
|
+
if (props.hrefFor !== undefined) return LinkTabs(props);
|
|
64
|
+
return ButtonTabs(props);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function LinkTabs(props: TabsLinkProps): JSX.Element {
|
|
68
|
+
const rt = solid();
|
|
69
|
+
let current: HTMLAnchorElement | undefined;
|
|
70
|
+
// A strip wider than a phone scrolls in its own box; the current tab is brought into it.
|
|
71
|
+
rt.createEffect(() => {
|
|
72
|
+
if (current !== undefined && typeof current.scrollIntoView === 'function') {
|
|
73
|
+
current.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const href = (id: string): string | undefined => {
|
|
77
|
+
const value: unknown = props.hrefFor(id);
|
|
78
|
+
if (typeof value !== 'string') throw invalidValueError('Tabs hrefFor', value, 'a string URL');
|
|
79
|
+
return linkTarget(value).href;
|
|
80
|
+
};
|
|
81
|
+
return (
|
|
82
|
+
<div class={cx(styles['tabs'], styles['orientation-horizontal'], props.class)}>
|
|
83
|
+
<nav aria-label={props.label}>
|
|
84
|
+
<ul class={cx(styles['list'], styles['links'])}>
|
|
85
|
+
{props.items.map((item) => (
|
|
86
|
+
<li>
|
|
87
|
+
<a
|
|
88
|
+
ref={(el: HTMLAnchorElement) => {
|
|
89
|
+
if (item.id === props.value) current = el;
|
|
90
|
+
}}
|
|
91
|
+
class={styles['tab']}
|
|
92
|
+
href={href(item.id)}
|
|
93
|
+
aria-current={item.id === props.value ? 'page' : undefined}
|
|
94
|
+
>
|
|
95
|
+
{item.label}
|
|
96
|
+
</a>
|
|
97
|
+
</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
</nav>
|
|
101
|
+
{props.children === undefined ? null : <div class={styles['panel']}>{props.children}</div>}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ButtonTabs(props: TabsButtonProps): JSX.Element {
|
|
31
107
|
const ui = useUi();
|
|
32
108
|
const base = useId('tabs');
|
|
33
109
|
let list: HTMLDivElement | undefined;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
// Colours are roles (`ansi.ts`): a theme recolours output, and 256/truecolor never reach here.
|
|
4
|
+
.terminal {
|
|
5
|
+
@include t.focus-ring;
|
|
6
|
+
border-radius: t.radius(md);
|
|
7
|
+
background: t.role('bg-soft');
|
|
8
|
+
color: t.role('fg');
|
|
9
|
+
font-family: var(--font-mono);
|
|
10
|
+
font-size: t.text(sm);
|
|
11
|
+
text-align: start;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// `rows` lines tall, in the box's own line height, never past most of the viewport.
|
|
15
|
+
// VirtualList's frame fills the box, and is a grid whose one row may SHRINK: an auto row grows to
|
|
16
|
+
// every row's height, and the list then measured itself as tall as the log and drew every line.
|
|
17
|
+
.frame {
|
|
18
|
+
grid-template-rows: minmax(0, 1fr);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.box {
|
|
22
|
+
block-size: min(70vh, calc(var(--terminal-rows, 24) * 1lh));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.line {
|
|
26
|
+
display: block;
|
|
27
|
+
padding-inline: t.space(3);
|
|
28
|
+
white-space: pre-wrap;
|
|
29
|
+
overflow-wrap: anywhere;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.fg-muted {
|
|
33
|
+
color: t.role('fg-muted');
|
|
34
|
+
}
|
|
35
|
+
.fg-strong {
|
|
36
|
+
color: t.role('fg-strong');
|
|
37
|
+
}
|
|
38
|
+
.fg-danger {
|
|
39
|
+
color: t.role('danger');
|
|
40
|
+
}
|
|
41
|
+
.fg-success {
|
|
42
|
+
color: t.role('success');
|
|
43
|
+
}
|
|
44
|
+
.fg-warning {
|
|
45
|
+
color: t.role('warning');
|
|
46
|
+
}
|
|
47
|
+
.fg-info {
|
|
48
|
+
color: t.role('info');
|
|
49
|
+
}
|
|
50
|
+
.fg-chart-5 {
|
|
51
|
+
color: t.role('chart-5');
|
|
52
|
+
}
|
|
53
|
+
.fg-chart-1 {
|
|
54
|
+
color: t.role('chart-1');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.bold {
|
|
58
|
+
font-weight: t.weight(bold);
|
|
59
|
+
}
|
|
60
|
+
.dim {
|
|
61
|
+
color: t.role('fg-muted');
|
|
62
|
+
}
|
|
63
|
+
.italic {
|
|
64
|
+
font-style: italic;
|
|
65
|
+
}
|
|
66
|
+
.underline {
|
|
67
|
+
text-decoration: underline;
|
|
68
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// One styled stream of terminal output on `VirtualList`: SGR colours as roles, every other escape
|
|
2
|
+
// dropped, wrapped by default, left-to-right under `dir="rtl"`. A focusable region where Home and
|
|
3
|
+
// End jump. Output only: keystrokes are an app's island over its own transport.
|
|
4
|
+
|
|
5
|
+
import { finiteCount } from '@ultimat3/core';
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { type AnsiLine, parseAnsi } from '../content/ansi';
|
|
8
|
+
import { cx } from '../cx';
|
|
9
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
10
|
+
import { useUi } from '../theme/context';
|
|
11
|
+
import { solid } from '../theme/solid-adapter';
|
|
12
|
+
import styles from './TerminalView.module.scss';
|
|
13
|
+
import { VirtualList } from './VirtualList';
|
|
14
|
+
|
|
15
|
+
export interface TerminalViewProps {
|
|
16
|
+
/** Raw output, escapes and all. */
|
|
17
|
+
text: string;
|
|
18
|
+
/** Already-translated name of the region; defaults to `ui.code.terminal`. */
|
|
19
|
+
label?: string | undefined;
|
|
20
|
+
/** Lines tall, before it scrolls. Defaults to 24, a terminal's own. */
|
|
21
|
+
rows?: number | undefined;
|
|
22
|
+
/** Follow the end as output arrives. Defaults to true. */
|
|
23
|
+
follow?: boolean | undefined;
|
|
24
|
+
class?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Row {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly runs: AnsiLine;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function TerminalView(props: TerminalViewProps): JSX.Element {
|
|
33
|
+
const ui = useUi();
|
|
34
|
+
const rt = solid();
|
|
35
|
+
const rows = rt.createMemo((): readonly Row[] =>
|
|
36
|
+
parseAnsi(props.text).map((runs, index) => ({ id: String(index), runs })),
|
|
37
|
+
);
|
|
38
|
+
let region: HTMLDivElement | undefined;
|
|
39
|
+
const label = (): string => props.label ?? ui.t(UI_KEYS.codeTerminal);
|
|
40
|
+
|
|
41
|
+
const line = (row: Row): JSX.Element => (
|
|
42
|
+
<span class={styles['line']}>
|
|
43
|
+
{row.runs.length === 0
|
|
44
|
+
? ' '
|
|
45
|
+
: row.runs.map((run) => (
|
|
46
|
+
<span
|
|
47
|
+
class={cx(
|
|
48
|
+
run.fg !== undefined && styles[`fg-${run.fg}`],
|
|
49
|
+
run.bold === true && styles['bold'],
|
|
50
|
+
run.dim === true && styles['dim'],
|
|
51
|
+
run.italic === true && styles['italic'],
|
|
52
|
+
run.underline === true && styles['underline'],
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{run.text}
|
|
56
|
+
</span>
|
|
57
|
+
))}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
// biome-ignore lint/a11y/useSemanticElements: the focusable region whose Home and End move the list inside it
|
|
63
|
+
<div
|
|
64
|
+
class={cx(styles['terminal'], props.class)}
|
|
65
|
+
tabindex="0"
|
|
66
|
+
role="region"
|
|
67
|
+
aria-label={label()}
|
|
68
|
+
dir="ltr"
|
|
69
|
+
style={{
|
|
70
|
+
'--terminal-rows': String(finiteCount('TerminalView', 'rows', props.rows ?? 24, 1)),
|
|
71
|
+
}}
|
|
72
|
+
ref={(el: HTMLDivElement) => {
|
|
73
|
+
region = el;
|
|
74
|
+
}}
|
|
75
|
+
onKeyDown={(event: KeyboardEvent) => {
|
|
76
|
+
if (event.key !== 'Home' && event.key !== 'End') return;
|
|
77
|
+
// VirtualList's scroll box is the parent of its list.
|
|
78
|
+
const box = region?.querySelector('ul')?.parentElement;
|
|
79
|
+
if (box === null || box === undefined) return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
box.scrollTop = event.key === 'Home' ? 0 : box.scrollHeight;
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{/* The box is this component's: its height is fixed here, whatever order the sheets load in. */}
|
|
85
|
+
<div class={styles['box']}>
|
|
86
|
+
<VirtualList
|
|
87
|
+
items={rows()}
|
|
88
|
+
itemKey={(row: Row) => row.id}
|
|
89
|
+
renderItem={line}
|
|
90
|
+
follow={props.follow !== false}
|
|
91
|
+
label={label()}
|
|
92
|
+
class={styles['frame']}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.picker {
|
|
4
|
+
display: grid;
|
|
5
|
+
gap: t.space(2);
|
|
6
|
+
min-inline-size: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.invalid {
|
|
10
|
+
margin: 0;
|
|
11
|
+
color: t.role('warning');
|
|
12
|
+
font-size: t.text(sm);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The presets scroll in their own box on a narrow screen rather than wrapping into a wall.
|
|
16
|
+
.presets {
|
|
17
|
+
@include t.custom-scrollbar;
|
|
18
|
+
display: flex;
|
|
19
|
+
gap: t.space(1);
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0;
|
|
22
|
+
overflow-x: auto;
|
|
23
|
+
list-style: none;
|
|
24
|
+
overscroll-behavior-inline: contain;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.preset {
|
|
28
|
+
@include t.padding-inline(t.space(3));
|
|
29
|
+
display: inline-flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
min-block-size: t.touch(target);
|
|
32
|
+
border: 1px solid t.role('line');
|
|
33
|
+
border-radius: t.radius(pill);
|
|
34
|
+
color: t.role('fg');
|
|
35
|
+
font-size: t.text(sm);
|
|
36
|
+
text-decoration: none;
|
|
37
|
+
white-space: nowrap;
|
|
38
|
+
|
|
39
|
+
&:hover {
|
|
40
|
+
background: t.role('bg-soft');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&[aria-current='true'] {
|
|
44
|
+
border-color: t.role('accent');
|
|
45
|
+
color: t.role('accent-fg');
|
|
46
|
+
background: t.role('accent');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&:focus-visible {
|
|
50
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
51
|
+
outline-offset: t.stroke(thick);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.custom {
|
|
56
|
+
min-inline-size: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.summary {
|
|
60
|
+
min-block-size: t.touch(target);
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
color: t.role('fg');
|
|
64
|
+
font-size: t.text(sm);
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.form {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
align-items: flex-end;
|
|
72
|
+
gap: t.space(3);
|
|
73
|
+
padding-block: t.space(2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.end {
|
|
77
|
+
display: grid;
|
|
78
|
+
gap: t.space(1);
|
|
79
|
+
min-inline-size: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.label {
|
|
83
|
+
color: t.role('fg-muted');
|
|
84
|
+
font-size: t.text(xs);
|
|
85
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// "This metric over that window": the app's presets as links, and a custom window as a GET form of
|
|
2
|
+
// two `datetime-local` inputs in a `<details>`. Links and a form, so 0 B of script on a static
|
|
3
|
+
// route; the URL is the state (`timeRangeUrl`), so a window survives reload, share and paging.
|
|
4
|
+
|
|
5
|
+
import type { TimeZone } from '@ultimat3/time';
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { useId } from '../a11y';
|
|
8
|
+
import { cx } from '../cx';
|
|
9
|
+
import { invalidValueError } from '../errors';
|
|
10
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
11
|
+
import { useUi } from '../theme/context';
|
|
12
|
+
import type { TimeRangeUrl } from '../time-range-url';
|
|
13
|
+
import { Button } from './Button';
|
|
14
|
+
import inputStyles from './Input.module.scss';
|
|
15
|
+
import styles from './TimeRangePicker.module.scss';
|
|
16
|
+
|
|
17
|
+
export interface TimeRangePickerProps {
|
|
18
|
+
/** The window the URL holds, read by `timeRangeUrl` in the loader. */
|
|
19
|
+
range: TimeRangeUrl;
|
|
20
|
+
/**
|
|
21
|
+
* The zone the custom window's wall-clock times are in. Required, and it must be the one the
|
|
22
|
+
* window was read in: there is no ambient zone, and a mismatch would post a different window.
|
|
23
|
+
*/
|
|
24
|
+
timeZone: TimeZone;
|
|
25
|
+
/** Already-translated name for the control. */
|
|
26
|
+
label: string;
|
|
27
|
+
class?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function TimeRangePicker(props: TimeRangePickerProps): JSX.Element {
|
|
31
|
+
const ui = useUi();
|
|
32
|
+
const id = useId('timerange');
|
|
33
|
+
if (props.timeZone !== props.range.timeZone) {
|
|
34
|
+
throw invalidValueError(
|
|
35
|
+
'TimeRangePicker timeZone',
|
|
36
|
+
props.timeZone,
|
|
37
|
+
`the zone the window was read in (${props.range.timeZone})`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<nav class={cx(styles['picker'], props.class)} aria-label={props.label}>
|
|
43
|
+
{props.range.invalid ? (
|
|
44
|
+
<p class={styles['invalid']} role="status">
|
|
45
|
+
{ui.t(UI_KEYS.rangeInvalid)}
|
|
46
|
+
</p>
|
|
47
|
+
) : null}
|
|
48
|
+
<ul class={styles['presets']}>
|
|
49
|
+
{props.range.presets.map((preset) => (
|
|
50
|
+
<li>
|
|
51
|
+
<a
|
|
52
|
+
class={styles['preset']}
|
|
53
|
+
href={props.range.href({ preset: preset.id })}
|
|
54
|
+
aria-current={props.range.preset === preset.id ? 'true' : undefined}
|
|
55
|
+
>
|
|
56
|
+
{preset.label}
|
|
57
|
+
</a>
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</ul>
|
|
61
|
+
<details class={styles['custom']} open={props.range.preset === undefined}>
|
|
62
|
+
<summary class={styles['summary']}>{ui.t(UI_KEYS.rangeCustom)}</summary>
|
|
63
|
+
<form class={styles['form']} method="get" action={props.range.action}>
|
|
64
|
+
{props.range.hidden.map(([name, value]) => (
|
|
65
|
+
<input type="hidden" name={name} value={value} />
|
|
66
|
+
))}
|
|
67
|
+
{(['from', 'to'] as const).map((end) => (
|
|
68
|
+
<span class={styles['end']}>
|
|
69
|
+
<label class={styles['label']} for={`${id}-${end}`}>
|
|
70
|
+
{ui.t(end === 'from' ? UI_KEYS.rangeFrom : UI_KEYS.rangeTo)}
|
|
71
|
+
</label>
|
|
72
|
+
<span class={cx(inputStyles['wrap'], inputStyles['size-md'])}>
|
|
73
|
+
<input
|
|
74
|
+
class={inputStyles['input']}
|
|
75
|
+
id={`${id}-${end}`}
|
|
76
|
+
type="datetime-local"
|
|
77
|
+
name={props.range.names[end]}
|
|
78
|
+
value={props.range.local[end]}
|
|
79
|
+
required
|
|
80
|
+
/>
|
|
81
|
+
</span>
|
|
82
|
+
</span>
|
|
83
|
+
))}
|
|
84
|
+
<Button type="submit" variant="secondary">
|
|
85
|
+
{ui.t(UI_KEYS.rangeApply)}
|
|
86
|
+
</Button>
|
|
87
|
+
</form>
|
|
88
|
+
</details>
|
|
89
|
+
</nav>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
@include t.tone-classes;
|
|
4
|
+
|
|
5
|
+
.timeline {
|
|
6
|
+
display: grid;
|
|
7
|
+
gap: t.space(3);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.day {
|
|
11
|
+
margin: 0;
|
|
12
|
+
color: t.role('fg-strong');
|
|
13
|
+
font-size: t.text(sm);
|
|
14
|
+
font-weight: t.weight(semibold);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.list {
|
|
18
|
+
display: grid;
|
|
19
|
+
gap: t.space(3);
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0;
|
|
22
|
+
list-style: none;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The marker column stays at every width; only the content wraps.
|
|
26
|
+
.item {
|
|
27
|
+
position: relative;
|
|
28
|
+
display: grid;
|
|
29
|
+
grid-template-columns: t.space(6) minmax(0, 1fr);
|
|
30
|
+
gap: t.space(3);
|
|
31
|
+
align-items: start;
|
|
32
|
+
|
|
33
|
+
// The rail between markers, on the inline-start column so it mirrors under `dir="rtl"`.
|
|
34
|
+
&:not(:last-child)::before {
|
|
35
|
+
position: absolute;
|
|
36
|
+
inset-block: t.space(6) calc(-1 * #{t.space(3)});
|
|
37
|
+
inset-inline-start: calc(#{t.space(3)} - #{t.stroke(hairline)} / 2);
|
|
38
|
+
border-inline-start: t.stroke(hairline) solid t.role('line');
|
|
39
|
+
content: '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.marker {
|
|
44
|
+
display: inline-grid;
|
|
45
|
+
place-items: center;
|
|
46
|
+
inline-size: t.space(6);
|
|
47
|
+
block-size: t.space(6);
|
|
48
|
+
border: t.stroke(thick) solid var(--tone);
|
|
49
|
+
border-radius: t.radius(full);
|
|
50
|
+
color: var(--tone);
|
|
51
|
+
background: t.role('surface');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.body {
|
|
55
|
+
display: grid;
|
|
56
|
+
gap: t.space(1);
|
|
57
|
+
min-inline-size: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.title {
|
|
61
|
+
color: t.role('fg');
|
|
62
|
+
overflow-wrap: anywhere;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.when {
|
|
66
|
+
color: t.role('fg-muted');
|
|
67
|
+
font-size: t.text(xs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.detail {
|
|
71
|
+
color: t.role('fg-muted');
|
|
72
|
+
font-size: t.text(sm);
|
|
73
|
+
overflow-wrap: anywhere;
|
|
74
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What happened, in order: when, what, a marker and a tone. An `<ol>` because the order IS the
|
|
2
|
+
// content; day headers come from `groupBy`, already formatted. Server-only, no signal: an activity
|
|
3
|
+
// feed is this plus `InfiniteScroll` and `RelativeTime`, never a component of its own.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import { Icon } from './Icon';
|
|
8
|
+
import type { IconGlyph } from './icon-glyph';
|
|
9
|
+
import styles from './Timeline.module.scss';
|
|
10
|
+
import { timelineGroups } from './timeline-view';
|
|
11
|
+
import type { Tone } from './variants';
|
|
12
|
+
|
|
13
|
+
export interface TimelineItem {
|
|
14
|
+
/** Stable identity, rendered as `data-key` so a test or a live update can find the row. */
|
|
15
|
+
key: string;
|
|
16
|
+
/** The `<time>` slot: a `DateTime` or a `RelativeTime`, formatted in the caller's zone. */
|
|
17
|
+
when: JSX.Element;
|
|
18
|
+
/** What happened, already translated. */
|
|
19
|
+
title: JSX.Element;
|
|
20
|
+
detail?: JSX.Element | undefined;
|
|
21
|
+
/** The marker's colour. The word a tone stands for is the caller's: no status map ships. */
|
|
22
|
+
tone?: Tone | undefined;
|
|
23
|
+
/** Drawn in the marker; decorative, since `title` says what happened. */
|
|
24
|
+
icon?: IconGlyph | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TimelineProps {
|
|
28
|
+
items: readonly TimelineItem[];
|
|
29
|
+
/** Already-translated accessible name for the list. */
|
|
30
|
+
label: string;
|
|
31
|
+
/** A day header per run of items: answer a label already formatted in the caller's zone. */
|
|
32
|
+
groupBy?: ((item: TimelineItem) => string) | undefined;
|
|
33
|
+
class?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Timeline(props: TimelineProps): JSX.Element {
|
|
37
|
+
const row = (item: TimelineItem): JSX.Element => (
|
|
38
|
+
<li class={cx(styles['item'], styles[`tone-${item.tone ?? 'neutral'}`])} data-key={item.key}>
|
|
39
|
+
<span class={styles['marker']} aria-hidden="true">
|
|
40
|
+
{item.icon === undefined ? null : <Icon glyph={item.icon} size="sm" />}
|
|
41
|
+
</span>
|
|
42
|
+
<div class={styles['body']}>
|
|
43
|
+
<div class={styles['title']}>{item.title}</div>
|
|
44
|
+
<div class={styles['when']}>{item.when}</div>
|
|
45
|
+
{item.detail === undefined ? null : <div class={styles['detail']}>{item.detail}</div>}
|
|
46
|
+
</div>
|
|
47
|
+
</li>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div class={cx(styles['timeline'], props.class)}>
|
|
52
|
+
{timelineGroups(props.items, props.groupBy).map((group) => [
|
|
53
|
+
group.label === undefined ? null : <p class={styles['day']}>{group.label}</p>,
|
|
54
|
+
<ol class={styles['list']} aria-label={group.label ?? props.label}>
|
|
55
|
+
{group.items.map(row)}
|
|
56
|
+
</ol>,
|
|
57
|
+
])}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|