@joewinke/jatui 0.1.27 → 0.1.28

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