@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.
Files changed (37) hide show
  1. package/CLAUDE.md +3 -3
  2. package/LICENSE +21 -0
  3. package/dist/actions/collapse.d.ts +1 -1
  4. package/dist/actions/collapse.js +16 -30
  5. package/dist/actions/listNav.d.ts +134 -0
  6. package/dist/actions/listNav.js +643 -0
  7. package/dist/actions/railNav.js +78 -89
  8. package/dist/components/InlineEdit.svelte +26 -1
  9. package/dist/components/InlineEdit.svelte.d.ts +13 -0
  10. package/dist/components/PhoneInput.svelte +70 -20
  11. package/dist/components/PhoneInput.svelte.d.ts +5 -2
  12. package/dist/components/ResizableDivider.svelte +148 -18
  13. package/dist/components/ResizableDivider.svelte.d.ts +14 -3
  14. package/dist/components/SignaturePad.svelte +13 -6
  15. package/dist/components/SignaturePad.svelte.d.ts +1 -0
  16. package/dist/components/ThemeSelector.svelte +61 -2
  17. package/dist/components/ThemeSelector.svelte.d.ts +4 -0
  18. package/dist/components/markup.js +59 -62
  19. package/dist/components/session-nav/transcriptModel.js +49 -66
  20. package/dist/data/emojis.js +4 -4
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1 -0
  23. package/dist/stores/confirmDialog.svelte.js +5 -5
  24. package/dist/stores/inputDialog.svelte.js +5 -5
  25. package/dist/types/booking.js +4 -4
  26. package/dist/types/comments.js +2 -2
  27. package/dist/types/messaging.js +3 -3
  28. package/dist/types/pipeline.js +2 -2
  29. package/dist/utils/currency.js +2 -4
  30. package/dist/utils/daisyuiColors.js +25 -27
  31. package/dist/utils/dateFormatters.js +44 -44
  32. package/dist/utils/googleMapsLoader.js +46 -100
  33. package/dist/utils/mentionParser.js +30 -30
  34. package/dist/utils/phone.js +21 -27
  35. package/dist/utils/phoneFormat.js +9 -9
  36. package/dist/utils/taskUtils.js +1 -1
  37. 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
+ }