@ultimat3/ui 29.4.0 → 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,118 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.calendar {
|
|
4
|
+
display: inline-grid;
|
|
5
|
+
gap: t.space(2);
|
|
6
|
+
max-inline-size: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.header {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: space-between;
|
|
13
|
+
gap: t.space(2);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.title {
|
|
17
|
+
flex: 1;
|
|
18
|
+
margin: 0;
|
|
19
|
+
color: t.role('fg-strong');
|
|
20
|
+
font-weight: t.weight(semibold);
|
|
21
|
+
text-align: center;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.nav {
|
|
25
|
+
@include t.touch-target;
|
|
26
|
+
display: inline-grid;
|
|
27
|
+
place-items: center;
|
|
28
|
+
border: 0;
|
|
29
|
+
border-radius: t.radius(md);
|
|
30
|
+
color: t.role('fg');
|
|
31
|
+
background: transparent;
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
|
|
34
|
+
&:hover {
|
|
35
|
+
background: t.role('bg-soft');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&:focus-visible {
|
|
39
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
40
|
+
outline-offset: calc(-1 * #{t.stroke(thick)});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The chevrons point the way the month moves, which is the other way under `dir="rtl"`.
|
|
45
|
+
:global([dir='rtl']) .nav {
|
|
46
|
+
transform: scaleX(-1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.grid {
|
|
50
|
+
border-collapse: collapse;
|
|
51
|
+
table-layout: fixed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.weekday {
|
|
55
|
+
padding-block-end: t.space(1);
|
|
56
|
+
color: t.role('fg-muted');
|
|
57
|
+
font-size: t.text(xs);
|
|
58
|
+
font-weight: t.weight(medium);
|
|
59
|
+
text-align: center;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.cell {
|
|
63
|
+
padding: 0;
|
|
64
|
+
text-align: center;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 44 px at 320 px wide: seven of them fit, and each is a finger's target.
|
|
68
|
+
.day,
|
|
69
|
+
.outside {
|
|
70
|
+
display: inline-grid;
|
|
71
|
+
place-items: center;
|
|
72
|
+
inline-size: t.touch(target);
|
|
73
|
+
block-size: t.touch(target);
|
|
74
|
+
border: 0;
|
|
75
|
+
border-radius: t.radius(md);
|
|
76
|
+
font-size: t.text(sm);
|
|
77
|
+
font-variant-numeric: tabular-nums;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.day {
|
|
81
|
+
color: t.role('fg');
|
|
82
|
+
background: transparent;
|
|
83
|
+
text-decoration: none;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
|
|
86
|
+
&:hover {
|
|
87
|
+
background: t.role('bg-soft');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&:focus-visible {
|
|
91
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
92
|
+
outline-offset: calc(-1 * #{t.stroke(thick)});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
&:disabled,
|
|
96
|
+
&[aria-disabled='true'] {
|
|
97
|
+
color: t.role('fg-muted');
|
|
98
|
+
text-decoration: line-through;
|
|
99
|
+
background: transparent;
|
|
100
|
+
cursor: not-allowed;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.outside {
|
|
105
|
+
color: t.role('fg-muted');
|
|
106
|
+
opacity: 0.5;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.cell[aria-selected='true'] .day,
|
|
110
|
+
.day[aria-current='date'] {
|
|
111
|
+
color: t.role('accent-fg');
|
|
112
|
+
background: t.role('accent');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.cell[data-span='inside'] .day {
|
|
116
|
+
color: t.role('accent-strong');
|
|
117
|
+
background: t.role('accent', 0.12);
|
|
118
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// A month you can pick a day from. Server-rendered and correct with no effect run: in link mode
|
|
2
|
+
// every day is a link (`hrefFor`), in select mode an APG grid with one tab stop. Inline on a
|
|
3
|
+
// booking page, and what `DatePicker`/`DateRangePicker` open. Rules: `calendar-view.ts`.
|
|
4
|
+
|
|
5
|
+
import { addPlainMonths, now, type PlainDate, plainDateIn } from '@ultimat3/time';
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { useId } from '../a11y';
|
|
8
|
+
import { cx } from '../cx';
|
|
9
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
10
|
+
import { iconChevronLeft } from '../icons/glyphs/chevron-left';
|
|
11
|
+
import { iconChevronRight } from '../icons/glyphs/chevron-right';
|
|
12
|
+
import { useUi } from '../theme/context';
|
|
13
|
+
import { solid } from '../theme/solid-adapter';
|
|
14
|
+
import styles from './Calendar.module.scss';
|
|
15
|
+
import {
|
|
16
|
+
assertBounds,
|
|
17
|
+
type CalendarDay,
|
|
18
|
+
dayLabel,
|
|
19
|
+
firstOfMonth,
|
|
20
|
+
monthGrid,
|
|
21
|
+
monthLabel,
|
|
22
|
+
optionalDateProp as optionalDate,
|
|
23
|
+
rovingDay,
|
|
24
|
+
stepDay,
|
|
25
|
+
type Weekday,
|
|
26
|
+
weekdayLabels,
|
|
27
|
+
weekStartOf,
|
|
28
|
+
} from './calendar-view';
|
|
29
|
+
import { type DateRange, spanPosition } from './date-range-view';
|
|
30
|
+
import { Icon } from './Icon';
|
|
31
|
+
|
|
32
|
+
interface CalendarBaseProps {
|
|
33
|
+
/** The chosen day, `YYYY-MM-DD`. */
|
|
34
|
+
value?: string | undefined;
|
|
35
|
+
/** A span to mark selected — what `DateRangePicker` passes. */
|
|
36
|
+
range?: DateRange | undefined;
|
|
37
|
+
/** The month shown first; defaults to `value`'s, then `min`'s, then today's in `useUi().timeZone`. */
|
|
38
|
+
month?: string | undefined;
|
|
39
|
+
min?: string | undefined;
|
|
40
|
+
max?: string | undefined;
|
|
41
|
+
isDisabled?: ((date: PlainDate) => boolean) | undefined;
|
|
42
|
+
/** Overrides the locale's week start (`Intl.Locale#getWeekInfo`). 1 is Monday, 7 Sunday. */
|
|
43
|
+
weekStart?: Weekday | undefined;
|
|
44
|
+
class?: string | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Link mode: every enabled day is a link. Works with scripting off. */
|
|
48
|
+
export interface CalendarLinkProps extends CalendarBaseProps {
|
|
49
|
+
hrefFor: (date: PlainDate) => string;
|
|
50
|
+
/** The previous and next month's links; omitted, the month does not change. */
|
|
51
|
+
monthHrefFor?: ((month: PlainDate) => string) | undefined;
|
|
52
|
+
onSelect?: undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Select mode: `role="grid"`, one tab stop, arrows and PageUp/PageDown. An island's. */
|
|
56
|
+
export interface CalendarSelectProps extends CalendarBaseProps {
|
|
57
|
+
onSelect: (date: PlainDate) => void;
|
|
58
|
+
hrefFor?: undefined;
|
|
59
|
+
monthHrefFor?: undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type CalendarProps = CalendarLinkProps | CalendarSelectProps;
|
|
63
|
+
|
|
64
|
+
export function Calendar(props: CalendarProps): JSX.Element {
|
|
65
|
+
const rt = solid();
|
|
66
|
+
const ui = useUi();
|
|
67
|
+
const titleId = useId('calendar');
|
|
68
|
+
const bounds = () => {
|
|
69
|
+
const resolved = {
|
|
70
|
+
min: optionalDate('Calendar min', props.min),
|
|
71
|
+
max: optionalDate('Calendar max', props.max),
|
|
72
|
+
isDisabled: props.isDisabled,
|
|
73
|
+
};
|
|
74
|
+
assertBounds('Calendar', resolved);
|
|
75
|
+
return resolved;
|
|
76
|
+
};
|
|
77
|
+
const value = (): PlainDate | undefined => optionalDate('Calendar value', props.value);
|
|
78
|
+
// With nothing to open on, the month of today in the context's zone — an explicit IANA zone,
|
|
79
|
+
// the request's or the provider's, never the device's.
|
|
80
|
+
const start =
|
|
81
|
+
optionalDate('Calendar month', props.month) ??
|
|
82
|
+
value() ??
|
|
83
|
+
bounds().min ??
|
|
84
|
+
plainDateIn(now(), ui.timeZone);
|
|
85
|
+
const [month, setMonth] = rt.createSignal<PlainDate>(firstOfMonth(start));
|
|
86
|
+
const [focused, setFocused] = rt.createSignal<PlainDate | undefined>(undefined);
|
|
87
|
+
const weekStart = (): Weekday => weekStartOf(ui.locale, props.weekStart);
|
|
88
|
+
const grid = (): readonly (readonly CalendarDay[])[] => monthGrid(month(), weekStart(), bounds());
|
|
89
|
+
const stop = (): PlainDate | undefined =>
|
|
90
|
+
rovingDay(grid(), [focused(), value(), props.range?.from]);
|
|
91
|
+
let table: HTMLTableElement | undefined;
|
|
92
|
+
|
|
93
|
+
const selected = (date: PlainDate): boolean =>
|
|
94
|
+
date === value() ||
|
|
95
|
+
(props.range !== undefined && spanPosition(date, props.range) !== undefined);
|
|
96
|
+
|
|
97
|
+
const moveTo = (date: PlainDate): void => {
|
|
98
|
+
if (firstOfMonth(date) !== month()) setMonth(firstOfMonth(date));
|
|
99
|
+
setFocused(date);
|
|
100
|
+
const buttons = table === undefined ? [] : [...table.querySelectorAll<HTMLElement>('button')];
|
|
101
|
+
buttons.find((button) => button.dataset['date'] === date)?.focus();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
105
|
+
const from = (event.target as HTMLElement | null)?.dataset?.['date'];
|
|
106
|
+
if (from === undefined || props.onSelect === undefined) return;
|
|
107
|
+
const next = stepDay(from as PlainDate, event.key, {
|
|
108
|
+
...bounds(),
|
|
109
|
+
dir: ui.dir,
|
|
110
|
+
weekStart: weekStart(),
|
|
111
|
+
shiftKey: event.shiftKey,
|
|
112
|
+
});
|
|
113
|
+
if (next === undefined) return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
moveTo(next);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const cell = (day: CalendarDay): JSX.Element => {
|
|
119
|
+
if (!day.inMonth) {
|
|
120
|
+
return (
|
|
121
|
+
<span class={styles['outside']} aria-hidden="true">
|
|
122
|
+
{day.day}
|
|
123
|
+
</span>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const onSelect = props.onSelect;
|
|
127
|
+
if (onSelect !== undefined) {
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
class={styles['day']}
|
|
132
|
+
data-date={day.date}
|
|
133
|
+
aria-label={dayLabel(day.date, ui.locale)}
|
|
134
|
+
tabIndex={stop() === day.date ? 0 : -1}
|
|
135
|
+
disabled={day.disabled}
|
|
136
|
+
onClick={() => {
|
|
137
|
+
setFocused(day.date);
|
|
138
|
+
onSelect(day.date);
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{day.day}
|
|
142
|
+
</button>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const hrefFor = props.hrefFor;
|
|
146
|
+
if (day.disabled || hrefFor === undefined) {
|
|
147
|
+
return (
|
|
148
|
+
<span class={styles['day']} aria-disabled="true">
|
|
149
|
+
{day.day}
|
|
150
|
+
</span>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return (
|
|
154
|
+
<a
|
|
155
|
+
class={styles['day']}
|
|
156
|
+
href={hrefFor(day.date)}
|
|
157
|
+
aria-label={dayLabel(day.date, ui.locale)}
|
|
158
|
+
aria-current={selected(day.date) ? 'date' : undefined}
|
|
159
|
+
>
|
|
160
|
+
{day.day}
|
|
161
|
+
</a>
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** A month control: a link in link mode (when the caller gave months a URL), a button otherwise. */
|
|
166
|
+
const monthControl = (step: -1 | 1): JSX.Element => {
|
|
167
|
+
const target = (): PlainDate => addPlainMonths(month(), step);
|
|
168
|
+
const label = ui.t(step === 1 ? UI_KEYS.dateNextMonth : UI_KEYS.datePreviousMonth);
|
|
169
|
+
const glyph = step === 1 ? iconChevronRight : iconChevronLeft;
|
|
170
|
+
if (props.onSelect === undefined) {
|
|
171
|
+
const monthHrefFor = props.monthHrefFor;
|
|
172
|
+
if (monthHrefFor === undefined) return null;
|
|
173
|
+
return (
|
|
174
|
+
<a class={styles['nav']} href={monthHrefFor(target())} aria-label={label}>
|
|
175
|
+
<Icon glyph={glyph} size="sm" />
|
|
176
|
+
</a>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
class={styles['nav']}
|
|
183
|
+
aria-label={label}
|
|
184
|
+
onClick={() => setMonth(target())}
|
|
185
|
+
>
|
|
186
|
+
<Icon glyph={glyph} size="sm" />
|
|
187
|
+
</button>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div class={cx(styles['calendar'], props.class)}>
|
|
193
|
+
<div class={styles['header']}>
|
|
194
|
+
{monthControl(-1)}
|
|
195
|
+
<p class={styles['title']} id={titleId} aria-live="polite">
|
|
196
|
+
{monthLabel(month(), ui.locale)}
|
|
197
|
+
</p>
|
|
198
|
+
{monthControl(1)}
|
|
199
|
+
</div>
|
|
200
|
+
<table
|
|
201
|
+
class={styles['grid']}
|
|
202
|
+
role={props.onSelect === undefined ? undefined : 'grid'}
|
|
203
|
+
aria-labelledby={titleId}
|
|
204
|
+
onKeyDown={onKeyDown}
|
|
205
|
+
ref={(el: HTMLTableElement) => {
|
|
206
|
+
table = el;
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
<thead>
|
|
210
|
+
<tr>
|
|
211
|
+
{weekdayLabels(ui.locale, weekStart()).map((weekday) => (
|
|
212
|
+
<th scope="col" abbr={weekday.long} class={styles['weekday']}>
|
|
213
|
+
{weekday.short}
|
|
214
|
+
</th>
|
|
215
|
+
))}
|
|
216
|
+
</tr>
|
|
217
|
+
</thead>
|
|
218
|
+
<tbody>
|
|
219
|
+
{grid().map((week) => (
|
|
220
|
+
<tr>
|
|
221
|
+
{week.map((day) => (
|
|
222
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-selected is set only with role="gridcell"
|
|
223
|
+
<td
|
|
224
|
+
class={styles['cell']}
|
|
225
|
+
role={props.onSelect === undefined ? undefined : 'gridcell'}
|
|
226
|
+
aria-selected={
|
|
227
|
+
props.onSelect === undefined || !day.inMonth
|
|
228
|
+
? undefined
|
|
229
|
+
: selected(day.date)
|
|
230
|
+
? 'true'
|
|
231
|
+
: 'false'
|
|
232
|
+
}
|
|
233
|
+
data-span={
|
|
234
|
+
props.range === undefined ? undefined : spanPosition(day.date, props.range)
|
|
235
|
+
}
|
|
236
|
+
>
|
|
237
|
+
{cell(day)}
|
|
238
|
+
</td>
|
|
239
|
+
))}
|
|
240
|
+
</tr>
|
|
241
|
+
))}
|
|
242
|
+
</tbody>
|
|
243
|
+
</table>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -18,6 +18,18 @@
|
|
|
18
18
|
--series: #{t.role('chart-#{$slot}')};
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
// A `tone` replaces the slot's colour — after the slots, so it wins on the same element. The
|
|
23
|
+
// series keeps its dash and marker: the tone is a meaning, never the only cue.
|
|
24
|
+
[data-tone='neutral'] {
|
|
25
|
+
--series: #{t.role('fg-strong')};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@each $tone in (accent, success, warning, danger, info) {
|
|
29
|
+
[data-tone='#{$tone}'] {
|
|
30
|
+
--series: #{t.role($tone)};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
.caption {
|
|
@@ -109,6 +121,27 @@
|
|
|
109
121
|
fill: var(--series);
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
// --- Reference rules ---------------------------------------------------------
|
|
125
|
+
.reference {
|
|
126
|
+
stroke: var(--series, #{t.role('fg-strong')});
|
|
127
|
+
stroke-width: 1.5;
|
|
128
|
+
stroke-dasharray: 6 3;
|
|
129
|
+
vector-effect: non-scaling-stroke;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.referenceLabel {
|
|
133
|
+
@include t.data-text;
|
|
134
|
+
position: absolute;
|
|
135
|
+
inset-block-start: var(--y);
|
|
136
|
+
inset-inline-end: 0;
|
|
137
|
+
translate: 0 -100%;
|
|
138
|
+
padding-inline: t.space(1);
|
|
139
|
+
color: var(--series, #{t.role('fg-strong')});
|
|
140
|
+
font-size: t.text(xs);
|
|
141
|
+
line-height: t.leading(tight);
|
|
142
|
+
pointer-events: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
112
145
|
// --- The keyboard layer ------------------------------------------------------
|
|
113
146
|
// Over the plot box, which every chart sheet makes `position: relative`. The layer lets the
|
|
114
147
|
// pointer through to nothing in particular; only the points themselves are targets.
|
|
@@ -155,21 +188,25 @@
|
|
|
155
188
|
opacity: 0;
|
|
156
189
|
transition: opacity t.duration(fast) t.easing(out);
|
|
157
190
|
|
|
191
|
+
// `:focus`, not only `:focus-visible`: a tap focuses the point without a visible-focus match,
|
|
192
|
+
// and on a phone the tap is the only way to ask for a reading.
|
|
158
193
|
.hit:hover > &,
|
|
159
|
-
.hit:focus
|
|
194
|
+
.hit:focus > & {
|
|
160
195
|
visibility: visible;
|
|
161
196
|
opacity: 1;
|
|
162
197
|
}
|
|
163
198
|
|
|
164
199
|
// A point at the edge of the plot opens its readout inward, so it never leaves the box.
|
|
200
|
+
// Anchored at the point's CENTRE, not the hit's edge: an outermost point sits `pad` inside the
|
|
201
|
+
// plot, and the hit box around it reaches past the plot's edge, so a readout from the box's edge
|
|
202
|
+
// started outside the plot (measured at 390px: 4px).
|
|
165
203
|
.hit[data-edge='start'] > & {
|
|
166
|
-
inset-inline-start: 0;
|
|
167
204
|
justify-content: flex-start;
|
|
168
205
|
}
|
|
169
206
|
|
|
170
207
|
.hit[data-edge='end'] > & {
|
|
171
208
|
inset-inline-start: auto;
|
|
172
|
-
inset-inline-end:
|
|
209
|
+
inset-inline-end: 50%;
|
|
173
210
|
justify-content: flex-end;
|
|
174
211
|
}
|
|
175
212
|
}
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import type { JSX } from 'solid-js';
|
|
7
7
|
import { cx } from '../cx';
|
|
8
|
+
import type { Accessor } from '../theme/solid-adapter';
|
|
9
|
+
import { solid } from '../theme/solid-adapter';
|
|
8
10
|
import styles from './ChartFrame.module.scss';
|
|
9
11
|
import type { ChartTable, SeriesStyle } from './chart-frame-view';
|
|
10
|
-
import { markerPath } from './chart-frame-view';
|
|
12
|
+
import { cssPercent, markerPath } from './chart-frame-view';
|
|
13
|
+
import type { ReferenceLine } from './chart-reference-view';
|
|
14
|
+
import { keyedRows } from './data-table-rows';
|
|
15
|
+
import { thunk } from './thunk';
|
|
16
|
+
import type { Tone } from './variants';
|
|
11
17
|
|
|
12
18
|
export interface ChartLegendItem {
|
|
13
19
|
/** The series' name, already translated. */
|
|
@@ -22,6 +28,8 @@ export interface ChartLegendItem {
|
|
|
22
28
|
|
|
23
29
|
/** One focusable point over the plot: where it sits, and what it reads. */
|
|
24
30
|
export interface ChartHit {
|
|
31
|
+
/** Stable across ticks (`hitIds`): the node, and the focus on it, follow the id. */
|
|
32
|
+
readonly id: string;
|
|
25
33
|
/** CSS percentages of the plot box (`cssPercent`). */
|
|
26
34
|
readonly x: string;
|
|
27
35
|
readonly y: string;
|
|
@@ -112,10 +120,16 @@ function chartTableNode(table: ChartTable): JSX.Element {
|
|
|
112
120
|
);
|
|
113
121
|
}
|
|
114
122
|
|
|
115
|
-
/** A legend key for a line series: its dash, its marker, its colour slot. */
|
|
116
|
-
export function seriesSwatch(style: SeriesStyle): JSX.Element {
|
|
123
|
+
/** A legend key for a line series: its dash, its marker, its colour slot — or its tone. */
|
|
124
|
+
export function seriesSwatch(style: SeriesStyle, tone?: Tone | undefined): JSX.Element {
|
|
117
125
|
return (
|
|
118
|
-
<svg
|
|
126
|
+
<svg
|
|
127
|
+
class={styles['key']}
|
|
128
|
+
viewBox="0 0 24 12"
|
|
129
|
+
data-series={style.slot}
|
|
130
|
+
data-tone={tone}
|
|
131
|
+
aria-hidden="true"
|
|
132
|
+
>
|
|
119
133
|
<path class={styles['keyLine']} d="M1,6 L23,6" stroke-dasharray={style.dash} />
|
|
120
134
|
<path class={styles['keyMarker']} d={markerPath(style.marker, 12, 6, 4)} />
|
|
121
135
|
</svg>
|
|
@@ -124,32 +138,74 @@ export function seriesSwatch(style: SeriesStyle): JSX.Element {
|
|
|
124
138
|
|
|
125
139
|
/**
|
|
126
140
|
* The keyboard layer: one focusable point per datum, over the plot, each reading its own value on
|
|
127
|
-
* `:hover
|
|
128
|
-
*
|
|
141
|
+
* `:hover`, focus and a tap — CSS only, so a chart that asks for it still ships no JS. DOM order is
|
|
142
|
+
* the tab order, and the caller passes them in reading order.
|
|
143
|
+
*
|
|
144
|
+
* KEYED by `ChartHit.id` (`data-table-rows.ts`'s rule): a live tick that moves a point moves its
|
|
145
|
+
* node, so a focused point keeps focus and its readout. Rebuilt per tick, focus fell to `<body>`.
|
|
129
146
|
*/
|
|
130
|
-
export function
|
|
147
|
+
export function ChartHits(props: { hits: readonly ChartHit[] }): JSX.Element {
|
|
148
|
+
const nodes = keyedRows<ChartHit, JSX.Element>(solid(), (hit) => hit.id, hitNode);
|
|
149
|
+
return <div class={styles['hits']}>{thunk(() => nodes(props.hits))}</div>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function hitNode(hit: Accessor<ChartHit>): JSX.Element {
|
|
131
153
|
return (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
<span class={styles['bubble']} data-readout="true">
|
|
148
|
-
{hit.label}
|
|
149
|
-
</span>
|
|
150
|
-
</span>
|
|
154
|
+
// A datum is an image of one value: `img` is the role that names it and nothing more.
|
|
155
|
+
<span
|
|
156
|
+
class={styles['hit']}
|
|
157
|
+
role="img"
|
|
158
|
+
tabindex={0}
|
|
159
|
+
aria-label={hit().label}
|
|
160
|
+
data-hit={hit().id}
|
|
161
|
+
data-edge={hit().edge}
|
|
162
|
+
style={{ '--x': hit().x, '--y': hit().y }}
|
|
163
|
+
>
|
|
164
|
+
{/* A zero-width anchor that centres the bubble over the point with flex, not a
|
|
165
|
+
percentage translate: that one lands a whole width off under `dir="rtl"`. */}
|
|
166
|
+
<span class={styles['readout']} aria-hidden="true">
|
|
167
|
+
<span class={styles['bubble']} data-readout="true">
|
|
168
|
+
{hit().label}
|
|
151
169
|
</span>
|
|
152
|
-
|
|
153
|
-
</
|
|
170
|
+
</span>
|
|
171
|
+
</span>
|
|
154
172
|
);
|
|
155
173
|
}
|
|
174
|
+
|
|
175
|
+
/** Reference rules inside a chart's svg: dashed in every tone, so a rule never reads as a series. */
|
|
176
|
+
export function ReferenceRules(props: {
|
|
177
|
+
lines: readonly ReferenceLine[];
|
|
178
|
+
width: number;
|
|
179
|
+
}): JSX.Element {
|
|
180
|
+
return props.lines.map((line) => (
|
|
181
|
+
<line
|
|
182
|
+
class={styles['reference']}
|
|
183
|
+
data-reference="true"
|
|
184
|
+
data-tone={line.tone}
|
|
185
|
+
x1={0}
|
|
186
|
+
x2={props.width}
|
|
187
|
+
y1={line.y}
|
|
188
|
+
y2={line.y}
|
|
189
|
+
/>
|
|
190
|
+
));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Each rule's label as HTML over the plot's inline end — text in a viewBox scales to a speck — and
|
|
195
|
+
* NOT hidden: "Target 120" is a fact the picture states and the table has no row for.
|
|
196
|
+
*/
|
|
197
|
+
export function ReferenceLabels(props: {
|
|
198
|
+
lines: readonly ReferenceLine[];
|
|
199
|
+
height: number;
|
|
200
|
+
format: (value: number) => string;
|
|
201
|
+
}): JSX.Element {
|
|
202
|
+
return props.lines.map((line) => (
|
|
203
|
+
<span
|
|
204
|
+
class={styles['referenceLabel']}
|
|
205
|
+
data-tone={line.tone}
|
|
206
|
+
style={{ '--y': cssPercent(line.y, props.height) }}
|
|
207
|
+
>
|
|
208
|
+
{`${line.label} ${props.format(line.value)}`}
|
|
209
|
+
</span>
|
|
210
|
+
));
|
|
211
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
// The code surface is `bg-soft`; every run colour is a role measured AA on it
|
|
4
|
+
// (`contrast-pairs.ts`, "a code token on the code surface"), so a theme recolours code for free.
|
|
5
|
+
.block {
|
|
6
|
+
position: relative;
|
|
7
|
+
border: t.stroke(hairline) solid t.role('line');
|
|
8
|
+
border-radius: t.radius(md);
|
|
9
|
+
background: t.role('bg-soft');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.bar {
|
|
13
|
+
@include t.row(t.space(1), center, flex-end);
|
|
14
|
+
padding-block: t.space(1);
|
|
15
|
+
padding-inline: t.space(2);
|
|
16
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Wrap and copy need script; without it the text is still there and selectable.
|
|
20
|
+
.block:not([data-enhanced='true']) .bar {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.toggle {
|
|
25
|
+
@include t.focus-ring;
|
|
26
|
+
border: 0;
|
|
27
|
+
border-radius: t.radius(sm);
|
|
28
|
+
background: transparent;
|
|
29
|
+
color: t.role('fg-muted');
|
|
30
|
+
font-size: t.text(sm);
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
min-block-size: t.touch(target);
|
|
33
|
+
|
|
34
|
+
&[aria-pressed='true'] {
|
|
35
|
+
color: t.role('accent');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.pre {
|
|
40
|
+
@include t.focus-ring;
|
|
41
|
+
margin: 0;
|
|
42
|
+
padding: t.space(3);
|
|
43
|
+
overflow-x: auto;
|
|
44
|
+
font-family: var(--font-mono);
|
|
45
|
+
font-size: t.text(sm);
|
|
46
|
+
line-height: t.leading(normal);
|
|
47
|
+
color: t.role('fg');
|
|
48
|
+
text-align: start;
|
|
49
|
+
white-space: pre;
|
|
50
|
+
tab-size: 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.block[data-wrap='true'] .pre {
|
|
54
|
+
white-space: pre-wrap;
|
|
55
|
+
overflow-wrap: anywhere;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.tok-plain {
|
|
59
|
+
color: inherit;
|
|
60
|
+
}
|
|
61
|
+
.tok-keyword {
|
|
62
|
+
color: t.role('accent-strong');
|
|
63
|
+
}
|
|
64
|
+
.tok-string {
|
|
65
|
+
color: t.role('success');
|
|
66
|
+
}
|
|
67
|
+
.tok-number {
|
|
68
|
+
color: t.role('warning');
|
|
69
|
+
}
|
|
70
|
+
.tok-comment {
|
|
71
|
+
color: t.role('fg-muted');
|
|
72
|
+
font-style: italic;
|
|
73
|
+
}
|
|
74
|
+
.tok-type {
|
|
75
|
+
color: t.role('info');
|
|
76
|
+
}
|
|
77
|
+
.tok-function {
|
|
78
|
+
color: t.role('fg-strong');
|
|
79
|
+
}
|
|
80
|
+
.tok-property {
|
|
81
|
+
color: t.role('info');
|
|
82
|
+
}
|
|
83
|
+
.tok-tag {
|
|
84
|
+
color: t.role('danger');
|
|
85
|
+
}
|
|
86
|
+
.tok-attribute {
|
|
87
|
+
color: t.role('warning');
|
|
88
|
+
}
|
|
89
|
+
.tok-operator,
|
|
90
|
+
.tok-punctuation {
|
|
91
|
+
color: t.role('fg');
|
|
92
|
+
}
|
|
93
|
+
.tok-inserted {
|
|
94
|
+
color: t.role('success');
|
|
95
|
+
}
|
|
96
|
+
.tok-deleted {
|
|
97
|
+
color: t.role('danger');
|
|
98
|
+
}
|
|
99
|
+
.tok-meta {
|
|
100
|
+
color: t.role('fg-muted');
|
|
101
|
+
}
|