@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,187 @@
|
|
|
1
|
+
// The rules of a SearchableSelect, apart from its markup: which options a query shows and in what
|
|
2
|
+
// order, where the active row moves on a key, what a pick does to the chosen values, and which
|
|
3
|
+
// option lists are refused. Pure, so the listbox and the no-script `<select>` read one answer.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { UI_ERROR_CODES, UiError } from '../errors';
|
|
7
|
+
import type { RankableOption } from './rank-options';
|
|
8
|
+
import { rankOptions } from './rank-options';
|
|
9
|
+
|
|
10
|
+
export interface SearchableOption extends RankableOption {
|
|
11
|
+
/** What the form posts. Unique within `options`. */
|
|
12
|
+
readonly value: string;
|
|
13
|
+
/** Already translated: the visible row text and, once picked, the field's text. */
|
|
14
|
+
readonly label: string;
|
|
15
|
+
/** A group's name, already translated. A group's options must be adjacent in `options`. */
|
|
16
|
+
readonly group?: string | undefined;
|
|
17
|
+
readonly disabled?: boolean | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One rendered row: the option, and the group heading it opens (`undefined` mid-group). */
|
|
21
|
+
export interface SearchableRow {
|
|
22
|
+
readonly option: SearchableOption;
|
|
23
|
+
readonly opensGroup: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SearchableView {
|
|
27
|
+
readonly rows: readonly SearchableRow[];
|
|
28
|
+
/** Matches before the cap; above `rows.length` the control says "first N of M". */
|
|
29
|
+
readonly total: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function refused(cause: string): UiError {
|
|
33
|
+
return new UiError({
|
|
34
|
+
code: UI_ERROR_CODES.invalidValue,
|
|
35
|
+
cause,
|
|
36
|
+
fix: 'options: [...loaded, ...chosen.filter((o) => !loaded.some((l) => l.value === o.value))] # <SearchableSelect>: unique values, each group adjacent, every chosen value among them',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Refuses an option list the control cannot render honestly: two options under one value (a pick
|
|
42
|
+
* could post either), a group split in two (two headings with one name), and a chosen value that
|
|
43
|
+
* names no option — under `onSearch` the caller must keep the chosen options in what it hands back.
|
|
44
|
+
*/
|
|
45
|
+
export function checkOptions(
|
|
46
|
+
options: readonly SearchableOption[],
|
|
47
|
+
chosen: readonly string[],
|
|
48
|
+
): void {
|
|
49
|
+
const values = new Set<string>();
|
|
50
|
+
const closed = new Set<string>();
|
|
51
|
+
let open: string | undefined;
|
|
52
|
+
for (const option of options) {
|
|
53
|
+
if (values.has(option.value)) {
|
|
54
|
+
throw refused(
|
|
55
|
+
`<SearchableSelect> has two options with value ${renderCauseValue(option.value)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
values.add(option.value);
|
|
59
|
+
if (option.group === open) continue;
|
|
60
|
+
if (open !== undefined) closed.add(open);
|
|
61
|
+
if (option.group !== undefined && closed.has(option.group)) {
|
|
62
|
+
throw refused(
|
|
63
|
+
`<SearchableSelect> group ${renderCauseValue(option.group)} is split: its options are not adjacent, at value ${renderCauseValue(option.value)}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
open = option.group;
|
|
67
|
+
}
|
|
68
|
+
for (const value of chosen) {
|
|
69
|
+
if (!values.has(value)) {
|
|
70
|
+
throw refused(
|
|
71
|
+
`<SearchableSelect> value ${renderCauseValue(value)} names no option in options`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The rows a query shows: ranked, capped, then regathered by group in the order groups first
|
|
79
|
+
* appear — a ranked list would otherwise open one group three times. Within a group, rank order.
|
|
80
|
+
*/
|
|
81
|
+
export function searchableView(
|
|
82
|
+
options: readonly SearchableOption[],
|
|
83
|
+
query: string,
|
|
84
|
+
limit: number,
|
|
85
|
+
): SearchableView {
|
|
86
|
+
const ranked = rankOptions(options, query, limit);
|
|
87
|
+
const order: (string | undefined)[] = [];
|
|
88
|
+
const byGroup = new Map<string | undefined, SearchableOption[]>();
|
|
89
|
+
for (const option of ranked.matches) {
|
|
90
|
+
const bucket = byGroup.get(option.group);
|
|
91
|
+
if (bucket === undefined) {
|
|
92
|
+
order.push(option.group);
|
|
93
|
+
byGroup.set(option.group, [option]);
|
|
94
|
+
} else bucket.push(option);
|
|
95
|
+
}
|
|
96
|
+
const rows = order.flatMap((group) =>
|
|
97
|
+
(byGroup.get(group) ?? []).map((option, i) => ({
|
|
98
|
+
option,
|
|
99
|
+
opensGroup: i === 0 ? group : undefined,
|
|
100
|
+
})),
|
|
101
|
+
);
|
|
102
|
+
return { rows, total: ranked.total };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Rows a PageUp / PageDown skips: about one open listbox's height. */
|
|
106
|
+
export const SEARCHABLE_PAGE = 10;
|
|
107
|
+
|
|
108
|
+
const enabled = (rows: readonly SearchableRow[], index: number): boolean =>
|
|
109
|
+
rows[index] !== undefined && rows[index]?.option.disabled !== true;
|
|
110
|
+
|
|
111
|
+
/** The first enabled row at or after `from` walking by `step`; -1 when none, never wrapping. */
|
|
112
|
+
function seek(rows: readonly SearchableRow[], from: number, step: 1 | -1): number {
|
|
113
|
+
for (let i = from; i >= 0 && i < rows.length; i += step) if (enabled(rows, i)) return i;
|
|
114
|
+
return -1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where the active row goes on `key`, over ENABLED rows only (`roving.ts`'s rule: a disabled row
|
|
119
|
+
* that can be active pins the walk). Arrows wrap; Home/End and the page keys stop at the ends.
|
|
120
|
+
* `-1` is no active row; `undefined` is a key this rule does not own.
|
|
121
|
+
*/
|
|
122
|
+
export function stepActive(
|
|
123
|
+
rows: readonly SearchableRow[],
|
|
124
|
+
active: number,
|
|
125
|
+
key: string,
|
|
126
|
+
): number | undefined {
|
|
127
|
+
const first = seek(rows, 0, 1);
|
|
128
|
+
const last = seek(rows, rows.length - 1, -1);
|
|
129
|
+
switch (key) {
|
|
130
|
+
case 'ArrowDown': {
|
|
131
|
+
const next = active < 0 ? -1 : seek(rows, active + 1, 1);
|
|
132
|
+
return next === -1 ? first : next;
|
|
133
|
+
}
|
|
134
|
+
case 'ArrowUp': {
|
|
135
|
+
const next = active < 0 ? -1 : seek(rows, active - 1, -1);
|
|
136
|
+
return next === -1 ? last : next;
|
|
137
|
+
}
|
|
138
|
+
case 'Home':
|
|
139
|
+
return first;
|
|
140
|
+
case 'End':
|
|
141
|
+
return last;
|
|
142
|
+
case 'PageDown': {
|
|
143
|
+
const to = seek(rows, Math.min(rows.length - 1, Math.max(0, active) + SEARCHABLE_PAGE), -1);
|
|
144
|
+
return to <= active ? last : to;
|
|
145
|
+
}
|
|
146
|
+
case 'PageUp': {
|
|
147
|
+
const to = seek(rows, Math.max(0, active - SEARCHABLE_PAGE), 1);
|
|
148
|
+
return to === -1 || to >= active ? first : to;
|
|
149
|
+
}
|
|
150
|
+
default:
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The row a fresh query lands on: the best enabled match, so Enter picks what the eye sees first. */
|
|
156
|
+
export function firstActive(rows: readonly SearchableRow[]): number {
|
|
157
|
+
return seek(rows, 0, 1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A pick. One value replaces; under `multiple` a chosen value is removed, a new one appended. */
|
|
161
|
+
export function pickValue(
|
|
162
|
+
chosen: readonly string[],
|
|
163
|
+
value: string,
|
|
164
|
+
multiple: boolean,
|
|
165
|
+
): readonly string[] {
|
|
166
|
+
if (!multiple) return [value];
|
|
167
|
+
return chosen.includes(value) ? chosen.filter((v) => v !== value) : [...chosen, value];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Backspace on an empty query: the newest chip goes. */
|
|
171
|
+
export function dropLast(chosen: readonly string[]): readonly string[] {
|
|
172
|
+
return chosen.slice(0, -1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The option behind each chosen value, in chosen order; a value no option names is skipped. */
|
|
176
|
+
export function chosenOptions(
|
|
177
|
+
options: readonly SearchableOption[],
|
|
178
|
+
chosen: readonly string[],
|
|
179
|
+
): readonly SearchableOption[] {
|
|
180
|
+
const byValue = new Map(options.map((option) => [option.value, option] as const));
|
|
181
|
+
return chosen.flatMap((value) => byValue.get(value) ?? []);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A row's element id: derived from the control's own id and the row's index, never its value. */
|
|
185
|
+
export function rowId(base: string, index: number): string {
|
|
186
|
+
return `${base}-row-${index}`;
|
|
187
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A slider's numbers: bounds checked, the value snapped onto the step grid and kept in range. Pure,
|
|
2
|
+
// so the refusals — a zero step, a min above max, a non-finite anything — are tested without a DOM.
|
|
3
|
+
|
|
4
|
+
import { finiteOption } from '@ultimat3/core';
|
|
5
|
+
import { invalidValueError } from '../errors';
|
|
6
|
+
|
|
7
|
+
export interface SliderNumbers {
|
|
8
|
+
readonly min: number;
|
|
9
|
+
readonly max: number;
|
|
10
|
+
readonly step: number;
|
|
11
|
+
readonly value: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Decimal places of `step`, so a snapped `0.1 * 3` reads `0.3`, not `0.30000000000000004`. */
|
|
15
|
+
const decimals = (step: number): number => {
|
|
16
|
+
const text = String(step);
|
|
17
|
+
const dot = text.indexOf('.');
|
|
18
|
+
return dot === -1 ? 0 : text.length - dot - 1;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The slider's numbers, validated. `step` of zero or below, `min` not below `max`, and any
|
|
23
|
+
* non-finite number are refused naming the prop; `value` is clamped into range and snapped to the
|
|
24
|
+
* nearest step from `min`, which is what the native control would do with it anyway.
|
|
25
|
+
*/
|
|
26
|
+
export function sliderNumbers(input: {
|
|
27
|
+
readonly min?: number | undefined;
|
|
28
|
+
readonly max?: number | undefined;
|
|
29
|
+
readonly step?: number | undefined;
|
|
30
|
+
readonly value?: number | undefined;
|
|
31
|
+
}): SliderNumbers {
|
|
32
|
+
const min = finiteOption('Slider', 'min', input.min ?? 0);
|
|
33
|
+
const max = finiteOption('Slider', 'max', input.max ?? 100);
|
|
34
|
+
const step = finiteOption('Slider', 'step', input.step ?? 1);
|
|
35
|
+
if (step <= 0) throw invalidValueError('Slider step', step, 'a step above 0');
|
|
36
|
+
if (min >= max)
|
|
37
|
+
throw invalidValueError(
|
|
38
|
+
'Slider min',
|
|
39
|
+
`${String(min)} not below max ${String(max)}`,
|
|
40
|
+
'a min below max',
|
|
41
|
+
);
|
|
42
|
+
const raw = finiteOption('Slider', 'value', input.value ?? min);
|
|
43
|
+
const clamped = Math.min(max, Math.max(min, raw));
|
|
44
|
+
const snapped = min + Math.round((clamped - min) / step) * step;
|
|
45
|
+
const value = Number(Math.min(max, snapped).toFixed(decimals(step)));
|
|
46
|
+
return { min, max, step, value };
|
|
47
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The DOM half of a reorder: where each group and row is, the pointer gesture bound to a handle,
|
|
2
|
+
// the lifted row following the pointer (input, not animation, so reduced motion keeps it), the
|
|
3
|
+
// edge scroll, and focus handed back to a handle a keyboard move re-parented.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
autoScroller,
|
|
7
|
+
bindPointerDrag,
|
|
8
|
+
browserScrollEnv,
|
|
9
|
+
type Scrollable,
|
|
10
|
+
} from '../drag/pointer-drag';
|
|
11
|
+
import type { SortController, SortMeasure } from '../drag/sort-controller';
|
|
12
|
+
import type { AxisSpan } from '../drag/sort-view';
|
|
13
|
+
|
|
14
|
+
export interface SortGeometry extends SortMeasure {
|
|
15
|
+
root(el: HTMLElement): void;
|
|
16
|
+
group(id: string, el: HTMLElement): void;
|
|
17
|
+
row(id: string, el: HTMLElement): void;
|
|
18
|
+
rowOf(id: string): HTMLElement | undefined;
|
|
19
|
+
/** Every list in the document now, by group id. */
|
|
20
|
+
lists(): readonly (readonly [string, HTMLElement])[];
|
|
21
|
+
handle(id: string, el: HTMLElement): void;
|
|
22
|
+
refocus(id: string): void;
|
|
23
|
+
scrollers(): readonly Scrollable[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const inline = (el: Element): AxisSpan => {
|
|
27
|
+
const rect = el.getBoundingClientRect();
|
|
28
|
+
return { start: rect.left, size: rect.width };
|
|
29
|
+
};
|
|
30
|
+
const block = (el: Element): AxisSpan => {
|
|
31
|
+
const rect = el.getBoundingClientRect();
|
|
32
|
+
return { start: rect.top, size: rect.height };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Registries filled by refs. Groups are read in document order, so a re-render cannot reorder them. */
|
|
36
|
+
export function sortGeometry(): SortGeometry {
|
|
37
|
+
let root: HTMLElement | undefined;
|
|
38
|
+
const groups = new Map<string, HTMLElement>();
|
|
39
|
+
const rows = new Map<string, HTMLElement>();
|
|
40
|
+
const handles = new Map<string, HTMLElement>();
|
|
41
|
+
const live = (): HTMLElement[] =>
|
|
42
|
+
[...groups.values()]
|
|
43
|
+
.filter((el) => el.isConnected)
|
|
44
|
+
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
|
45
|
+
return {
|
|
46
|
+
root: (el) => {
|
|
47
|
+
root = el;
|
|
48
|
+
},
|
|
49
|
+
group: (id, el) => groups.set(id, el),
|
|
50
|
+
row: (id, el) => rows.set(id, el),
|
|
51
|
+
rowOf: (id) => rows.get(id),
|
|
52
|
+
lists: () => [...groups].filter(([, el]) => el.isConnected),
|
|
53
|
+
handle: (id, el) => handles.set(id, el),
|
|
54
|
+
groupBoxes: () => live().map((el) => ({ inline: inline(el), block: block(el) })),
|
|
55
|
+
itemSpans: (g, without) => {
|
|
56
|
+
const list = live()[g];
|
|
57
|
+
if (list === undefined) return [];
|
|
58
|
+
return [...list.children]
|
|
59
|
+
.filter((child) => child.getAttribute('data-sort-key') !== without)
|
|
60
|
+
.map(block);
|
|
61
|
+
},
|
|
62
|
+
// A keyboard move re-parents the focused row, and the browser drops focus to <body> with it.
|
|
63
|
+
refocus: (id) => queueMicrotask(() => handles.get(id)?.focus()),
|
|
64
|
+
scrollers: () => {
|
|
65
|
+
const page: Scrollable = {
|
|
66
|
+
getBoundingClientRect: () => ({ top: 0, bottom: innerHeight, left: 0, right: innerWidth }),
|
|
67
|
+
scrollBy: (x, y) => scrollBy(x, y),
|
|
68
|
+
};
|
|
69
|
+
return root === undefined ? [page] : [page, root];
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Binds one handle; the binding is undone when the row's owner is disposed. */
|
|
75
|
+
export function bindSortRow<T>(
|
|
76
|
+
rt: { onCleanup(fn: () => void): void },
|
|
77
|
+
geometry: SortGeometry,
|
|
78
|
+
controller: SortController<T>,
|
|
79
|
+
id: string,
|
|
80
|
+
handle: HTMLElement,
|
|
81
|
+
): void {
|
|
82
|
+
geometry.handle(id, handle);
|
|
83
|
+
if (typeof document === 'undefined' || typeof handle.setPointerCapture !== 'function') return;
|
|
84
|
+
const sort = controller.pointer(id, geometry);
|
|
85
|
+
let grab = { x: 0, y: 0 };
|
|
86
|
+
let shift = { x: 0, y: 0 };
|
|
87
|
+
let scroll: { update(p: { x: number; y: number }): void; stop(): void }[] = [];
|
|
88
|
+
const place = (row: HTMLElement | undefined, x: number, y: number): void => {
|
|
89
|
+
if (row === undefined) return;
|
|
90
|
+
const rect = row.getBoundingClientRect();
|
|
91
|
+
shift = { x: x - grab.x - (rect.left - shift.x), y: y - grab.y - (rect.top - shift.y) };
|
|
92
|
+
row.style.setProperty('--drag-x', `${shift.x}px`);
|
|
93
|
+
row.style.setProperty('--drag-y', `${shift.y}px`);
|
|
94
|
+
};
|
|
95
|
+
const settle = (): void => {
|
|
96
|
+
for (const scroller of scroll) scroller.stop();
|
|
97
|
+
scroll = [];
|
|
98
|
+
const row = geometry.rowOf(id);
|
|
99
|
+
row?.style.removeProperty('--drag-x');
|
|
100
|
+
row?.style.removeProperty('--drag-y');
|
|
101
|
+
shift = { x: 0, y: 0 };
|
|
102
|
+
};
|
|
103
|
+
const unbind = bindPointerDrag(handle, {
|
|
104
|
+
start: (origin) => {
|
|
105
|
+
const rect = geometry.rowOf(id)?.getBoundingClientRect();
|
|
106
|
+
grab = rect === undefined ? origin : { x: origin.x - rect.left, y: origin.y - rect.top };
|
|
107
|
+
const env = browserScrollEnv();
|
|
108
|
+
scroll = geometry.scrollers().map((box) => autoScroller(env, box));
|
|
109
|
+
sort.start(origin);
|
|
110
|
+
},
|
|
111
|
+
move: (point) => {
|
|
112
|
+
sort.move(point);
|
|
113
|
+
place(geometry.rowOf(id), point.x, point.y);
|
|
114
|
+
for (const scroller of scroll) scroller.update(point);
|
|
115
|
+
},
|
|
116
|
+
drop: (point) => {
|
|
117
|
+
settle();
|
|
118
|
+
sort.drop(point);
|
|
119
|
+
},
|
|
120
|
+
cancel: () => {
|
|
121
|
+
settle();
|
|
122
|
+
sort.cancel();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
rt.onCleanup(unbind);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `list` holds exactly `rows`, in order, moving nodes that are elsewhere and dropping its own that
|
|
130
|
+
* are no longer listed. Only ever `insertBefore` and a removal of a child this list still holds.
|
|
131
|
+
*/
|
|
132
|
+
export function placeRows(list: HTMLElement, rows: readonly unknown[]): void {
|
|
133
|
+
const nodes = rows.filter(
|
|
134
|
+
(row): row is Node => typeof Node !== 'undefined' && row instanceof Node,
|
|
135
|
+
);
|
|
136
|
+
nodes.forEach((node, index) => {
|
|
137
|
+
const at = list.childNodes[index] ?? null;
|
|
138
|
+
if (at !== node) list.insertBefore(node, at);
|
|
139
|
+
});
|
|
140
|
+
while (list.childNodes.length > nodes.length) list.lastChild?.remove();
|
|
141
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// The markup `SortableList` and `Board` share: keyed rows (a live tick beneath a drag keeps every
|
|
2
|
+
// node), a handle button carrying the keyboard and pointer gesture, and — with scripting off — a
|
|
3
|
+
// real form per row. One module, so the two components cannot drift on the gesture.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { announce } from '../a11y';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import { type SortController, type SortSpeech, sortController } from '../drag/sort-controller';
|
|
9
|
+
import { type CanDrop, type SortGroup, type SortMove, sortPositions } from '../drag/sort-view';
|
|
10
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
11
|
+
import { iconGripVertical } from '../icons/glyphs/grip-vertical';
|
|
12
|
+
import { useUi } from '../theme/context';
|
|
13
|
+
import { solid } from '../theme/solid-adapter';
|
|
14
|
+
import boardStyles from './Board.module.scss';
|
|
15
|
+
import { keyedRows } from './data-table-rows';
|
|
16
|
+
import { Icon } from './Icon';
|
|
17
|
+
import styles from './SortableList.module.scss';
|
|
18
|
+
import { bindSortRow, placeRows, type SortGeometry, sortGeometry } from './sort-pointer';
|
|
19
|
+
|
|
20
|
+
/** The form field names the no-script controls post. `null` leaves that field out. */
|
|
21
|
+
export interface SortFields {
|
|
22
|
+
readonly id?: string | undefined;
|
|
23
|
+
readonly fromGroup?: string | null | undefined;
|
|
24
|
+
readonly toGroup?: string | null | undefined;
|
|
25
|
+
readonly toIndex?: string | null | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SortSurfaceGroup<T> extends SortGroup<T> {
|
|
29
|
+
readonly label: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SortSurfaceInput<T> {
|
|
33
|
+
readonly groups: () => readonly SortSurfaceGroup<T>[];
|
|
34
|
+
readonly itemKey: (item: T) => string;
|
|
35
|
+
readonly renderItem: (item: T) => JSX.Element;
|
|
36
|
+
readonly onMove: (move: SortMove) => unknown;
|
|
37
|
+
readonly canDrop?: CanDrop<T> | undefined;
|
|
38
|
+
readonly action?: string | undefined;
|
|
39
|
+
readonly fields?: SortFields | undefined;
|
|
40
|
+
readonly layout: 'list' | 'board';
|
|
41
|
+
readonly label: string;
|
|
42
|
+
readonly class?: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SPEECH = new Map<SortSpeech, string>([
|
|
46
|
+
['lift', UI_KEYS.sortLift],
|
|
47
|
+
['moved', UI_KEYS.sortMoved],
|
|
48
|
+
['dropped', UI_KEYS.sortDropped],
|
|
49
|
+
['cancelled', UI_KEYS.sortCancelled],
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const field = (name: string | null | undefined, fallback: string): string | null =>
|
|
53
|
+
name === undefined ? fallback : name;
|
|
54
|
+
|
|
55
|
+
export function sortSurface<T>(input: SortSurfaceInput<T>): JSX.Element {
|
|
56
|
+
const ui = useUi();
|
|
57
|
+
const rt = solid();
|
|
58
|
+
const [enhanced, setEnhanced] = rt.createSignal(false);
|
|
59
|
+
// Effects never run on the server, so the no-script form is what the document carries.
|
|
60
|
+
rt.createEffect(() => setEnhanced(true));
|
|
61
|
+
const labelOf = (id: string): string =>
|
|
62
|
+
input.groups().find((group) => group.id === id)?.label ?? id;
|
|
63
|
+
const controller: SortController<T> = sortController(rt, {
|
|
64
|
+
groups: input.groups,
|
|
65
|
+
itemKey: input.itemKey,
|
|
66
|
+
onMove: input.onMove,
|
|
67
|
+
canDrop: input.canDrop,
|
|
68
|
+
dir: () => ui.dir,
|
|
69
|
+
groupLabel: labelOf,
|
|
70
|
+
say: (what, params) => announce(ui.t(SPEECH.get(what) ?? UI_KEYS.sortMoved, params)),
|
|
71
|
+
});
|
|
72
|
+
const geometry: SortGeometry = sortGeometry();
|
|
73
|
+
const names = input.fields ?? {};
|
|
74
|
+
|
|
75
|
+
const hidden = (name: string | null, value: string): JSX.Element =>
|
|
76
|
+
name === null ? null : <input type="hidden" name={name} value={value} />;
|
|
77
|
+
|
|
78
|
+
/** The no-script move: Up and Down in a list, a target column in a board. */
|
|
79
|
+
const fallback = (item: T, id: string): JSX.Element => {
|
|
80
|
+
if (input.action === undefined) return null;
|
|
81
|
+
const groups = input.groups();
|
|
82
|
+
const g = groups.findIndex((group) => group.items.includes(item));
|
|
83
|
+
const group = groups[g];
|
|
84
|
+
if (group === undefined) return null;
|
|
85
|
+
const index = group.items.indexOf(item);
|
|
86
|
+
const targets = groups.filter(
|
|
87
|
+
(target) => target.id === group.id || input.canDrop?.(item, target.id) !== false,
|
|
88
|
+
);
|
|
89
|
+
// A card no other column accepts has no move to offer.
|
|
90
|
+
if (input.layout === 'board' && targets.length < 2) return null;
|
|
91
|
+
const toIndex = field(names.toIndex, 'toIndex');
|
|
92
|
+
return (
|
|
93
|
+
<form method="post" action={input.action} class={styles['fallback']}>
|
|
94
|
+
{hidden(field(names.id, 'id') ?? 'id', id)}
|
|
95
|
+
{hidden(field(names.fromGroup, 'fromGroup'), group.id)}
|
|
96
|
+
{input.layout === 'list' ? (
|
|
97
|
+
<>
|
|
98
|
+
{hidden(field(names.toGroup, 'toGroup'), group.id)}
|
|
99
|
+
<button
|
|
100
|
+
type="submit"
|
|
101
|
+
name={toIndex ?? undefined}
|
|
102
|
+
value={String(index - 1)}
|
|
103
|
+
disabled={index === 0}
|
|
104
|
+
>
|
|
105
|
+
{ui.t(UI_KEYS.sortUp)}
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
type="submit"
|
|
109
|
+
name={toIndex ?? undefined}
|
|
110
|
+
value={String(index + 1)}
|
|
111
|
+
disabled={index === group.items.length - 1}
|
|
112
|
+
>
|
|
113
|
+
{ui.t(UI_KEYS.sortDown)}
|
|
114
|
+
</button>
|
|
115
|
+
</>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
<select
|
|
119
|
+
name={field(names.toGroup, 'toGroup') ?? undefined}
|
|
120
|
+
aria-label={ui.t(UI_KEYS.sortTarget)}
|
|
121
|
+
>
|
|
122
|
+
{targets.map((target) => (
|
|
123
|
+
<option value={target.id} selected={target.id === group.id}>
|
|
124
|
+
{target.label}
|
|
125
|
+
</option>
|
|
126
|
+
))}
|
|
127
|
+
</select>
|
|
128
|
+
<button type="submit">{ui.t(UI_KEYS.sortHandle)}</button>
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
</form>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const rows = keyedRows<T, JSX.Element>(rt, input.itemKey, (row, id) => (
|
|
136
|
+
<li
|
|
137
|
+
class={styles['row']}
|
|
138
|
+
data-sort-key={id}
|
|
139
|
+
data-lifted={controller.lifted() === id ? 'true' : undefined}
|
|
140
|
+
ref={(el: HTMLLIElement) => geometry.row(id, el)}
|
|
141
|
+
>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
class={styles['handle']}
|
|
145
|
+
aria-label={ui.t(UI_KEYS.sortHandle)}
|
|
146
|
+
aria-pressed={controller.lifted() === id ? 'true' : 'false'}
|
|
147
|
+
data-sort-handle={id}
|
|
148
|
+
ref={(el: HTMLButtonElement) => bindSortRow(rt, geometry, controller, id, el)}
|
|
149
|
+
onKeyDown={(event: KeyboardEvent) => {
|
|
150
|
+
if (!controller.key(id, event)) return;
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
geometry.refocus(id);
|
|
153
|
+
}}
|
|
154
|
+
onFocusOut={(event: FocusEvent) => {
|
|
155
|
+
if (event.relatedTarget !== null) controller.leave(id);
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<Icon glyph={iconGripVertical} size="sm" />
|
|
159
|
+
</button>
|
|
160
|
+
<div class={styles['body']}>{input.renderItem(row())}</div>
|
|
161
|
+
{fallback(row(), id)}
|
|
162
|
+
</li>
|
|
163
|
+
));
|
|
164
|
+
|
|
165
|
+
// One memo: every column reads its slice of ONE keyed pass, so a card crossing columns keeps
|
|
166
|
+
// its node. Keys are checked across every group first, naming the duplicate as the surface's.
|
|
167
|
+
const groupNodes = rt.createMemo((): Map<string, JSX.Element[]> => {
|
|
168
|
+
const view = controller.view();
|
|
169
|
+
sortPositions(view, input.itemKey);
|
|
170
|
+
const all = rows(view.flatMap((group) => group.items));
|
|
171
|
+
const out = new Map<string, JSX.Element[]>();
|
|
172
|
+
let at = 0;
|
|
173
|
+
for (const group of view) {
|
|
174
|
+
out.set(group.id, all.slice(at, at + group.items.length));
|
|
175
|
+
at += group.items.length;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Built once per group: a lift re-orders the rows INSIDE a list and never replaces the list,
|
|
181
|
+
// so the lifted row's handle keeps its place in the document and a live tick touches nothing.
|
|
182
|
+
const list = (id: string, label: () => string | undefined): JSX.Element => (
|
|
183
|
+
<ul
|
|
184
|
+
class={styles['list']}
|
|
185
|
+
aria-label={label()}
|
|
186
|
+
ref={(el: HTMLUListElement) => geometry.group(id, el)}
|
|
187
|
+
>
|
|
188
|
+
{typeof document === 'undefined' ? (groupNodes().get(id) ?? []) : null}
|
|
189
|
+
</ul>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// In a browser ONE effect places every row in every list. Solid's own reconcile, run per list,
|
|
193
|
+
// removes a row it no longer lists with `node.remove()` — and when the destination list ran
|
|
194
|
+
// first, that detached the card it had just received. One pass only ever moves a node. A server
|
|
195
|
+
// render has no effects and writes the rows as children above.
|
|
196
|
+
rt.createEffect(() => {
|
|
197
|
+
const nodes = groupNodes();
|
|
198
|
+
for (const [id, list] of geometry.lists()) placeRows(list, nodes.get(id) ?? []);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const columns = keyedRows<SortSurfaceGroup<T>, JSX.Element>(
|
|
202
|
+
rt,
|
|
203
|
+
(group) => group.id,
|
|
204
|
+
(group, id) => (
|
|
205
|
+
// biome-ignore lint/a11y/useSemanticElements: a column is a group of the board, not a form's fieldset
|
|
206
|
+
<section class={boardStyles['column']} role="group" aria-label={group().label}>
|
|
207
|
+
<p class={boardStyles['columnLabel']} aria-hidden="true">
|
|
208
|
+
{group().label}
|
|
209
|
+
</p>
|
|
210
|
+
{list(id, () => group().label)}
|
|
211
|
+
</section>
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
// A list's one group id, read once: a list never becomes another list.
|
|
215
|
+
const single =
|
|
216
|
+
input.layout === 'list' ? list(input.groups()[0]?.id ?? '', () => undefined) : null;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
// biome-ignore lint/a11y/useSemanticElements: the list or board as one named group, not a form's fieldset
|
|
220
|
+
<div
|
|
221
|
+
class={cx(styles['root'], input.layout === 'board' && boardStyles['board'], input.class)}
|
|
222
|
+
data-enhanced={enhanced() ? 'true' : undefined}
|
|
223
|
+
data-dragging={controller.lifted() === null ? undefined : 'true'}
|
|
224
|
+
role="group"
|
|
225
|
+
aria-label={input.label}
|
|
226
|
+
ref={(el: HTMLDivElement) => geometry.root(el)}
|
|
227
|
+
>
|
|
228
|
+
{single ?? columns(input.groups())}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|