@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,53 @@
|
|
|
1
|
+
// A unified patch (`git diff`, `diff -u`) read into the same rows `lineDiff` makes: file and hunk
|
|
2
|
+
// headers, numbered context, additions and removals, word-marked in pairs. Malformed text is never
|
|
3
|
+
// an error — a line the grammar does not know is kept as context, so nothing a tool wrote is lost.
|
|
4
|
+
|
|
5
|
+
import { type DiffRow, markPairs } from './line-diff';
|
|
6
|
+
|
|
7
|
+
const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
8
|
+
|
|
9
|
+
export function parseUnifiedPatch(patch: string): DiffRow[] {
|
|
10
|
+
const rows: DiffRow[] = [];
|
|
11
|
+
let oldNo = 0;
|
|
12
|
+
let newNo = 0;
|
|
13
|
+
let inHunk = false;
|
|
14
|
+
for (const line of patch.replace(/\r\n?/g, '\n').split('\n')) {
|
|
15
|
+
const hunk = HUNK.exec(line);
|
|
16
|
+
if (hunk !== null) {
|
|
17
|
+
oldNo = Number.parseInt(hunk[1] ?? '1', 10) - 1;
|
|
18
|
+
newNo = Number.parseInt(hunk[2] ?? '1', 10) - 1;
|
|
19
|
+
inHunk = true;
|
|
20
|
+
rows.push({ kind: 'hunk', text: line });
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (
|
|
24
|
+
line.startsWith('diff ') ||
|
|
25
|
+
(!inHunk && (line.startsWith('--- ') || line.startsWith('+++ ')))
|
|
26
|
+
) {
|
|
27
|
+
inHunk = false;
|
|
28
|
+
rows.push({ kind: 'file', text: line });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!inHunk) {
|
|
32
|
+
if (line !== '') rows.push({ kind: 'file', text: line });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// `` annotates the line above; it is not a line of either side.
|
|
36
|
+
if (line.startsWith('\\')) continue;
|
|
37
|
+
const added = line.startsWith('+');
|
|
38
|
+
const removed = line.startsWith('-');
|
|
39
|
+
if (!added) oldNo += 1;
|
|
40
|
+
if (!removed) newNo += 1;
|
|
41
|
+
if (added) rows.push({ kind: 'add', new: newNo, text: line.slice(1) });
|
|
42
|
+
else if (removed) rows.push({ kind: 'del', old: oldNo, text: line.slice(1) });
|
|
43
|
+
else {
|
|
44
|
+
const text = line.startsWith(' ') ? line.slice(1) : line;
|
|
45
|
+
rows.push({ kind: 'context', old: oldNo, new: newNo, text });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// A trailing newline in the patch is not a context line.
|
|
49
|
+
const last = rows[rows.length - 1];
|
|
50
|
+
if (last !== undefined && last.kind === 'context' && last.text === '' && patch.endsWith('\n'))
|
|
51
|
+
rows.pop();
|
|
52
|
+
return markPairs(rows);
|
|
53
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The one drag gesture: Pointer Events with capture, never HTML5 drag events (they do not fire on
|
|
2
|
+
// touch and have no keyboard form). A pure tracker decides tap vs drag and cancel; the binder wires
|
|
3
|
+
// it to a handle; the auto-scroller steps a box near its edges. Clock and geometry are injected.
|
|
4
|
+
|
|
5
|
+
/** A pointer position in client coordinates. */
|
|
6
|
+
export interface DragPoint {
|
|
7
|
+
readonly x: number;
|
|
8
|
+
readonly y: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Below this travel a press is a tap: a click on the handle, never a lift. */
|
|
12
|
+
export const DRAG_THRESHOLD_PX = 4;
|
|
13
|
+
|
|
14
|
+
export interface DragHandlers {
|
|
15
|
+
/** The travel passed the threshold: the item lifts at the press point. */
|
|
16
|
+
start(origin: DragPoint): void;
|
|
17
|
+
move(point: DragPoint): void;
|
|
18
|
+
drop(point: DragPoint): void;
|
|
19
|
+
/** Escape, `pointercancel`, a lost capture or a blur: back to the origin, nothing requested. */
|
|
20
|
+
cancel(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DragPhase = 'idle' | 'pressed' | 'dragging';
|
|
24
|
+
|
|
25
|
+
export interface DragTracker {
|
|
26
|
+
phase(): DragPhase;
|
|
27
|
+
down(point: DragPoint): void;
|
|
28
|
+
move(point: DragPoint): void;
|
|
29
|
+
up(point: DragPoint): void;
|
|
30
|
+
cancel(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The state machine alone, so a test drives tap, drag, drop and every cancel with plain points. */
|
|
34
|
+
export function dragTracker(handlers: DragHandlers, threshold = DRAG_THRESHOLD_PX): DragTracker {
|
|
35
|
+
let phase: DragPhase = 'idle';
|
|
36
|
+
let origin: DragPoint = { x: 0, y: 0 };
|
|
37
|
+
return {
|
|
38
|
+
phase: () => phase,
|
|
39
|
+
down(point) {
|
|
40
|
+
phase = 'pressed';
|
|
41
|
+
origin = point;
|
|
42
|
+
},
|
|
43
|
+
move(point) {
|
|
44
|
+
if (phase === 'pressed') {
|
|
45
|
+
if (Math.hypot(point.x - origin.x, point.y - origin.y) < threshold) return;
|
|
46
|
+
phase = 'dragging';
|
|
47
|
+
handlers.start(origin);
|
|
48
|
+
}
|
|
49
|
+
if (phase === 'dragging') handlers.move(point);
|
|
50
|
+
},
|
|
51
|
+
up(point) {
|
|
52
|
+
const was = phase;
|
|
53
|
+
phase = 'idle';
|
|
54
|
+
if (was === 'dragging') handlers.drop(point);
|
|
55
|
+
},
|
|
56
|
+
cancel() {
|
|
57
|
+
const was = phase;
|
|
58
|
+
phase = 'idle';
|
|
59
|
+
if (was === 'dragging') handlers.cancel();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The edge band, in px, inside which a drag scrolls its box, and the cap on a frame's step. */
|
|
65
|
+
export const AUTO_SCROLL_EDGE_PX = 48;
|
|
66
|
+
export const AUTO_SCROLL_MAX_PX = 16;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Px per frame along one axis for a pointer at `pos` in a box spanning `lo..hi`: zero in the
|
|
70
|
+
* middle, growing toward an edge, capped. Under reduced motion there is no ramp — one whole step
|
|
71
|
+
* of `edge` px, which the scroller spends on a slow cadence instead of every frame.
|
|
72
|
+
*/
|
|
73
|
+
export function autoScrollDelta(pos: number, lo: number, hi: number, reduced: boolean): number {
|
|
74
|
+
const edge = Math.min(AUTO_SCROLL_EDGE_PX, (hi - lo) / 2);
|
|
75
|
+
if (edge <= 0) return 0;
|
|
76
|
+
const into = pos < lo + edge ? -(lo + edge - pos) : pos > hi - edge ? pos - (hi - edge) : 0;
|
|
77
|
+
if (into === 0) return 0;
|
|
78
|
+
if (reduced) return Math.sign(into) * AUTO_SCROLL_EDGE_PX;
|
|
79
|
+
return (
|
|
80
|
+
Math.sign(into) *
|
|
81
|
+
Math.min(AUTO_SCROLL_MAX_PX, Math.ceil((Math.abs(into) / edge) * AUTO_SCROLL_MAX_PX))
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The host a scroller needs: a frame clock and a reduced-motion answer, both injected. */
|
|
86
|
+
export interface ScrollEnv {
|
|
87
|
+
frame(run: () => void): number;
|
|
88
|
+
cancelFrame(handle: number): void;
|
|
89
|
+
now(): number;
|
|
90
|
+
reducedMotion(): boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Under reduced motion the box moves in steps this far apart, never as a glide. */
|
|
94
|
+
export const REDUCED_STEP_MS = 250;
|
|
95
|
+
|
|
96
|
+
export interface Scrollable {
|
|
97
|
+
getBoundingClientRect(): { top: number; bottom: number; left: number; right: number };
|
|
98
|
+
scrollBy(x: number, y: number): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Scrolls `box` while the last pointer sits in an edge band; `stop()` ends it for good. */
|
|
102
|
+
export function autoScroller(
|
|
103
|
+
env: ScrollEnv,
|
|
104
|
+
box: Scrollable,
|
|
105
|
+
): { update(point: DragPoint): void; stop(): void } {
|
|
106
|
+
let point: DragPoint | null = null;
|
|
107
|
+
let handle: number | null = null;
|
|
108
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
109
|
+
const tick = (): void => {
|
|
110
|
+
handle = null;
|
|
111
|
+
if (point === null) return;
|
|
112
|
+
const rect = box.getBoundingClientRect();
|
|
113
|
+
const reduced = env.reducedMotion();
|
|
114
|
+
const dx = autoScrollDelta(point.x, rect.left, rect.right, reduced);
|
|
115
|
+
const dy = autoScrollDelta(point.y, rect.top, rect.bottom, reduced);
|
|
116
|
+
if (dx === 0 && dy === 0) return;
|
|
117
|
+
if (!reduced || env.now() - last >= REDUCED_STEP_MS) {
|
|
118
|
+
last = env.now();
|
|
119
|
+
box.scrollBy(dx, dy);
|
|
120
|
+
}
|
|
121
|
+
handle = env.frame(tick);
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
update(next) {
|
|
125
|
+
point = next;
|
|
126
|
+
if (handle === null) handle = env.frame(tick);
|
|
127
|
+
},
|
|
128
|
+
stop() {
|
|
129
|
+
point = null;
|
|
130
|
+
if (handle !== null) env.cancelFrame(handle);
|
|
131
|
+
handle = null;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The browser's frame clock, read only when a drag starts — a server render never gets here. */
|
|
137
|
+
export function browserScrollEnv(): ScrollEnv {
|
|
138
|
+
return {
|
|
139
|
+
frame: (run) => requestAnimationFrame(run),
|
|
140
|
+
cancelFrame: (handle) => cancelAnimationFrame(handle),
|
|
141
|
+
now: () => performance.now(),
|
|
142
|
+
reducedMotion: () => matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pointOf = (event: PointerEvent): DragPoint => ({ x: event.clientX, y: event.clientY });
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Wires a tracker to one handle: the press captures the pointer, so the move, the release and the
|
|
150
|
+
* cancel arrive here wherever the finger goes. Primary button only. Answers the unbind.
|
|
151
|
+
*/
|
|
152
|
+
export function bindPointerDrag(handle: HTMLElement, handlers: DragHandlers): () => void {
|
|
153
|
+
const tracker = dragTracker(handlers);
|
|
154
|
+
const onDown = (event: PointerEvent): void => {
|
|
155
|
+
if (event.button !== 0 || !event.isPrimary) return;
|
|
156
|
+
tracker.down(pointOf(event));
|
|
157
|
+
handle.setPointerCapture(event.pointerId);
|
|
158
|
+
};
|
|
159
|
+
const onMove = (event: PointerEvent): void => {
|
|
160
|
+
if (tracker.phase() === 'idle') return;
|
|
161
|
+
tracker.move(pointOf(event));
|
|
162
|
+
if (tracker.phase() === 'dragging') event.preventDefault();
|
|
163
|
+
};
|
|
164
|
+
const onUp = (event: PointerEvent): void => tracker.up(pointOf(event));
|
|
165
|
+
const onCancel = (): void => tracker.cancel();
|
|
166
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
167
|
+
if (event.key === 'Escape' && tracker.phase() === 'dragging') tracker.cancel();
|
|
168
|
+
};
|
|
169
|
+
handle.addEventListener('pointerdown', onDown);
|
|
170
|
+
handle.addEventListener('pointermove', onMove);
|
|
171
|
+
handle.addEventListener('pointerup', onUp);
|
|
172
|
+
handle.addEventListener('pointercancel', onCancel);
|
|
173
|
+
handle.addEventListener('lostpointercapture', onCancel);
|
|
174
|
+
document.addEventListener('keydown', onKey);
|
|
175
|
+
window.addEventListener('blur', onCancel);
|
|
176
|
+
return () => {
|
|
177
|
+
handle.removeEventListener('pointerdown', onDown);
|
|
178
|
+
handle.removeEventListener('pointermove', onMove);
|
|
179
|
+
handle.removeEventListener('pointerup', onUp);
|
|
180
|
+
handle.removeEventListener('pointercancel', onCancel);
|
|
181
|
+
handle.removeEventListener('lostpointercapture', onCancel);
|
|
182
|
+
document.removeEventListener('keydown', onKey);
|
|
183
|
+
window.removeEventListener('blur', onCancel);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The one reorder state machine `SortableList` and `Board` share: a lifted item and where it sits,
|
|
2
|
+
// the preview it makes of the caller's groups, keyboard and pointer input, and the drop as a
|
|
3
|
+
// REQUEST — the preview holds until `onMove` settles, and a refusal puts it back and says so.
|
|
4
|
+
|
|
5
|
+
import type { Direction } from '@ultimat3/core';
|
|
6
|
+
import type { Accessor, Setter } from '../theme/solid-adapter';
|
|
7
|
+
import type { DragHandlers, DragPoint } from './pointer-drag';
|
|
8
|
+
import {
|
|
9
|
+
type AxisBox,
|
|
10
|
+
type AxisSpan,
|
|
11
|
+
type CanDrop,
|
|
12
|
+
dropMove,
|
|
13
|
+
moveBetween,
|
|
14
|
+
pointerGroup,
|
|
15
|
+
pointerIndex,
|
|
16
|
+
type SortGroup,
|
|
17
|
+
type SortMove,
|
|
18
|
+
type SortPosition,
|
|
19
|
+
sortKeyStep,
|
|
20
|
+
sortPositions,
|
|
21
|
+
sortSpeech,
|
|
22
|
+
} from './sort-view';
|
|
23
|
+
|
|
24
|
+
/** The four things said aloud, each taking `{ position, total, group }`. */
|
|
25
|
+
export type SortSpeech = 'lift' | 'moved' | 'dropped' | 'cancelled';
|
|
26
|
+
|
|
27
|
+
export interface SortControllerInput<T> {
|
|
28
|
+
readonly groups: () => readonly SortGroup<T>[];
|
|
29
|
+
readonly itemKey: (item: T) => string;
|
|
30
|
+
readonly onMove: (move: SortMove) => unknown;
|
|
31
|
+
readonly canDrop?: CanDrop<T> | undefined;
|
|
32
|
+
readonly dir: () => Direction;
|
|
33
|
+
/** Text for one announcement; the component reads its catalog, a test reads a literal. */
|
|
34
|
+
readonly say: (what: SortSpeech, params: ReturnType<typeof sortSpeech>) => void;
|
|
35
|
+
readonly groupLabel: (id: string) => string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Geometry the pointer path reads: the groups along the inline axis, items along the block. */
|
|
39
|
+
export interface SortMeasure {
|
|
40
|
+
groupBoxes(): readonly AxisBox[];
|
|
41
|
+
itemSpans(group: number, without: string): readonly AxisSpan[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SortRuntime {
|
|
45
|
+
createSignal<T>(value: T): [Accessor<T>, Setter<T>];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Held {
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly at: SortPosition;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SortController<T> {
|
|
54
|
+
/** The caller's groups, with a lifted or in-flight item shown where it is headed. */
|
|
55
|
+
view(): readonly SortGroup<T>[];
|
|
56
|
+
lifted(): string | null;
|
|
57
|
+
/** True for a key the handle consumed — the component then prevents its default. */
|
|
58
|
+
key(id: string, event: { key: string; altKey: boolean }): boolean;
|
|
59
|
+
pointer(id: string, measure: SortMeasure): DragHandlers;
|
|
60
|
+
/** Tab left the lifted handle: put it back. */
|
|
61
|
+
leave(id: string): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sortController<T>(
|
|
65
|
+
rt: SortRuntime,
|
|
66
|
+
input: SortControllerInput<T>,
|
|
67
|
+
): SortController<T> {
|
|
68
|
+
const [lift, setLift] = rt.createSignal<Held | null>(null);
|
|
69
|
+
const [held, setHeld] = rt.createSignal<Held | null>(null);
|
|
70
|
+
|
|
71
|
+
const origin = (id: string): SortPosition | undefined =>
|
|
72
|
+
sortPositions(input.groups(), input.itemKey).get(id);
|
|
73
|
+
const accepts =
|
|
74
|
+
(id: string) =>
|
|
75
|
+
(group: string): boolean => {
|
|
76
|
+
const at = origin(id);
|
|
77
|
+
const item = at === undefined ? undefined : input.groups()[at.group]?.items[at.index];
|
|
78
|
+
if (item === undefined || input.canDrop === undefined) return true;
|
|
79
|
+
return input.groups()[at?.group ?? -1]?.id === group || input.canDrop(item, group);
|
|
80
|
+
};
|
|
81
|
+
const view = (): readonly SortGroup<T>[] => {
|
|
82
|
+
const groups = input.groups();
|
|
83
|
+
const moving = lift() ?? held();
|
|
84
|
+
const from = moving === null ? undefined : origin(moving.id);
|
|
85
|
+
return moving === null || from === undefined ? groups : moveBetween(groups, from, moving.at);
|
|
86
|
+
};
|
|
87
|
+
const say = (what: SortSpeech, at: SortPosition): void =>
|
|
88
|
+
input.say(what, sortSpeech(view(), at, input.groupLabel));
|
|
89
|
+
|
|
90
|
+
const start = (id: string): void => {
|
|
91
|
+
const at = origin(id);
|
|
92
|
+
if (at === undefined) return;
|
|
93
|
+
setLift({ id, at });
|
|
94
|
+
say('lift', at);
|
|
95
|
+
};
|
|
96
|
+
const moveTo = (id: string, at: SortPosition, speak: boolean): void => {
|
|
97
|
+
setLift({ id, at });
|
|
98
|
+
if (speak) say('moved', at);
|
|
99
|
+
};
|
|
100
|
+
const cancel = (): void => {
|
|
101
|
+
const was = lift();
|
|
102
|
+
setLift(null);
|
|
103
|
+
const at = was === null ? undefined : origin(was.id);
|
|
104
|
+
if (at !== undefined) say('cancelled', at);
|
|
105
|
+
};
|
|
106
|
+
// Only the answer to the move now held clears it: an earlier request settling while a later one
|
|
107
|
+
// is in flight would otherwise snap the preview back to the stale order.
|
|
108
|
+
const release = (was: Held): void => {
|
|
109
|
+
if (held() === was) setHeld(null);
|
|
110
|
+
};
|
|
111
|
+
const drop = (): void => {
|
|
112
|
+
const was = lift();
|
|
113
|
+
if (was === null) return;
|
|
114
|
+
const from = origin(was.id);
|
|
115
|
+
const item = from === undefined ? undefined : input.groups()[from.group]?.items[from.index];
|
|
116
|
+
const move =
|
|
117
|
+
from === undefined || item === undefined
|
|
118
|
+
? null
|
|
119
|
+
: dropMove(input.groups(), item, was.id, from, was.at, input.canDrop);
|
|
120
|
+
if (move === null || from === undefined) {
|
|
121
|
+
setLift(null);
|
|
122
|
+
if (from !== undefined) say('cancelled', from);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Held, not committed: the preview stays until the caller's answer, so a row does not jump
|
|
126
|
+
// back and forward again while its server echo is in flight.
|
|
127
|
+
setHeld(was);
|
|
128
|
+
setLift(null);
|
|
129
|
+
say('dropped', was.at);
|
|
130
|
+
Promise.resolve()
|
|
131
|
+
.then(() => input.onMove(move))
|
|
132
|
+
.then(
|
|
133
|
+
() => release(was),
|
|
134
|
+
() => {
|
|
135
|
+
release(was);
|
|
136
|
+
const back = origin(was.id);
|
|
137
|
+
if (back !== undefined) say('cancelled', back);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
view,
|
|
144
|
+
lifted: () => lift()?.id ?? null,
|
|
145
|
+
key(id, event) {
|
|
146
|
+
const current = lift();
|
|
147
|
+
if (current === null) {
|
|
148
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
149
|
+
start(id);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (!event.altKey) return false;
|
|
153
|
+
// A step from a move still in flight starts where the preview shows it, not at the echo.
|
|
154
|
+
const pending = held();
|
|
155
|
+
const at = pending?.id === id ? pending.at : origin(id);
|
|
156
|
+
const base = pending?.id === id ? view() : input.groups();
|
|
157
|
+
const step =
|
|
158
|
+
at === undefined ? null : sortKeyStep(base, at, event.key, input.dir(), accepts(id));
|
|
159
|
+
if (step === null) return false;
|
|
160
|
+
setLift({ id, at: step });
|
|
161
|
+
drop();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (current.id !== id) return false;
|
|
165
|
+
if (event.key === 'Escape') cancel();
|
|
166
|
+
else if (event.key === ' ' || event.key === 'Enter') drop();
|
|
167
|
+
else {
|
|
168
|
+
const step = sortKeyStep(view(), current.at, event.key, input.dir(), accepts(id));
|
|
169
|
+
if (step === null) return event.key.startsWith('Arrow');
|
|
170
|
+
moveTo(id, step, true);
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
pointer(id, measure): DragHandlers {
|
|
175
|
+
return {
|
|
176
|
+
start: () => start(id),
|
|
177
|
+
move: (point: DragPoint) => {
|
|
178
|
+
const current = lift();
|
|
179
|
+
if (current === null) return;
|
|
180
|
+
const boxes = measure.groupBoxes();
|
|
181
|
+
const near = boxes.length > 1 ? pointerGroup(point, boxes) : 0;
|
|
182
|
+
const group = accepts(id)(view()[near]?.id ?? '') ? near : current.at.group;
|
|
183
|
+
const index = pointerIndex(point.y, measure.itemSpans(group, id));
|
|
184
|
+
if (group !== current.at.group || index !== current.at.index) {
|
|
185
|
+
moveTo(id, { group, index }, false);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
drop: () => drop(),
|
|
189
|
+
cancel: () => cancel(),
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
leave(id) {
|
|
193
|
+
if (lift()?.id === id) cancel();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The pure rules of reordering: where an item is, where a pointer or a key puts it, and whether the
|
|
2
|
+
// drop is a request at all. No DOM and no clock, so `SortableList` and `Board` share one answer and
|
|
3
|
+
// every rule is tested under `dir="rtl"` with plain numbers.
|
|
4
|
+
|
|
5
|
+
import type { Direction } from '@ultimat3/core';
|
|
6
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
7
|
+
import { UI_ERROR_CODES, UiError } from '../errors';
|
|
8
|
+
|
|
9
|
+
/** One column of a board, or the single group of a list. `id` is what `onMove` names. */
|
|
10
|
+
export interface SortGroup<T> {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly items: readonly T[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Where an item sits: the group's position in the list of groups, and its index inside it. */
|
|
16
|
+
export interface SortPosition {
|
|
17
|
+
readonly group: number;
|
|
18
|
+
readonly index: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a drop asks for. `toIndex` is the item's index in `toGroup` AFTER the move. */
|
|
22
|
+
export interface SortMove {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly fromGroup: string;
|
|
25
|
+
readonly toGroup: string;
|
|
26
|
+
readonly toIndex: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A span along one axis, as a measured rect gives it. */
|
|
30
|
+
export interface AxisSpan {
|
|
31
|
+
readonly start: number;
|
|
32
|
+
readonly size: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `canDrop(item, groupId)`: the one seam a business rule (a WIP limit, a status machine) uses. */
|
|
36
|
+
export type CanDrop<T> = (item: T, group: string) => boolean;
|
|
37
|
+
|
|
38
|
+
function duplicateKeyError(key: string): UiError {
|
|
39
|
+
return new UiError({
|
|
40
|
+
code: UI_ERROR_CODES.invalidValue,
|
|
41
|
+
cause: `itemKey answered ${renderCauseValue(key)} for two items, across every group of the list or board — a dragged item must be exactly one node`,
|
|
42
|
+
fix: 'itemKey: (item) => item.id # on <SortableList> or <Board>: a key unique across every group',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every item's position by key. Two items under one key, in any group, is refused. */
|
|
47
|
+
export function sortPositions<T>(
|
|
48
|
+
groups: readonly SortGroup<T>[],
|
|
49
|
+
itemKey: (item: T) => string,
|
|
50
|
+
): Map<string, SortPosition> {
|
|
51
|
+
const at = new Map<string, SortPosition>();
|
|
52
|
+
groups.forEach((group, g) => {
|
|
53
|
+
group.items.forEach((item, index) => {
|
|
54
|
+
const key = itemKey(item);
|
|
55
|
+
if (at.has(key)) throw duplicateKeyError(key);
|
|
56
|
+
at.set(key, { group: g, index });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return at;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The groups with one item taken from `from` and put at `to` — the drag preview, never truth. */
|
|
63
|
+
export function moveBetween<T>(
|
|
64
|
+
groups: readonly SortGroup<T>[],
|
|
65
|
+
from: SortPosition,
|
|
66
|
+
to: SortPosition,
|
|
67
|
+
): readonly SortGroup<T>[] {
|
|
68
|
+
const source = groups[from.group];
|
|
69
|
+
const item = source?.items[from.index];
|
|
70
|
+
if (source === undefined || item === undefined || groups[to.group] === undefined) return groups;
|
|
71
|
+
const lifted = groups.map((group, g) =>
|
|
72
|
+
g === from.group
|
|
73
|
+
? { id: group.id, items: group.items.filter((_, i) => i !== from.index) }
|
|
74
|
+
: group,
|
|
75
|
+
);
|
|
76
|
+
return lifted.map((group, g) => {
|
|
77
|
+
if (g !== to.group) return group;
|
|
78
|
+
const index = Math.max(0, Math.min(to.index, group.items.length));
|
|
79
|
+
return {
|
|
80
|
+
id: group.id,
|
|
81
|
+
items: [...group.items.slice(0, index), item, ...group.items.slice(index)],
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The first group from `start` walking by `step` that accepts the item, or null at the edge. */
|
|
87
|
+
function nextGroup<T>(
|
|
88
|
+
groups: readonly SortGroup<T>[],
|
|
89
|
+
start: number,
|
|
90
|
+
step: number,
|
|
91
|
+
accepts: (group: string) => boolean,
|
|
92
|
+
): number | null {
|
|
93
|
+
for (let g = start + step; g >= 0 && g < groups.length; g += step) {
|
|
94
|
+
const group = groups[g];
|
|
95
|
+
if (group !== undefined && accepts(group.id)) return g;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One key while an item is lifted, over the PREVIEW groups (the item already sits at `at`).
|
|
102
|
+
* Up and Down walk the group; the inline arrows walk the groups, mirrored under `rtl`, skipping a
|
|
103
|
+
* group `canDrop` refuses; Home and End go to the group's ends. Null: the key does not move it.
|
|
104
|
+
*/
|
|
105
|
+
export function sortKeyStep<T>(
|
|
106
|
+
groups: readonly SortGroup<T>[],
|
|
107
|
+
at: SortPosition,
|
|
108
|
+
key: string,
|
|
109
|
+
dir: Direction,
|
|
110
|
+
canDrop: (group: string) => boolean = () => true,
|
|
111
|
+
): SortPosition | null {
|
|
112
|
+
const length = groups[at.group]?.items.length ?? 0;
|
|
113
|
+
const within = (index: number): SortPosition | null => {
|
|
114
|
+
const clamped = Math.max(0, Math.min(index, length - 1));
|
|
115
|
+
return clamped === at.index ? null : { group: at.group, index: clamped };
|
|
116
|
+
};
|
|
117
|
+
if (key === 'ArrowUp') return within(at.index - 1);
|
|
118
|
+
if (key === 'ArrowDown') return within(at.index + 1);
|
|
119
|
+
if (key === 'Home') return within(0);
|
|
120
|
+
if (key === 'End') return within(length - 1);
|
|
121
|
+
if (key !== 'ArrowLeft' && key !== 'ArrowRight') return null;
|
|
122
|
+
const forward = key === 'ArrowRight' ? 1 : -1;
|
|
123
|
+
const step = dir === 'rtl' ? -forward : forward;
|
|
124
|
+
const g = nextGroup(groups, at.group, step, canDrop);
|
|
125
|
+
if (g === null) return null;
|
|
126
|
+
// The destination does not hold the item yet, so its insertion points run 0..length.
|
|
127
|
+
return { group: g, index: Math.min(at.index, groups[g]?.items.length ?? 0) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The insertion index a pointer at `pointer` (one axis) asks for, among `spans` — the measured
|
|
132
|
+
* items of the destination group WITHOUT the dragged one. Past an item's midpoint is after it.
|
|
133
|
+
*/
|
|
134
|
+
export function pointerIndex(pointer: number, spans: readonly AxisSpan[]): number {
|
|
135
|
+
let index = 0;
|
|
136
|
+
for (const span of spans) if (pointer > span.start + span.size / 2) index += 1;
|
|
137
|
+
return index;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A measured box: one span per axis. */
|
|
141
|
+
export interface AxisBox {
|
|
142
|
+
readonly inline: AxisSpan;
|
|
143
|
+
readonly block: AxisSpan;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const outside = (pointer: number, span: AxisSpan): number =>
|
|
147
|
+
pointer < span.start ? span.start - pointer : Math.max(0, pointer - span.start - span.size);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The group whose box holds the pointer, else the nearest one: a drop between columns lands. Both
|
|
151
|
+
* axes, so columns side by side and columns stacked on a narrow screen answer alike.
|
|
152
|
+
*/
|
|
153
|
+
export function pointerGroup(
|
|
154
|
+
point: { readonly x: number; readonly y: number },
|
|
155
|
+
boxes: readonly AxisBox[],
|
|
156
|
+
): number {
|
|
157
|
+
let best = 0;
|
|
158
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
159
|
+
boxes.forEach((box, g) => {
|
|
160
|
+
const away = Math.hypot(outside(point.x, box.inline), outside(point.y, box.block));
|
|
161
|
+
if (away < distance) {
|
|
162
|
+
distance = away;
|
|
163
|
+
best = g;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return best;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The move a drop requests, or null when there is none to make: back where it started, or a
|
|
171
|
+
* group `canDrop` refuses — both keep the origin and call nothing.
|
|
172
|
+
*/
|
|
173
|
+
export function dropMove<T>(
|
|
174
|
+
groups: readonly SortGroup<T>[],
|
|
175
|
+
item: T,
|
|
176
|
+
id: string,
|
|
177
|
+
from: SortPosition,
|
|
178
|
+
to: SortPosition,
|
|
179
|
+
canDrop?: CanDrop<T>,
|
|
180
|
+
): SortMove | null {
|
|
181
|
+
const fromGroup = groups[from.group]?.id;
|
|
182
|
+
const toGroup = groups[to.group]?.id;
|
|
183
|
+
if (fromGroup === undefined || toGroup === undefined) return null;
|
|
184
|
+
if (from.group === to.group && from.index === to.index) return null;
|
|
185
|
+
if (canDrop !== undefined && from.group !== to.group && !canDrop(item, toGroup)) return null;
|
|
186
|
+
return { id, fromGroup, toGroup, toIndex: to.index };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The numbers every announcement takes: a 1-based position, the group's size, and its name. */
|
|
190
|
+
export function sortSpeech<T>(
|
|
191
|
+
groups: readonly SortGroup<T>[],
|
|
192
|
+
at: SortPosition,
|
|
193
|
+
groupLabel: (id: string) => string,
|
|
194
|
+
): { readonly position: number; readonly total: number; readonly group: string } {
|
|
195
|
+
const group = groups[at.group];
|
|
196
|
+
return {
|
|
197
|
+
position: at.index + 1,
|
|
198
|
+
total: group?.items.length ?? 0,
|
|
199
|
+
group: group === undefined ? '' : groupLabel(group.id),
|
|
200
|
+
};
|
|
201
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -103,7 +103,7 @@ export function providerNeedsRuntimeError(): UiError {
|
|
|
103
103
|
code: UI_ERROR_CODES.runtimeMissing,
|
|
104
104
|
cause:
|
|
105
105
|
'<UiProvider> was rendered with no Solid runtime registered, so its locale, time zone, currency and translator would reach no component',
|
|
106
|
-
fix: "in an island, paste `import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext } from 'solid-js';` at the top of the *.island.tsx and `setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup });` as the first line of its mount(), above the render() that builds <UiProvider> —
|
|
106
|
+
fix: "in an island, paste `import { createContext, createEffect, createMemo, createRoot, createSignal, onCleanup, useContext } from 'solid-js';` at the top of the *.island.tsx and `setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup, createRoot });` as the first line of its mount(), above the render() that builds <UiProvider> — seven named imports, never `import * as`, which keeps all of solid-js in the chunk; on the server, delete <UiProvider> — useUi() already reads the request locale and time zone",
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
|