@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.
- package/CLAUDE.md +106 -1
- package/dist/actions/keyboardScope.d.ts +58 -0
- package/dist/actions/keyboardScope.js +110 -0
- package/dist/actions/listNav.d.ts +48 -3
- package/dist/actions/listNav.js +267 -77
- package/dist/actions/longpress.d.ts +37 -0
- package/dist/actions/longpress.js +105 -0
- package/dist/actions/stickToBottom.d.ts +66 -0
- package/dist/actions/stickToBottom.js +155 -0
- package/dist/actions/swipe.d.ts +64 -0
- package/dist/actions/swipe.js +179 -0
- package/dist/components/BottomSheet.svelte +280 -0
- package/dist/components/BottomSheet.svelte.d.ts +27 -0
- package/dist/components/ConfirmDialog.svelte +21 -0
- package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
- package/dist/components/ConfirmModal.svelte +15 -0
- package/dist/components/ConfirmModal.svelte.d.ts +5 -0
- package/dist/components/Drawer.svelte +18 -1
- package/dist/components/Drawer.svelte.d.ts +9 -1
- package/dist/components/InputDialog.svelte +21 -0
- package/dist/components/InputDialog.svelte.d.ts +11 -1
- package/dist/components/Modal.svelte +18 -1
- package/dist/components/Modal.svelte.d.ts +8 -0
- package/dist/components/basic/Empty.svelte +173 -9
- package/dist/components/basic/Empty.svelte.d.ts +50 -1
- package/dist/components/basic/ErrorState.svelte +128 -0
- package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
- package/dist/components/basic/JumpToLatest.svelte +60 -0
- package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
- package/dist/components/basic/PageHeader.svelte +216 -0
- package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
- package/dist/components/basic/ProgressButton.svelte +221 -0
- package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
- package/dist/components/graph/GraphApiBridge.svelte +36 -0
- package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
- package/dist/components/graph/GraphCanvas.svelte +212 -27
- package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
- package/dist/components/graph/api.d.ts +52 -0
- package/dist/components/graph/api.js +66 -0
- package/dist/components/graph/index.d.ts +2 -0
- package/dist/components/graph/index.js +1 -0
- package/dist/components/graph/theme.js +13 -0
- package/dist/components/messaging/MessageItem.svelte +5 -0
- package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
- package/dist/components/table/DataTable.svelte +452 -78
- package/dist/components/table/DataTable.svelte.d.ts +2 -2
- package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
- package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
- package/dist/components/table/columnStorage.d.ts +29 -0
- package/dist/components/table/columnStorage.js +61 -0
- package/dist/components/table/groupRows.d.ts +72 -0
- package/dist/components/table/groupRows.js +118 -0
- package/dist/components/table/index.d.ts +7 -1
- package/dist/components/table/index.js +3 -0
- package/dist/components/table/types.d.ts +121 -20
- package/dist/index.d.ts +25 -2
- package/dist/index.js +32 -1
- package/dist/utils/ansiToHtml.d.ts +83 -0
- package/dist/utils/ansiToHtml.js +543 -0
- package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
- package/dist/utils/keyboardShortcutRegistry.js +1070 -76
- package/dist/utils/pollBackoff.d.ts +83 -0
- package/dist/utils/pollBackoff.js +151 -0
- package/dist/utils/snapFlick.d.ts +59 -0
- package/dist/utils/snapFlick.js +66 -0
- package/dist/video/advisors/qr.d.ts +1 -1
- package/dist/virtual/createRowWindow.d.ts +75 -0
- package/dist/virtual/createRowWindow.js +129 -0
- package/dist/virtual/index.d.ts +4 -0
- package/dist/virtual/index.js +5 -0
- package/dist/virtual/rowWindowCore.d.ts +202 -0
- package/dist/virtual/rowWindowCore.js +510 -0
- package/package.json +11 -1
package/dist/actions/listNav.js
CHANGED
|
@@ -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.
|
|
118
|
-
* when layout isn't
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
400
|
+
applySelectionClasses(list.rendered);
|
|
248
401
|
}
|
|
249
402
|
function focus(idx) {
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
403
|
+
const list = resolveList();
|
|
404
|
+
if (list.length === 0)
|
|
252
405
|
return;
|
|
253
|
-
apply(
|
|
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
|
|
260
|
-
if (
|
|
261
|
-
apply(
|
|
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
|
|
273
|
-
if (
|
|
427
|
+
const list = resolveList();
|
|
428
|
+
if (list.length === 0) {
|
|
274
429
|
clear();
|
|
275
430
|
return;
|
|
276
431
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 <
|
|
283
|
-
apply(
|
|
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(
|
|
463
|
+
applySelectionClasses(list.rendered);
|
|
288
464
|
}
|
|
289
|
-
function toggleSelectionByIndex(
|
|
290
|
-
const id =
|
|
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
|
|
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 (
|
|
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(
|
|
505
|
+
apply(list, 0);
|
|
329
506
|
visualMode = true;
|
|
330
507
|
visualAnchor = focused;
|
|
331
|
-
toggleSelectionByIndex(
|
|
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 =
|
|
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 ||
|
|
522
|
+
if (focused < 0 || list.length === 0)
|
|
346
523
|
return false;
|
|
347
524
|
e.preventDefault();
|
|
348
|
-
toggleSelectionByIndex(
|
|
525
|
+
toggleSelectionByIndex(list, focused);
|
|
349
526
|
clearPending();
|
|
350
527
|
return true;
|
|
351
528
|
}
|
|
352
|
-
// * — toggle select-all of
|
|
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 (
|
|
532
|
+
if (list.length === 0)
|
|
355
533
|
return false;
|
|
356
534
|
e.preventDefault();
|
|
357
535
|
const allIds = [];
|
|
358
|
-
for (
|
|
359
|
-
const id =
|
|
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 (
|
|
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 :
|
|
385
|
-
const beforeId =
|
|
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(
|
|
389
|
-
apply(
|
|
390
|
-
const afterId =
|
|
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 (
|
|
582
|
+
if (list.length === 0)
|
|
405
583
|
return false;
|
|
406
584
|
e.preventDefault();
|
|
407
|
-
const
|
|
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 :
|
|
410
|
-
apply(
|
|
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 (
|
|
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(
|
|
622
|
+
? Math.max(0, Math.min(list.length - 1, pendingCount - 1))
|
|
442
623
|
: 0;
|
|
443
|
-
apply(
|
|
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 (
|
|
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(
|
|
462
|
-
:
|
|
463
|
-
apply(
|
|
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
|
-
|
|
472
|
-
el
|
|
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 (
|
|
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(
|
|
678
|
+
apply(list, wrapIndex(target, list.length, wrap));
|
|
493
679
|
clearPending();
|
|
494
680
|
if (selectable && visualMode)
|
|
495
|
-
extendVisualRange(
|
|
681
|
+
extendVisualRange(list);
|
|
496
682
|
return true;
|
|
497
683
|
}
|
|
498
684
|
case 'k':
|
|
499
685
|
case 'ArrowUp': {
|
|
500
|
-
if (
|
|
686
|
+
if (list.length === 0)
|
|
501
687
|
return false;
|
|
502
688
|
e.preventDefault();
|
|
503
689
|
const step = pendingCount ?? 1;
|
|
504
|
-
const target = focused < 0 ?
|
|
505
|
-
apply(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
606
|
-
for (
|
|
607
|
-
const id =
|
|
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
|
+
};
|