@joewinke/jatui 0.1.27 → 0.1.29
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/CLAUDE.md +3 -3
- package/LICENSE +21 -0
- package/dist/actions/collapse.d.ts +1 -1
- package/dist/actions/collapse.js +16 -30
- package/dist/actions/listNav.d.ts +134 -0
- package/dist/actions/listNav.js +643 -0
- package/dist/actions/railNav.js +78 -89
- package/dist/components/InlineEdit.svelte +26 -1
- package/dist/components/InlineEdit.svelte.d.ts +13 -0
- package/dist/components/PhoneInput.svelte +70 -20
- package/dist/components/PhoneInput.svelte.d.ts +5 -2
- package/dist/components/ResizableDivider.svelte +148 -18
- package/dist/components/ResizableDivider.svelte.d.ts +14 -3
- package/dist/components/SignaturePad.svelte +13 -6
- package/dist/components/SignaturePad.svelte.d.ts +1 -0
- package/dist/components/ThemeSelector.svelte +61 -2
- package/dist/components/ThemeSelector.svelte.d.ts +4 -0
- package/dist/components/markup.js +59 -62
- package/dist/components/session-nav/transcriptModel.js +49 -66
- package/dist/data/emojis.js +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/stores/confirmDialog.svelte.js +5 -5
- package/dist/stores/inputDialog.svelte.js +5 -5
- package/dist/types/booking.js +4 -4
- package/dist/types/comments.js +2 -2
- package/dist/types/messaging.js +3 -3
- package/dist/types/pipeline.js +2 -2
- package/dist/utils/currency.js +2 -4
- package/dist/utils/daisyuiColors.js +25 -27
- package/dist/utils/dateFormatters.js +44 -44
- package/dist/utils/googleMapsLoader.js +46 -100
- package/dist/utils/mentionParser.js +30 -30
- package/dist/utils/phone.js +21 -27
- package/dist/utils/phoneFormat.js +9 -9
- package/dist/utils/taskUtils.js +1 -1
- package/package.json +11 -5
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared keyboard-navigation action + composable for list-like UIs.
|
|
3
|
+
*
|
|
4
|
+
* Base motions — j/k / ArrowDown/ArrowUp with wraparound, Enter/Space to
|
|
5
|
+
* trigger select, Escape to clear focus. Typing targets (input, textarea,
|
|
6
|
+
* select, contenteditable) and most modifier combos are skipped so the
|
|
7
|
+
* shortcuts never interfere with typing or app-level keybindings.
|
|
8
|
+
*
|
|
9
|
+
* Vim-motion layer (always on):
|
|
10
|
+
*
|
|
11
|
+
* gg jump to first item
|
|
12
|
+
* G jump to last item
|
|
13
|
+
* {n}j / {n}k move `n` items (count-prefix buffer with 1.5s timeout)
|
|
14
|
+
* {n}G, {n}gg jump to 1-indexed line `n` (clamped to list length)
|
|
15
|
+
* Ctrl+D / Ctrl+U move roughly half a viewport's worth of items
|
|
16
|
+
* zz scroll the focused item to the centre of its viewport
|
|
17
|
+
*
|
|
18
|
+
* Selection layer (opt-in via `selectable: true`):
|
|
19
|
+
*
|
|
20
|
+
* V toggle visual mode (anchor at current focus, selects it)
|
|
21
|
+
* j / k while visual mode is on, also extends the range from anchor
|
|
22
|
+
* x toggle selection of the focused item (no movement)
|
|
23
|
+
* Shift+J / K toggle current + move down/up + toggle new focus (range-build)
|
|
24
|
+
* * toggle select-all of the currently visible items
|
|
25
|
+
* Escape backs out one layer at a time:
|
|
26
|
+
* pending motion → visual mode → selection → focus / onEscape
|
|
27
|
+
*
|
|
28
|
+
* Selected items get the `selected-class` (default `jk-selected`) so pages
|
|
29
|
+
* can style them. The controller exposes selectedIds()/clearSelection()/
|
|
30
|
+
* selectAll()/toggleSelection()/isSelecting() and fires onSelectionChange
|
|
31
|
+
* whenever the set mutates. A floating `BulkActionBar` is the canonical
|
|
32
|
+
* chip for surfacing the count + per-route bulk actions.
|
|
33
|
+
*
|
|
34
|
+
* Usage — action form (simplest):
|
|
35
|
+
*
|
|
36
|
+
* <div use:listNav={{
|
|
37
|
+
* onSelect: (el, idx) => openDetail(el.dataset.navId!),
|
|
38
|
+
* onEscape: () => closeDetail(),
|
|
39
|
+
* }}>
|
|
40
|
+
* {#each items as item}
|
|
41
|
+
* <button data-nav-id={item.id}>{item.title}</button>
|
|
42
|
+
* {/each}
|
|
43
|
+
* </div>
|
|
44
|
+
*
|
|
45
|
+
* Usage — composable form (when you already have a keydown handler):
|
|
46
|
+
*
|
|
47
|
+
* const nav = createListNav({
|
|
48
|
+
* getItems: () => Array.from(container.querySelectorAll('[data-nav-id]')),
|
|
49
|
+
* onSelect: (el) => openDetail(el.dataset.navId!),
|
|
50
|
+
* selectable: true,
|
|
51
|
+
* onSelectionChange: (ids) => { selectedIds = ids; },
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* function onKeydown(e: KeyboardEvent) {
|
|
55
|
+
* if (nav.handleKeydown(e)) return;
|
|
56
|
+
* // ...page-specific shortcuts
|
|
57
|
+
* }
|
|
58
|
+
*/
|
|
59
|
+
/** How long a half-typed motion (count prefix, pending `g`, pending `z`) waits
|
|
60
|
+
* for its completion before resetting. Matches Vim's default `timeoutlen`. */
|
|
61
|
+
const MOTION_BUFFER_TIMEOUT_MS = 1500;
|
|
62
|
+
/** Upper cap so `99999j` can't produce pathological math. Above any list size
|
|
63
|
+
* you'd reasonably navigate with j/k. */
|
|
64
|
+
const MAX_COUNT = 99999;
|
|
65
|
+
/** Fallback half-page size when we can't measure the viewport (SSR, jsdom
|
|
66
|
+
* without layout, items not yet attached). */
|
|
67
|
+
const DEFAULT_HALF_PAGE = 5;
|
|
68
|
+
function isTypingTarget(target) {
|
|
69
|
+
if (typeof HTMLElement === 'undefined')
|
|
70
|
+
return false;
|
|
71
|
+
if (!(target instanceof HTMLElement))
|
|
72
|
+
return false;
|
|
73
|
+
const tag = target.tagName;
|
|
74
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
75
|
+
return true;
|
|
76
|
+
return target.isContentEditable;
|
|
77
|
+
}
|
|
78
|
+
function hasModifier(e) {
|
|
79
|
+
return e.ctrlKey || e.metaKey || e.altKey;
|
|
80
|
+
}
|
|
81
|
+
function resolveItems(optsRef) {
|
|
82
|
+
const getItems = optsRef.current.getItems;
|
|
83
|
+
if (!getItems)
|
|
84
|
+
return [];
|
|
85
|
+
const result = getItems();
|
|
86
|
+
return Array.isArray(result) ? result : Array.from(result);
|
|
87
|
+
}
|
|
88
|
+
function wrapIndex(next, length, wrap) {
|
|
89
|
+
if (length === 0)
|
|
90
|
+
return -1;
|
|
91
|
+
if (next < 0)
|
|
92
|
+
return wrap ? ((next % length) + length) % length : 0;
|
|
93
|
+
if (next >= length)
|
|
94
|
+
return wrap ? next % length : length - 1;
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Estimate how many list items fit in one 'page' of the nearest scrollable
|
|
99
|
+
* ancestor, then halve it. Used for Ctrl+D / Ctrl+U. Returns {@link DEFAULT_HALF_PAGE}
|
|
100
|
+
* when layout isn't available (SSR, jsdom, detached elements).
|
|
101
|
+
*/
|
|
102
|
+
function getHalfPageSize(items, currentIdx) {
|
|
103
|
+
if (items.length === 0)
|
|
104
|
+
return 0;
|
|
105
|
+
try {
|
|
106
|
+
const pivot = items[Math.max(0, Math.min(items.length - 1, currentIdx))] ?? items[0];
|
|
107
|
+
if (!pivot || typeof window === 'undefined')
|
|
108
|
+
return DEFAULT_HALF_PAGE;
|
|
109
|
+
// Walk up looking for an ancestor that is actually scrolling.
|
|
110
|
+
let scroller = pivot.parentElement;
|
|
111
|
+
while (scroller) {
|
|
112
|
+
const cs = window.getComputedStyle(scroller);
|
|
113
|
+
if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') &&
|
|
114
|
+
scroller.clientHeight > 0 &&
|
|
115
|
+
scroller.clientHeight < scroller.scrollHeight) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
scroller = scroller.parentElement;
|
|
119
|
+
}
|
|
120
|
+
const viewportHeight = scroller?.clientHeight ?? window.innerHeight;
|
|
121
|
+
const itemHeight = pivot.getBoundingClientRect().height;
|
|
122
|
+
if (viewportHeight > 0 && itemHeight > 0) {
|
|
123
|
+
const fullPage = Math.max(1, Math.floor(viewportHeight / itemHeight));
|
|
124
|
+
return Math.max(1, Math.floor(fullPage / 2));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Fall through to default.
|
|
129
|
+
}
|
|
130
|
+
return DEFAULT_HALF_PAGE;
|
|
131
|
+
}
|
|
132
|
+
function makeController(optsRef) {
|
|
133
|
+
let focused = -1;
|
|
134
|
+
let lastEl = null;
|
|
135
|
+
// Motion-buffer state. A motion is 'pending' until its follow-up key
|
|
136
|
+
// arrives (e.g. the second `g` in `gg`) or the buffer times out.
|
|
137
|
+
let pendingCount = null;
|
|
138
|
+
let pendingG = false;
|
|
139
|
+
let pendingZ = false;
|
|
140
|
+
let bufferTimer = null;
|
|
141
|
+
// Selection state. `selected` holds stable ids (from getItemId / data-nav-id)
|
|
142
|
+
// rather than indices, since list filters can shift indices but ids persist.
|
|
143
|
+
// `visualMode` flips on/off via V; while on, j/k extends the range from
|
|
144
|
+
// `visualAnchor` (the index where V was first pressed) to current focus.
|
|
145
|
+
const selected = new Set();
|
|
146
|
+
let visualMode = false;
|
|
147
|
+
let visualAnchor = -1;
|
|
148
|
+
function clearBufferTimer() {
|
|
149
|
+
if (bufferTimer !== null) {
|
|
150
|
+
clearTimeout(bufferTimer);
|
|
151
|
+
bufferTimer = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function clearPending() {
|
|
155
|
+
pendingCount = null;
|
|
156
|
+
pendingG = false;
|
|
157
|
+
pendingZ = false;
|
|
158
|
+
clearBufferTimer();
|
|
159
|
+
}
|
|
160
|
+
function bumpTimer() {
|
|
161
|
+
clearBufferTimer();
|
|
162
|
+
if (typeof setTimeout !== 'undefined') {
|
|
163
|
+
bufferTimer = setTimeout(clearPending, MOTION_BUFFER_TIMEOUT_MS);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function focusedClass() {
|
|
167
|
+
return optsRef.current.focusedClass ?? 'jk-focused';
|
|
168
|
+
}
|
|
169
|
+
function selectedClass() {
|
|
170
|
+
return optsRef.current.selectedClass ?? 'jk-selected';
|
|
171
|
+
}
|
|
172
|
+
function getId(el) {
|
|
173
|
+
if (!el)
|
|
174
|
+
return null;
|
|
175
|
+
const resolver = optsRef.current.getItemId;
|
|
176
|
+
const raw = resolver ? resolver(el) : el.dataset.navId;
|
|
177
|
+
return raw == null ? null : String(raw);
|
|
178
|
+
}
|
|
179
|
+
function applySelectionClasses(items) {
|
|
180
|
+
const cls = selectedClass();
|
|
181
|
+
for (const el of items) {
|
|
182
|
+
const id = getId(el);
|
|
183
|
+
if (id !== null && selected.has(id))
|
|
184
|
+
el.classList.add(cls);
|
|
185
|
+
else
|
|
186
|
+
el.classList.remove(cls);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function emitSelectionChange() {
|
|
190
|
+
applySelectionClasses(resolveItems(optsRef));
|
|
191
|
+
optsRef.current.onSelectionChange?.(new Set(selected));
|
|
192
|
+
}
|
|
193
|
+
function apply(items, idx) {
|
|
194
|
+
const cls = focusedClass();
|
|
195
|
+
const next = items[idx] ?? null;
|
|
196
|
+
if (lastEl && lastEl !== next)
|
|
197
|
+
lastEl.classList.remove(cls);
|
|
198
|
+
if (next) {
|
|
199
|
+
next.classList.add(cls);
|
|
200
|
+
next.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
201
|
+
}
|
|
202
|
+
lastEl = next;
|
|
203
|
+
focused = next ? idx : -1;
|
|
204
|
+
optsRef.current.onFocusChange?.(next, focused);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Re-derive the selected set from the inclusive range [anchor..focus] while
|
|
208
|
+
* visual mode is active. Items outside the range that were selected before
|
|
209
|
+
* V was pressed are preserved (additive — Vim's `V` extends, doesn't reset).
|
|
210
|
+
*/
|
|
211
|
+
function extendVisualRange(items) {
|
|
212
|
+
if (!visualMode || visualAnchor < 0 || focused < 0)
|
|
213
|
+
return;
|
|
214
|
+
const lo = Math.min(visualAnchor, focused);
|
|
215
|
+
const hi = Math.max(visualAnchor, focused);
|
|
216
|
+
// Add every id in [lo..hi]. Items outside the range are left alone so
|
|
217
|
+
// existing pre-V selections survive moving the anchor to a new region.
|
|
218
|
+
let mutated = false;
|
|
219
|
+
for (let i = lo; i <= hi; i++) {
|
|
220
|
+
const id = getId(items[i] ?? null);
|
|
221
|
+
if (id !== null && !selected.has(id)) {
|
|
222
|
+
selected.add(id);
|
|
223
|
+
mutated = true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (mutated)
|
|
227
|
+
emitSelectionChange();
|
|
228
|
+
else
|
|
229
|
+
applySelectionClasses(items);
|
|
230
|
+
}
|
|
231
|
+
function focus(idx) {
|
|
232
|
+
const items = resolveItems(optsRef);
|
|
233
|
+
if (items.length === 0)
|
|
234
|
+
return;
|
|
235
|
+
apply(items, Math.max(0, Math.min(items.length - 1, idx)));
|
|
236
|
+
}
|
|
237
|
+
function focusFirst() {
|
|
238
|
+
focus(0);
|
|
239
|
+
}
|
|
240
|
+
function focusLast() {
|
|
241
|
+
const items = resolveItems(optsRef);
|
|
242
|
+
if (items.length)
|
|
243
|
+
apply(items, items.length - 1);
|
|
244
|
+
}
|
|
245
|
+
function clear() {
|
|
246
|
+
if (lastEl)
|
|
247
|
+
lastEl.classList.remove(focusedClass());
|
|
248
|
+
lastEl = null;
|
|
249
|
+
focused = -1;
|
|
250
|
+
clearPending();
|
|
251
|
+
optsRef.current.onFocusChange?.(null, -1);
|
|
252
|
+
}
|
|
253
|
+
function refresh() {
|
|
254
|
+
const items = resolveItems(optsRef);
|
|
255
|
+
if (items.length === 0) {
|
|
256
|
+
clear();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Re-home the focused element if the old node has been removed.
|
|
260
|
+
if (lastEl && !items.includes(lastEl)) {
|
|
261
|
+
const newIdx = Math.min(focused, items.length - 1);
|
|
262
|
+
apply(items, Math.max(0, newIdx));
|
|
263
|
+
}
|
|
264
|
+
else if (focused >= 0 && focused < items.length) {
|
|
265
|
+
apply(items, focused);
|
|
266
|
+
}
|
|
267
|
+
// Re-paint selection classes — items may have been re-rendered, so the
|
|
268
|
+
// CSS class needs to be re-applied to the new DOM nodes whose ids match.
|
|
269
|
+
applySelectionClasses(items);
|
|
270
|
+
}
|
|
271
|
+
function toggleSelectionByIndex(items, idx) {
|
|
272
|
+
const id = getId(items[idx] ?? null);
|
|
273
|
+
if (id === null)
|
|
274
|
+
return;
|
|
275
|
+
if (selected.has(id))
|
|
276
|
+
selected.delete(id);
|
|
277
|
+
else
|
|
278
|
+
selected.add(id);
|
|
279
|
+
emitSelectionChange();
|
|
280
|
+
}
|
|
281
|
+
function exitVisualMode() {
|
|
282
|
+
visualMode = false;
|
|
283
|
+
visualAnchor = -1;
|
|
284
|
+
}
|
|
285
|
+
function handleKeydown(e) {
|
|
286
|
+
const opts = optsRef.current;
|
|
287
|
+
if (opts.enabled === false)
|
|
288
|
+
return false;
|
|
289
|
+
if (isTypingTarget(e.target))
|
|
290
|
+
return false;
|
|
291
|
+
const items = resolveItems(optsRef);
|
|
292
|
+
const wrap = opts.wraparound ?? true;
|
|
293
|
+
const selectable = opts.selectable === true;
|
|
294
|
+
// --- Selection layer (opt-in) ----------------------------------------
|
|
295
|
+
// These keys are only owned by the controller when `selectable` is true,
|
|
296
|
+
// so existing pages without a selection model keep their bindings.
|
|
297
|
+
if (selectable && !hasModifier(e)) {
|
|
298
|
+
// V — toggle visual mode. First press anchors at current focus and
|
|
299
|
+
// selects the focused item; second press exits visual mode while
|
|
300
|
+
// keeping the existing selection (Vim parity for re-entry).
|
|
301
|
+
if (e.key === 'V') {
|
|
302
|
+
if (items.length === 0)
|
|
303
|
+
return false;
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
if (visualMode) {
|
|
306
|
+
exitVisualMode();
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
if (focused < 0)
|
|
310
|
+
apply(items, 0);
|
|
311
|
+
visualMode = true;
|
|
312
|
+
visualAnchor = focused;
|
|
313
|
+
toggleSelectionByIndex(items, focused);
|
|
314
|
+
// If the toggle deselected the anchor, re-add it — entering
|
|
315
|
+
// visual mode should always include the anchor.
|
|
316
|
+
const anchorId = getId(items[focused] ?? null);
|
|
317
|
+
if (anchorId !== null && !selected.has(anchorId)) {
|
|
318
|
+
selected.add(anchorId);
|
|
319
|
+
emitSelectionChange();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
clearPending();
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
// x — toggle selection of the focused item (no movement, no visual).
|
|
326
|
+
if (e.key === 'x' && !e.shiftKey) {
|
|
327
|
+
if (focused < 0 || items.length === 0)
|
|
328
|
+
return false;
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
toggleSelectionByIndex(items, focused);
|
|
331
|
+
clearPending();
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
// * — toggle select-all of currently visible items.
|
|
335
|
+
if (e.key === '*') {
|
|
336
|
+
if (items.length === 0)
|
|
337
|
+
return false;
|
|
338
|
+
e.preventDefault();
|
|
339
|
+
const allIds = [];
|
|
340
|
+
for (const el of items) {
|
|
341
|
+
const id = getId(el);
|
|
342
|
+
if (id !== null)
|
|
343
|
+
allIds.push(id);
|
|
344
|
+
}
|
|
345
|
+
const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id));
|
|
346
|
+
if (allSelected) {
|
|
347
|
+
for (const id of allIds)
|
|
348
|
+
selected.delete(id);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
for (const id of allIds)
|
|
352
|
+
selected.add(id);
|
|
353
|
+
}
|
|
354
|
+
emitSelectionChange();
|
|
355
|
+
clearPending();
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
// Shift+J / Shift+K — toggle current + move down/up + add new focus.
|
|
359
|
+
// Preserves the existing /triage idiom (range-build without entering
|
|
360
|
+
// visual mode). Always wraps within bounds (no wraparound for safety).
|
|
361
|
+
if (e.key === 'J' || e.key === 'K') {
|
|
362
|
+
if (items.length === 0)
|
|
363
|
+
return false;
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
const dir = e.key === 'J' ? 1 : -1;
|
|
366
|
+
const before = focused < 0 ? (dir > 0 ? 0 : items.length - 1) : focused;
|
|
367
|
+
const beforeId = getId(items[before] ?? null);
|
|
368
|
+
if (beforeId !== null)
|
|
369
|
+
selected.add(beforeId);
|
|
370
|
+
const afterIdx = Math.max(0, Math.min(items.length - 1, before + dir));
|
|
371
|
+
apply(items, afterIdx);
|
|
372
|
+
const afterId = getId(items[afterIdx] ?? null);
|
|
373
|
+
if (afterId !== null)
|
|
374
|
+
selected.add(afterId);
|
|
375
|
+
emitSelectionChange();
|
|
376
|
+
clearPending();
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Ctrl+D / Ctrl+U — half-page motion. Intercepted before the generic
|
|
381
|
+
// Ctrl/Meta/Alt bailout so vim half-page works; every other modifier
|
|
382
|
+
// combination still falls through untouched.
|
|
383
|
+
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
|
|
384
|
+
const lower = e.key.toLowerCase();
|
|
385
|
+
if (lower === 'd' || lower === 'u') {
|
|
386
|
+
if (items.length === 0)
|
|
387
|
+
return false;
|
|
388
|
+
e.preventDefault();
|
|
389
|
+
const half = getHalfPageSize(items, focused);
|
|
390
|
+
const dir = lower === 'd' ? 1 : -1;
|
|
391
|
+
const start = focused < 0 ? (dir > 0 ? 0 : items.length - 1) : focused;
|
|
392
|
+
apply(items, wrapIndex(start + dir * half, items.length, wrap));
|
|
393
|
+
clearPending();
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (hasModifier(e))
|
|
398
|
+
return false;
|
|
399
|
+
// Count prefix. A leading `0` is intentionally pass-through so
|
|
400
|
+
// consumers can still bind `0` themselves; subsequent digits within
|
|
401
|
+
// an already-started count (e.g. `10j`) are accumulated normally.
|
|
402
|
+
if (e.key.length === 1 && e.key >= '0' && e.key <= '9') {
|
|
403
|
+
if (pendingCount === null && e.key === '0')
|
|
404
|
+
return false;
|
|
405
|
+
e.preventDefault();
|
|
406
|
+
pendingCount = (pendingCount ?? 0) * 10 + Number(e.key);
|
|
407
|
+
if (pendingCount > MAX_COUNT)
|
|
408
|
+
pendingCount = MAX_COUNT;
|
|
409
|
+
pendingG = false;
|
|
410
|
+
pendingZ = false;
|
|
411
|
+
bumpTimer();
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
// gg — jump to first item (or to line {count} when a count is pending).
|
|
415
|
+
if (e.key === 'g' && !e.shiftKey) {
|
|
416
|
+
if (items.length === 0) {
|
|
417
|
+
clearPending();
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
if (pendingG) {
|
|
422
|
+
const target = pendingCount !== null
|
|
423
|
+
? Math.max(0, Math.min(items.length - 1, pendingCount - 1))
|
|
424
|
+
: 0;
|
|
425
|
+
apply(items, target);
|
|
426
|
+
clearPending();
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
pendingG = true;
|
|
430
|
+
pendingZ = false;
|
|
431
|
+
bumpTimer();
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
// G — jump to last item (or to line {count}).
|
|
436
|
+
if (e.key === 'G') {
|
|
437
|
+
if (items.length === 0) {
|
|
438
|
+
clearPending();
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
e.preventDefault();
|
|
442
|
+
const target = pendingCount !== null
|
|
443
|
+
? Math.max(0, Math.min(items.length - 1, pendingCount - 1))
|
|
444
|
+
: items.length - 1;
|
|
445
|
+
apply(items, target);
|
|
446
|
+
clearPending();
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
// zz — centre the focused item in its viewport.
|
|
450
|
+
if (e.key === 'z' && !e.shiftKey) {
|
|
451
|
+
e.preventDefault();
|
|
452
|
+
if (pendingZ) {
|
|
453
|
+
const el = lastEl ?? items[focused] ?? null;
|
|
454
|
+
el?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
|
|
455
|
+
clearPending();
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
pendingZ = true;
|
|
459
|
+
pendingG = false;
|
|
460
|
+
bumpTimer();
|
|
461
|
+
}
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
switch (e.key) {
|
|
465
|
+
case 'j':
|
|
466
|
+
case 'ArrowDown': {
|
|
467
|
+
if (items.length === 0)
|
|
468
|
+
return false;
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
const step = pendingCount ?? 1;
|
|
471
|
+
// From an unfocused state, `{n}j` selects the n-th item
|
|
472
|
+
// (1-indexed) so it mirrors `{n}G` from the top.
|
|
473
|
+
const target = focused < 0 ? step - 1 : focused + step;
|
|
474
|
+
apply(items, wrapIndex(target, items.length, wrap));
|
|
475
|
+
clearPending();
|
|
476
|
+
if (selectable && visualMode)
|
|
477
|
+
extendVisualRange(items);
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
case 'k':
|
|
481
|
+
case 'ArrowUp': {
|
|
482
|
+
if (items.length === 0)
|
|
483
|
+
return false;
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
const step = pendingCount ?? 1;
|
|
486
|
+
const target = focused < 0 ? items.length - step : focused - step;
|
|
487
|
+
apply(items, wrapIndex(target, items.length, wrap));
|
|
488
|
+
clearPending();
|
|
489
|
+
if (selectable && visualMode)
|
|
490
|
+
extendVisualRange(items);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
case 'Enter':
|
|
494
|
+
case ' ': {
|
|
495
|
+
if (focused < 0)
|
|
496
|
+
return false;
|
|
497
|
+
const el = items[focused];
|
|
498
|
+
if (!el)
|
|
499
|
+
return false;
|
|
500
|
+
e.preventDefault();
|
|
501
|
+
clearPending();
|
|
502
|
+
opts.onSelect?.(el, focused);
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
case 'Escape': {
|
|
506
|
+
// Cancel a half-typed motion first — matches vim's behaviour
|
|
507
|
+
// where Esc drops the count prefix without moving the cursor.
|
|
508
|
+
if (pendingCount !== null || pendingG || pendingZ) {
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
clearPending();
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
// Selection cascade: visual mode → selection → focus / onEscape.
|
|
514
|
+
// Each Esc backs out one layer so users can stop extending without
|
|
515
|
+
// losing what they've already selected, then Esc again to clear.
|
|
516
|
+
if (selectable && visualMode) {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
exitVisualMode();
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
if (selectable && selected.size > 0) {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
selected.clear();
|
|
524
|
+
emitSelectionChange();
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
if (focused < 0 && !opts.onEscape)
|
|
528
|
+
return false;
|
|
529
|
+
e.preventDefault();
|
|
530
|
+
if (opts.onEscape)
|
|
531
|
+
opts.onEscape();
|
|
532
|
+
else
|
|
533
|
+
clear();
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Any other key terminates a half-typed motion so `gx`, `5x`, or
|
|
538
|
+
// `zx` don't leave stale state around.
|
|
539
|
+
if (pendingCount !== null || pendingG || pendingZ)
|
|
540
|
+
clearPending();
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
handleKeydown,
|
|
545
|
+
focusedIndex: () => focused,
|
|
546
|
+
focus,
|
|
547
|
+
focusFirst,
|
|
548
|
+
focusLast,
|
|
549
|
+
clear,
|
|
550
|
+
refresh,
|
|
551
|
+
selectedIds: () => new Set(selected),
|
|
552
|
+
toggleSelection: (navId) => {
|
|
553
|
+
const items = resolveItems(optsRef);
|
|
554
|
+
if (navId === undefined) {
|
|
555
|
+
if (focused < 0)
|
|
556
|
+
return;
|
|
557
|
+
toggleSelectionByIndex(items, focused);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (selected.has(navId))
|
|
561
|
+
selected.delete(navId);
|
|
562
|
+
else
|
|
563
|
+
selected.add(navId);
|
|
564
|
+
emitSelectionChange();
|
|
565
|
+
},
|
|
566
|
+
setSelection: (ids) => {
|
|
567
|
+
selected.clear();
|
|
568
|
+
for (const id of ids)
|
|
569
|
+
selected.add(id);
|
|
570
|
+
emitSelectionChange();
|
|
571
|
+
},
|
|
572
|
+
clearSelection: () => {
|
|
573
|
+
if (selected.size === 0 && !visualMode)
|
|
574
|
+
return;
|
|
575
|
+
selected.clear();
|
|
576
|
+
exitVisualMode();
|
|
577
|
+
emitSelectionChange();
|
|
578
|
+
},
|
|
579
|
+
selectAll: () => {
|
|
580
|
+
const items = resolveItems(optsRef);
|
|
581
|
+
for (const el of items) {
|
|
582
|
+
const id = getId(el);
|
|
583
|
+
if (id !== null)
|
|
584
|
+
selected.add(id);
|
|
585
|
+
}
|
|
586
|
+
emitSelectionChange();
|
|
587
|
+
},
|
|
588
|
+
isSelecting: () => visualMode,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Imperative composable — use when you already own a keydown handler and want
|
|
593
|
+
* listNav to decide whether to consume the event.
|
|
594
|
+
*/
|
|
595
|
+
export function createListNav(options) {
|
|
596
|
+
const ref = { current: options };
|
|
597
|
+
return makeController(ref);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Svelte action — attaches a keydown listener (window by default) and handles
|
|
601
|
+
* j/k/ArrowDown/ArrowUp/Enter/Space/Escape navigation over children of `node`.
|
|
602
|
+
*/
|
|
603
|
+
export const listNav = (node, initial = {}) => {
|
|
604
|
+
const ref = { current: withDefaultGetItems(node, initial) };
|
|
605
|
+
const controller = makeController(ref);
|
|
606
|
+
let target = ref.current.global === false ? node : window;
|
|
607
|
+
function onKeydown(e) {
|
|
608
|
+
controller.handleKeydown(e);
|
|
609
|
+
}
|
|
610
|
+
function attach() {
|
|
611
|
+
target = ref.current.global === false ? node : window;
|
|
612
|
+
target.addEventListener('keydown', onKeydown);
|
|
613
|
+
}
|
|
614
|
+
function detach() {
|
|
615
|
+
target.removeEventListener('keydown', onKeydown);
|
|
616
|
+
}
|
|
617
|
+
attach();
|
|
618
|
+
return {
|
|
619
|
+
update(next = {}) {
|
|
620
|
+
const wasGlobal = ref.current.global !== false;
|
|
621
|
+
ref.current = withDefaultGetItems(node, next);
|
|
622
|
+
const isGlobal = ref.current.global !== false;
|
|
623
|
+
if (wasGlobal !== isGlobal) {
|
|
624
|
+
detach();
|
|
625
|
+
attach();
|
|
626
|
+
}
|
|
627
|
+
controller.refresh();
|
|
628
|
+
},
|
|
629
|
+
destroy() {
|
|
630
|
+
detach();
|
|
631
|
+
controller.clear();
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
function withDefaultGetItems(node, options) {
|
|
636
|
+
if (options.getItems)
|
|
637
|
+
return options;
|
|
638
|
+
const selector = options.itemSelector ?? '[data-nav-id]';
|
|
639
|
+
return {
|
|
640
|
+
...options,
|
|
641
|
+
getItems: () => Array.from(node.querySelectorAll(selector)),
|
|
642
|
+
};
|
|
643
|
+
}
|