@joewinke/jatui 0.26.0 → 0.27.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.
Files changed (73) hide show
  1. package/CLAUDE.md +106 -1
  2. package/dist/actions/keyboardScope.d.ts +58 -0
  3. package/dist/actions/keyboardScope.js +110 -0
  4. package/dist/actions/listNav.d.ts +48 -3
  5. package/dist/actions/listNav.js +267 -77
  6. package/dist/actions/longpress.d.ts +37 -0
  7. package/dist/actions/longpress.js +105 -0
  8. package/dist/actions/stickToBottom.d.ts +66 -0
  9. package/dist/actions/stickToBottom.js +155 -0
  10. package/dist/actions/swipe.d.ts +64 -0
  11. package/dist/actions/swipe.js +179 -0
  12. package/dist/components/BottomSheet.svelte +280 -0
  13. package/dist/components/BottomSheet.svelte.d.ts +27 -0
  14. package/dist/components/ConfirmDialog.svelte +21 -0
  15. package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
  16. package/dist/components/ConfirmModal.svelte +15 -0
  17. package/dist/components/ConfirmModal.svelte.d.ts +5 -0
  18. package/dist/components/Drawer.svelte +18 -1
  19. package/dist/components/Drawer.svelte.d.ts +9 -1
  20. package/dist/components/InputDialog.svelte +21 -0
  21. package/dist/components/InputDialog.svelte.d.ts +11 -1
  22. package/dist/components/Modal.svelte +18 -1
  23. package/dist/components/Modal.svelte.d.ts +8 -0
  24. package/dist/components/basic/Empty.svelte +173 -9
  25. package/dist/components/basic/Empty.svelte.d.ts +50 -1
  26. package/dist/components/basic/ErrorState.svelte +128 -0
  27. package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
  28. package/dist/components/basic/JumpToLatest.svelte +60 -0
  29. package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
  30. package/dist/components/basic/PageHeader.svelte +216 -0
  31. package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
  32. package/dist/components/basic/ProgressButton.svelte +221 -0
  33. package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
  34. package/dist/components/graph/GraphApiBridge.svelte +36 -0
  35. package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
  36. package/dist/components/graph/GraphCanvas.svelte +212 -27
  37. package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
  38. package/dist/components/graph/api.d.ts +52 -0
  39. package/dist/components/graph/api.js +66 -0
  40. package/dist/components/graph/index.d.ts +2 -0
  41. package/dist/components/graph/index.js +1 -0
  42. package/dist/components/graph/theme.js +13 -0
  43. package/dist/components/messaging/MessageItem.svelte +5 -0
  44. package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
  45. package/dist/components/table/DataTable.svelte +452 -78
  46. package/dist/components/table/DataTable.svelte.d.ts +2 -2
  47. package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
  48. package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
  49. package/dist/components/table/columnStorage.d.ts +29 -0
  50. package/dist/components/table/columnStorage.js +61 -0
  51. package/dist/components/table/groupRows.d.ts +72 -0
  52. package/dist/components/table/groupRows.js +118 -0
  53. package/dist/components/table/index.d.ts +7 -1
  54. package/dist/components/table/index.js +3 -0
  55. package/dist/components/table/types.d.ts +121 -20
  56. package/dist/index.d.ts +25 -2
  57. package/dist/index.js +32 -1
  58. package/dist/utils/ansiToHtml.d.ts +83 -0
  59. package/dist/utils/ansiToHtml.js +543 -0
  60. package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
  61. package/dist/utils/keyboardShortcutRegistry.js +1070 -76
  62. package/dist/utils/pollBackoff.d.ts +83 -0
  63. package/dist/utils/pollBackoff.js +151 -0
  64. package/dist/utils/snapFlick.d.ts +59 -0
  65. package/dist/utils/snapFlick.js +66 -0
  66. package/dist/video/advisors/qr.d.ts +1 -1
  67. package/dist/virtual/createRowWindow.d.ts +75 -0
  68. package/dist/virtual/createRowWindow.js +129 -0
  69. package/dist/virtual/index.d.ts +4 -0
  70. package/dist/virtual/index.js +5 -0
  71. package/dist/virtual/rowWindowCore.d.ts +202 -0
  72. package/dist/virtual/rowWindowCore.js +510 -0
  73. package/package.json +11 -1
@@ -61,6 +61,29 @@
61
61
  * if (nav.handleKeydown(e)) return;
62
62
  * // ...page-specific shortcuts
63
63
  * }
64
+ *
65
+ * Windowed / virtualised lists (`getOrderedIds` + `revealItem`):
66
+ *
67
+ * A windowed list only renders the rows near the viewport, so a DOM query
68
+ * stops j/k at the window edge and `*` misses every unrendered row. Pass
69
+ * `getOrderedIds` and the controller navigates over that id list instead;
70
+ * the DOM query (or `getItems`) is then only used to find the elements that
71
+ * ARE rendered, so the focus/selection classes can be painted on them.
72
+ * When the cursor lands on an id with no rendered element, `revealItem(id)`
73
+ * is called to scroll it into the window; the focus class is painted once
74
+ * the row mounts (same frame if the reveal renders synchronously, otherwise
75
+ * on a following animation frame, or on the next `refresh()`).
76
+ *
77
+ * <div use:listNav={{
78
+ * getOrderedIds: () => table.orderedRowIds(),
79
+ * revealItem: (id) => table.scrollToRow(id),
80
+ * onSelect: (el) => openDetail(el.dataset.navId!),
81
+ * }}>
82
+ * <!-- only the windowed rows, each with data-nav-id -->
83
+ * </div>
84
+ *
85
+ * Enter/Space need a rendered element (`onSelect` receives it), so they
86
+ * pass through when the focused row has been scrolled out of the window.
64
87
  */
65
88
  /** How long a half-typed motion (count prefix, pending `g`, pending `z`) waits
66
89
  * for its completion before resetting. Matches Vim's default `timeoutlen`. */
@@ -114,14 +137,12 @@ function wrapIndex(next, length, wrap) {
114
137
  }
115
138
  /**
116
139
  * Estimate how many list items fit in one 'page' of the nearest scrollable
117
- * ancestor, then halve it. Used for Ctrl+D / Ctrl+U. Returns {@link DEFAULT_HALF_PAGE}
118
- * when layout isn't available (SSR, jsdom, detached elements).
140
+ * ancestor, then halve it. Used for Ctrl+D / Ctrl+U. `pivot` is a rendered
141
+ * item to measure. Returns {@link DEFAULT_HALF_PAGE} when layout isn't
142
+ * available (SSR, jsdom, detached elements, nothing rendered).
119
143
  */
120
- function getHalfPageSize(items, currentIdx) {
121
- if (items.length === 0)
122
- return 0;
144
+ function getHalfPageSize(pivot) {
123
145
  try {
124
- const pivot = items[Math.max(0, Math.min(items.length - 1, currentIdx))] ?? items[0];
125
146
  if (!pivot || typeof window === 'undefined')
126
147
  return DEFAULT_HALF_PAGE;
127
148
  // Walk up looking for an ancestor that is actually scrolling.
@@ -147,9 +168,14 @@ function getHalfPageSize(items, currentIdx) {
147
168
  }
148
169
  return DEFAULT_HALF_PAGE;
149
170
  }
171
+ /** How many animation frames a revealed row gets to mount before the
172
+ * controller stops polling for it (a later `refresh()` still paints it). */
173
+ const REVEAL_MAX_FRAMES = 10;
150
174
  function makeController(optsRef) {
151
175
  let focused = -1;
176
+ let focusedItemId = null;
152
177
  let lastEl = null;
178
+ let revealTimer = null;
153
179
  // Motion-buffer state. A motion is 'pending' until its follow-up key
154
180
  // arrives (e.g. the second `g` in `gg`) or the buffer times out.
155
181
  let pendingCount = null;
@@ -194,6 +220,57 @@ function makeController(optsRef) {
194
220
  const raw = resolver ? resolver(el) : el.dataset.navId;
195
221
  return raw == null ? null : String(raw);
196
222
  }
223
+ function resolveList() {
224
+ const items = resolveItems(optsRef);
225
+ const getOrderedIds = optsRef.current.getOrderedIds;
226
+ if (!getOrderedIds) {
227
+ return {
228
+ ordered: false,
229
+ length: items.length,
230
+ idAt: (i) => getId(items[i] ?? null),
231
+ el: (i) => items[i] ?? null,
232
+ indexOfId: (id) => items.findIndex((el) => getId(el) === id),
233
+ rendered: items,
234
+ };
235
+ }
236
+ const ids = Array.from(getOrderedIds());
237
+ // Lazily built: a plain j/k on a long list only ever needs one lookup.
238
+ let byId = null;
239
+ const renderedById = () => {
240
+ if (byId)
241
+ return byId;
242
+ byId = new Map();
243
+ for (const el of items) {
244
+ const id = getId(el);
245
+ if (id !== null && !byId.has(id))
246
+ byId.set(id, el);
247
+ }
248
+ return byId;
249
+ };
250
+ return {
251
+ ordered: true,
252
+ length: ids.length,
253
+ idAt: (i) => ids[i] ?? null,
254
+ el: (i) => {
255
+ const id = ids[i];
256
+ return id === undefined ? null : (renderedById().get(id) ?? null);
257
+ },
258
+ indexOfId: (id) => ids.indexOf(id),
259
+ rendered: items,
260
+ };
261
+ }
262
+ /** Find the rendered element for `id`, re-querying the DOM. */
263
+ function findRendered(id) {
264
+ for (const el of resolveItems(optsRef)) {
265
+ if (getId(el) === id)
266
+ return el;
267
+ }
268
+ return null;
269
+ }
270
+ function cancelReveal() {
271
+ revealTimer?.cancel();
272
+ revealTimer = null;
273
+ }
197
274
  function applySelectionClasses(items) {
198
275
  const cls = selectedClass();
199
276
  for (const el of items) {
@@ -208,25 +285,101 @@ function makeController(optsRef) {
208
285
  applySelectionClasses(resolveItems(optsRef));
209
286
  optsRef.current.onSelectionChange?.(new Set(selected));
210
287
  }
211
- function apply(items, idx) {
288
+ /** Move the focused class onto `next` (no scrolling). */
289
+ function paint(next) {
212
290
  const cls = focusedClass();
213
- const next = items[idx] ?? null;
214
291
  if (lastEl && lastEl !== next)
215
292
  lastEl.classList.remove(cls);
216
- if (next) {
293
+ if (next)
217
294
  next.classList.add(cls);
218
- next.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
219
- }
220
295
  lastEl = next;
221
- focused = next ? idx : -1;
222
- optsRef.current.onFocusChange?.(next, focused);
296
+ }
297
+ /**
298
+ * Ordered mode: the focused id was revealed but its row has not mounted
299
+ * yet (the window renders on a later tick). Poll a few frames for it, then
300
+ * paint it and re-announce the focus with the element.
301
+ */
302
+ function awaitReveal(id) {
303
+ cancelReveal();
304
+ const raf = typeof requestAnimationFrame !== 'undefined'
305
+ ? (fn) => {
306
+ const h = requestAnimationFrame(fn);
307
+ return () => cancelAnimationFrame(h);
308
+ }
309
+ : typeof setTimeout !== 'undefined'
310
+ ? (fn) => {
311
+ const h = setTimeout(fn, 16);
312
+ return () => clearTimeout(h);
313
+ }
314
+ : null;
315
+ if (!raf)
316
+ return;
317
+ let frames = 0;
318
+ let cancelFrame = () => { };
319
+ const tick = () => {
320
+ if (focusedItemId !== id)
321
+ return;
322
+ const el = findRendered(id);
323
+ if (el) {
324
+ revealTimer = null;
325
+ paint(el);
326
+ applySelectionClasses(resolveItems(optsRef));
327
+ optsRef.current.onFocusChange?.(el, focused, id);
328
+ return;
329
+ }
330
+ if (++frames >= REVEAL_MAX_FRAMES) {
331
+ revealTimer = null;
332
+ return;
333
+ }
334
+ cancelFrame = raf(tick);
335
+ };
336
+ cancelFrame = raf(tick);
337
+ revealTimer = { cancel: () => cancelFrame() };
338
+ }
339
+ function apply(list, idx) {
340
+ cancelReveal();
341
+ const inRange = idx >= 0 && idx < list.length;
342
+ const id = inRange ? list.idAt(idx) : null;
343
+ let next = inRange ? list.el(idx) : null;
344
+ let pendingReveal = false;
345
+ if (!next && inRange && list.ordered && id !== null) {
346
+ const reveal = optsRef.current.revealItem;
347
+ if (reveal) {
348
+ reveal(id);
349
+ // A synchronous reveal may already have mounted the row.
350
+ next = findRendered(id);
351
+ pendingReveal = next === null;
352
+ }
353
+ }
354
+ paint(next);
355
+ next?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
356
+ // DOM mode only ever focuses a real element; ordered mode keeps the
357
+ // cursor on an id even while its row is outside the rendered window.
358
+ focused = list.ordered ? (inRange ? idx : -1) : next ? idx : -1;
359
+ focusedItemId = focused >= 0 ? id : null;
360
+ optsRef.current.onFocusChange?.(next, focused, focusedItemId);
361
+ if (pendingReveal && id !== null)
362
+ awaitReveal(id);
363
+ }
364
+ /**
365
+ * Ordered mode: re-derive the cursor's position from its id, since the id
366
+ * list may have been re-sorted or filtered since the last keypress.
367
+ */
368
+ function syncFocus(list) {
369
+ if (!list.ordered || focused < 0)
370
+ return;
371
+ const idx = focusedItemId !== null ? list.indexOfId(focusedItemId) : -1;
372
+ if (idx >= 0)
373
+ focused = idx;
374
+ else if (focused >= list.length)
375
+ focused = list.length - 1;
223
376
  }
224
377
  /**
225
378
  * Re-derive the selected set from the inclusive range [anchor..focus] while
226
379
  * visual mode is active. Items outside the range that were selected before
227
380
  * V was pressed are preserved (additive — Vim's `V` extends, doesn't reset).
228
381
  */
229
- function extendVisualRange(items) {
382
+ function extendVisualRange(list) {
230
383
  if (!visualMode || visualAnchor < 0 || focused < 0)
231
384
  return;
232
385
  const lo = Math.min(visualAnchor, focused);
@@ -235,7 +388,7 @@ function makeController(optsRef) {
235
388
  // existing pre-V selections survive moving the anchor to a new region.
236
389
  let mutated = false;
237
390
  for (let i = lo; i <= hi; i++) {
238
- const id = getId(items[i] ?? null);
391
+ const id = list.idAt(i);
239
392
  if (id !== null && !selected.has(id)) {
240
393
  selected.add(id);
241
394
  mutated = true;
@@ -244,50 +397,73 @@ function makeController(optsRef) {
244
397
  if (mutated)
245
398
  emitSelectionChange();
246
399
  else
247
- applySelectionClasses(items);
400
+ applySelectionClasses(list.rendered);
248
401
  }
249
402
  function focus(idx) {
250
- const items = resolveItems(optsRef);
251
- if (items.length === 0)
403
+ const list = resolveList();
404
+ if (list.length === 0)
252
405
  return;
253
- apply(items, Math.max(0, Math.min(items.length - 1, idx)));
406
+ apply(list, Math.max(0, Math.min(list.length - 1, idx)));
254
407
  }
255
408
  function focusFirst() {
256
409
  focus(0);
257
410
  }
258
411
  function focusLast() {
259
- const items = resolveItems(optsRef);
260
- if (items.length)
261
- apply(items, items.length - 1);
412
+ const list = resolveList();
413
+ if (list.length)
414
+ apply(list, list.length - 1);
262
415
  }
263
416
  function clear() {
417
+ cancelReveal();
264
418
  if (lastEl)
265
419
  lastEl.classList.remove(focusedClass());
266
420
  lastEl = null;
267
421
  focused = -1;
422
+ focusedItemId = null;
268
423
  clearPending();
269
- optsRef.current.onFocusChange?.(null, -1);
424
+ optsRef.current.onFocusChange?.(null, -1, null);
270
425
  }
271
426
  function refresh() {
272
- const items = resolveItems(optsRef);
273
- if (items.length === 0) {
427
+ const list = resolveList();
428
+ if (list.length === 0) {
274
429
  clear();
275
430
  return;
276
431
  }
277
- // Re-home the focused element if the old node has been removed.
278
- if (lastEl && !items.includes(lastEl)) {
279
- const newIdx = Math.min(focused, items.length - 1);
280
- apply(items, Math.max(0, newIdx));
432
+ if (list.ordered) {
433
+ // Repaint only — never reveal or scroll here: refresh() runs as the
434
+ // window re-renders during the user's own scrolling, and yanking the
435
+ // viewport back to the cursor would fight them.
436
+ const idx = focusedItemId !== null ? list.indexOfId(focusedItemId) : -1;
437
+ if (idx >= 0) {
438
+ focused = idx;
439
+ const el = list.el(idx);
440
+ if (el !== lastEl) {
441
+ paint(el);
442
+ if (el) {
443
+ cancelReveal();
444
+ optsRef.current.onFocusChange?.(el, focused, focusedItemId);
445
+ }
446
+ }
447
+ }
448
+ else if (focused >= 0) {
449
+ // The focused id left the list (filtered out): re-home like DOM mode.
450
+ apply(list, Math.min(focused, list.length - 1));
451
+ }
452
+ }
453
+ else if (lastEl && !list.rendered.includes(lastEl)) {
454
+ // Re-home the focused element if the old node has been removed.
455
+ const newIdx = Math.min(focused, list.length - 1);
456
+ apply(list, Math.max(0, newIdx));
281
457
  }
282
- else if (focused >= 0 && focused < items.length) {
283
- apply(items, focused);
458
+ else if (focused >= 0 && focused < list.length) {
459
+ apply(list, focused);
284
460
  }
285
461
  // Re-paint selection classes — items may have been re-rendered, so the
286
462
  // CSS class needs to be re-applied to the new DOM nodes whose ids match.
287
- applySelectionClasses(items);
463
+ applySelectionClasses(list.rendered);
288
464
  }
289
- function toggleSelectionByIndex(items, idx) {
290
- const id = getId(items[idx] ?? null);
465
+ function toggleSelectionByIndex(list, idx) {
466
+ const id = list.idAt(idx);
291
467
  if (id === null)
292
468
  return;
293
469
  if (selected.has(id))
@@ -306,7 +482,8 @@ function makeController(optsRef) {
306
482
  return false;
307
483
  if (isTypingTarget(e.target))
308
484
  return false;
309
- const items = resolveItems(optsRef);
485
+ const list = resolveList();
486
+ syncFocus(list);
310
487
  const wrap = opts.wraparound ?? true;
311
488
  const selectable = opts.selectable === true;
312
489
  // --- Selection layer (opt-in) ----------------------------------------
@@ -317,7 +494,7 @@ function makeController(optsRef) {
317
494
  // selects the focused item; second press exits visual mode while
318
495
  // keeping the existing selection (Vim parity for re-entry).
319
496
  if (e.key === 'V') {
320
- if (items.length === 0)
497
+ if (list.length === 0)
321
498
  return false;
322
499
  e.preventDefault();
323
500
  if (visualMode) {
@@ -325,13 +502,13 @@ function makeController(optsRef) {
325
502
  }
326
503
  else {
327
504
  if (focused < 0)
328
- apply(items, 0);
505
+ apply(list, 0);
329
506
  visualMode = true;
330
507
  visualAnchor = focused;
331
- toggleSelectionByIndex(items, focused);
508
+ toggleSelectionByIndex(list, focused);
332
509
  // If the toggle deselected the anchor, re-add it — entering
333
510
  // visual mode should always include the anchor.
334
- const anchorId = getId(items[focused] ?? null);
511
+ const anchorId = list.idAt(focused);
335
512
  if (anchorId !== null && !selected.has(anchorId)) {
336
513
  selected.add(anchorId);
337
514
  emitSelectionChange();
@@ -342,21 +519,22 @@ function makeController(optsRef) {
342
519
  }
343
520
  // x — toggle selection of the focused item (no movement, no visual).
344
521
  if (e.key === 'x' && !e.shiftKey) {
345
- if (focused < 0 || items.length === 0)
522
+ if (focused < 0 || list.length === 0)
346
523
  return false;
347
524
  e.preventDefault();
348
- toggleSelectionByIndex(items, focused);
525
+ toggleSelectionByIndex(list, focused);
349
526
  clearPending();
350
527
  return true;
351
528
  }
352
- // * — toggle select-all of currently visible items.
529
+ // * — toggle select-all of the list (every id, rendered or not, when
530
+ // `getOrderedIds` is given; otherwise the rendered items).
353
531
  if (e.key === '*') {
354
- if (items.length === 0)
532
+ if (list.length === 0)
355
533
  return false;
356
534
  e.preventDefault();
357
535
  const allIds = [];
358
- for (const el of items) {
359
- const id = getId(el);
536
+ for (let i = 0; i < list.length; i++) {
537
+ const id = list.idAt(i);
360
538
  if (id !== null)
361
539
  allIds.push(id);
362
540
  }
@@ -377,17 +555,17 @@ function makeController(optsRef) {
377
555
  // Preserves the existing /triage idiom (range-build without entering
378
556
  // visual mode). Always wraps within bounds (no wraparound for safety).
379
557
  if (e.key === 'J' || e.key === 'K') {
380
- if (items.length === 0)
558
+ if (list.length === 0)
381
559
  return false;
382
560
  e.preventDefault();
383
561
  const dir = e.key === 'J' ? 1 : -1;
384
- const before = focused < 0 ? (dir > 0 ? 0 : items.length - 1) : focused;
385
- const beforeId = getId(items[before] ?? null);
562
+ const before = focused < 0 ? (dir > 0 ? 0 : list.length - 1) : focused;
563
+ const beforeId = list.idAt(before);
386
564
  if (beforeId !== null)
387
565
  selected.add(beforeId);
388
- const afterIdx = Math.max(0, Math.min(items.length - 1, before + dir));
389
- apply(items, afterIdx);
390
- const afterId = getId(items[afterIdx] ?? null);
566
+ const afterIdx = Math.max(0, Math.min(list.length - 1, before + dir));
567
+ apply(list, afterIdx);
568
+ const afterId = list.idAt(afterIdx);
391
569
  if (afterId !== null)
392
570
  selected.add(afterId);
393
571
  emitSelectionChange();
@@ -401,13 +579,16 @@ function makeController(optsRef) {
401
579
  if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
402
580
  const lower = e.key.toLowerCase();
403
581
  if (lower === 'd' || lower === 'u') {
404
- if (items.length === 0)
582
+ if (list.length === 0)
405
583
  return false;
406
584
  e.preventDefault();
407
- const half = getHalfPageSize(items, focused);
585
+ const pivot = list.el(Math.max(0, Math.min(list.length - 1, focused))) ??
586
+ list.rendered[0] ??
587
+ null;
588
+ const half = getHalfPageSize(pivot);
408
589
  const dir = lower === 'd' ? 1 : -1;
409
- const start = focused < 0 ? (dir > 0 ? 0 : items.length - 1) : focused;
410
- apply(items, wrapIndex(start + dir * half, items.length, wrap));
590
+ const start = focused < 0 ? (dir > 0 ? 0 : list.length - 1) : focused;
591
+ apply(list, wrapIndex(start + dir * half, list.length, wrap));
411
592
  clearPending();
412
593
  return true;
413
594
  }
@@ -431,16 +612,16 @@ function makeController(optsRef) {
431
612
  }
432
613
  // gg — jump to first item (or to line {count} when a count is pending).
433
614
  if (e.key === 'g' && !e.shiftKey) {
434
- if (items.length === 0) {
615
+ if (list.length === 0) {
435
616
  clearPending();
436
617
  return false;
437
618
  }
438
619
  e.preventDefault();
439
620
  if (pendingG) {
440
621
  const target = pendingCount !== null
441
- ? Math.max(0, Math.min(items.length - 1, pendingCount - 1))
622
+ ? Math.max(0, Math.min(list.length - 1, pendingCount - 1))
442
623
  : 0;
443
- apply(items, target);
624
+ apply(list, target);
444
625
  clearPending();
445
626
  }
446
627
  else {
@@ -452,15 +633,15 @@ function makeController(optsRef) {
452
633
  }
453
634
  // G — jump to last item (or to line {count}).
454
635
  if (e.key === 'G') {
455
- if (items.length === 0) {
636
+ if (list.length === 0) {
456
637
  clearPending();
457
638
  return false;
458
639
  }
459
640
  e.preventDefault();
460
641
  const target = pendingCount !== null
461
- ? Math.max(0, Math.min(items.length - 1, pendingCount - 1))
462
- : items.length - 1;
463
- apply(items, target);
642
+ ? Math.max(0, Math.min(list.length - 1, pendingCount - 1))
643
+ : list.length - 1;
644
+ apply(list, target);
464
645
  clearPending();
465
646
  return true;
466
647
  }
@@ -468,8 +649,13 @@ function makeController(optsRef) {
468
649
  if (e.key === 'z' && !e.shiftKey) {
469
650
  e.preventDefault();
470
651
  if (pendingZ) {
471
- const el = lastEl ?? items[focused] ?? null;
472
- el?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
652
+ // Ordered mode: lastEl may be a row the window has since unmounted.
653
+ const el = list.ordered ? list.el(focused) : (lastEl ?? list.el(focused));
654
+ if (el)
655
+ el.scrollIntoView?.({ block: 'center', inline: 'nearest' });
656
+ else if (list.ordered && focusedItemId !== null) {
657
+ opts.revealItem?.(focusedItemId);
658
+ }
473
659
  clearPending();
474
660
  }
475
661
  else {
@@ -482,37 +668,39 @@ function makeController(optsRef) {
482
668
  switch (e.key) {
483
669
  case 'j':
484
670
  case 'ArrowDown': {
485
- if (items.length === 0)
671
+ if (list.length === 0)
486
672
  return false;
487
673
  e.preventDefault();
488
674
  const step = pendingCount ?? 1;
489
675
  // From an unfocused state, `{n}j` selects the n-th item
490
676
  // (1-indexed) so it mirrors `{n}G` from the top.
491
677
  const target = focused < 0 ? step - 1 : focused + step;
492
- apply(items, wrapIndex(target, items.length, wrap));
678
+ apply(list, wrapIndex(target, list.length, wrap));
493
679
  clearPending();
494
680
  if (selectable && visualMode)
495
- extendVisualRange(items);
681
+ extendVisualRange(list);
496
682
  return true;
497
683
  }
498
684
  case 'k':
499
685
  case 'ArrowUp': {
500
- if (items.length === 0)
686
+ if (list.length === 0)
501
687
  return false;
502
688
  e.preventDefault();
503
689
  const step = pendingCount ?? 1;
504
- const target = focused < 0 ? items.length - step : focused - step;
505
- apply(items, wrapIndex(target, items.length, wrap));
690
+ const target = focused < 0 ? list.length - step : focused - step;
691
+ apply(list, wrapIndex(target, list.length, wrap));
506
692
  clearPending();
507
693
  if (selectable && visualMode)
508
- extendVisualRange(items);
694
+ extendVisualRange(list);
509
695
  return true;
510
696
  }
511
697
  case 'Enter':
512
698
  case ' ': {
513
699
  if (focused < 0)
514
700
  return false;
515
- const el = items[focused];
701
+ // Ordered mode: no element while the focused row is scrolled out of
702
+ // the window — onSelect needs one, so the key passes through.
703
+ const el = list.el(focused);
516
704
  if (!el)
517
705
  return false;
518
706
  // Focus honesty (mos-5lzns.59): when real DOM focus sits on a
@@ -573,13 +761,15 @@ function makeController(optsRef) {
573
761
  focusLast,
574
762
  clear,
575
763
  refresh,
764
+ focusedId: () => focusedItemId,
576
765
  selectedIds: () => new Set(selected),
577
766
  toggleSelection: (navId) => {
578
- const items = resolveItems(optsRef);
579
767
  if (navId === undefined) {
580
768
  if (focused < 0)
581
769
  return;
582
- toggleSelectionByIndex(items, focused);
770
+ const list = resolveList();
771
+ syncFocus(list);
772
+ toggleSelectionByIndex(list, focused);
583
773
  return;
584
774
  }
585
775
  if (selected.has(navId))
@@ -602,9 +792,9 @@ function makeController(optsRef) {
602
792
  emitSelectionChange();
603
793
  },
604
794
  selectAll: () => {
605
- const items = resolveItems(optsRef);
606
- for (const el of items) {
607
- const id = getId(el);
795
+ const list = resolveList();
796
+ for (let i = 0; i < list.length; i++) {
797
+ const id = list.idAt(i);
608
798
  if (id !== null)
609
799
  selected.add(id);
610
800
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Long-press + right-click Svelte action.
3
+ *
4
+ * Opens a context menu on either gesture:
5
+ * - Touch: press and hold past `duration` ms without moving more than `moveTolerance`
6
+ * px (a drag/scroll cancels it). Fires once; a subsequent scroll/tap is unaffected.
7
+ * - Mouse/pen: the native `contextmenu` event (right-click), suppressed so the browser
8
+ * menu never shows.
9
+ *
10
+ * The callback receives the coordinates of the gesture so the caller can anchor a menu
11
+ * (though the mobile feed uses a fixed bottom-sheet and ignores them). A light haptic
12
+ * pulse fires on touch long-press where `navigator.vibrate` is available.
13
+ *
14
+ * Deliberately NOT built on lib/actions/swipe.ts: swipe tracks horizontal drags and
15
+ * commits on release, whereas long-press must fire mid-hold and be cancelled by any
16
+ * movement. They observe the same touch stream with opposite intent, so a card can use
17
+ * both — swipe preventDefault only kicks in once a horizontal drag is recognized, which
18
+ * is exactly the movement that cancels a long-press.
19
+ */
20
+ export interface LongPressOptions {
21
+ /** Fires on long-press (touch) or right-click (mouse). */
22
+ onLongPress?: (detail: {
23
+ x: number;
24
+ y: number;
25
+ source: 'touch' | 'mouse';
26
+ }) => void;
27
+ /** Hold time in ms before a touch press counts as a long-press. Default 450. */
28
+ duration?: number;
29
+ /** Movement (px) that cancels an in-progress touch long-press. Default 10. */
30
+ moveTolerance?: number;
31
+ /** When false, both gestures no-op (listeners stay attached for cheap toggling). */
32
+ enabled?: boolean;
33
+ }
34
+ export declare function longpress(node: HTMLElement, initial?: LongPressOptions): {
35
+ update(next: LongPressOptions): void;
36
+ destroy(): void;
37
+ };