cursedbelt 5.78.0 → 5.79.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/dist/react/canvas-hud/HudControls.d.ts +8 -2
- package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
- package/dist/react/canvas-hud/HudControls.js +6 -3
- package/dist/react/canvas-hud/HudControls.js.map +1 -1
- package/dist/react/components/AppShell.d.ts +14 -0
- package/dist/react/components/AppShell.d.ts.map +1 -1
- package/dist/react/components/AppShell.js +7 -31
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
- package/dist/react/components/ChipFilterStrip.js +2 -18
- package/dist/react/components/ChipFilterStrip.js.map +1 -1
- package/dist/react/components/KanbanBoard.d.ts +55 -0
- package/dist/react/components/KanbanBoard.d.ts.map +1 -0
- package/dist/react/components/KanbanBoard.js +89 -0
- package/dist/react/components/KanbanBoard.js.map +1 -0
- package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
- package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
- package/dist/react/components/ResponsiveWorkbench.js +33 -4
- package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
- package/dist/react/components/SelectField.d.ts +18 -48
- package/dist/react/components/SelectField.d.ts.map +1 -1
- package/dist/react/components/SelectField.js +88 -11
- package/dist/react/components/SelectField.js.map +1 -1
- package/dist/react/components/SelectFieldImpl.d.ts +7 -0
- package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
- package/dist/react/components/SelectFieldImpl.js +26 -0
- package/dist/react/components/SelectFieldImpl.js.map +1 -0
- package/dist/react/components/filterChip.d.ts +8 -0
- package/dist/react/components/filterChip.d.ts.map +1 -0
- package/dist/react/components/filterChip.js +20 -0
- package/dist/react/components/filterChip.js.map +1 -0
- package/dist/react/hooks/pointerDnd.d.ts +37 -3
- package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
- package/dist/react/hooks/pointerDnd.js +101 -9
- package/dist/react/hooks/pointerDnd.js.map +1 -1
- package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
- package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
- package/dist/react/hooks/pragmaticDnd.js +9 -3
- package/dist/react/hooks/pragmaticDnd.js.map +1 -1
- package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
- package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
- package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
- package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/selectField.d.ts +72 -0
- package/dist/react/lib/selectField.d.ts.map +1 -0
- package/dist/react/lib/selectField.js +23 -0
- package/dist/react/lib/selectField.js.map +1 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +2 -1
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +2 -1
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/denseGallery.css +2 -0
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +6 -7
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
- package/dist/react/nav/CommandBar.d.ts.map +1 -1
- package/dist/react/nav/CommandBar.js +2 -1
- package/dist/react/nav/CommandBar.js.map +1 -1
- package/dist/react/palette/CommandPalette.d.ts +15 -1
- package/dist/react/palette/CommandPalette.d.ts.map +1 -1
- package/dist/react/palette/CommandPalette.js +58 -8
- package/dist/react/palette/CommandPalette.js.map +1 -1
- package/dist/react/palette/Kbd.d.ts +15 -0
- package/dist/react/palette/Kbd.d.ts.map +1 -0
- package/dist/react/palette/Kbd.js +17 -0
- package/dist/react/palette/Kbd.js.map +1 -0
- package/dist/react/palette/ShortcutSheet.d.ts +30 -0
- package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
- package/dist/react/palette/ShortcutSheet.js +15 -0
- package/dist/react/palette/ShortcutSheet.js.map +1 -0
- package/dist/react/palette/commandSources.d.ts +14 -0
- package/dist/react/palette/commandSources.d.ts.map +1 -1
- package/dist/react/palette/commandSources.js +11 -1
- package/dist/react/palette/commandSources.js.map +1 -1
- package/dist/react/palette/index.d.ts +4 -2
- package/dist/react/palette/index.d.ts.map +1 -1
- package/dist/react/palette/index.js +2 -0
- package/dist/react/palette/index.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/canvas-hud.css +2 -2
- package/dist/styles-areas/kanban.css +31 -0
- package/dist/styles-areas/palette.css +3 -3
- package/dist/styles-areas/workbench.css +15 -13
- package/dist/styles-utilities.css +42 -0
- package/package.json +3 -2
- package/scripts/guardrails-baseline.json +2 -1
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +77 -6
- package/scripts/styleAreas.ts +2 -0
- package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
- package/src/react/canvas-hud/HudControls.tsx +33 -9
- package/src/react/components/AppShell.spec.tsx +40 -0
- package/src/react/components/AppShell.tsx +25 -27
- package/src/react/components/ChipFilterStrip.tsx +2 -38
- package/src/react/components/KanbanBoard.spec.tsx +89 -0
- package/src/react/components/KanbanBoard.tsx +270 -0
- package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
- package/src/react/components/ResponsiveWorkbench.tsx +69 -4
- package/src/react/components/SelectField.spec.tsx +117 -0
- package/src/react/components/SelectField.tsx +131 -166
- package/src/react/components/SelectFieldImpl.tsx +140 -0
- package/src/react/components/filterChip.tsx +43 -0
- package/src/react/hooks/pointerDnd.spec.ts +89 -3
- package/src/react/hooks/pointerDnd.ts +103 -8
- package/src/react/hooks/pragmaticDnd.ts +13 -2
- package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
- package/src/react/index.ts +3 -0
- package/src/react/lib/floatingStyles.spec.ts +1 -1
- package/src/react/lib/selectField.ts +96 -0
- package/src/react/media/VideoChapterEditor.tsx +3 -2
- package/src/react/media/VideoCutEditor.tsx +3 -2
- package/src/react/media/denseGallery.css +2 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
- package/src/react/media-gallery/MediaGallery.tsx +16 -18
- package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
- package/src/react/nav/CommandBar.tsx +2 -3
- package/src/react/palette/CommandPalette.spec.tsx +73 -0
- package/src/react/palette/CommandPalette.tsx +153 -13
- package/src/react/palette/Kbd.tsx +46 -0
- package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
- package/src/react/palette/ShortcutSheet.tsx +110 -0
- package/src/react/palette/commandSources.tsx +30 -1
- package/src/react/palette/index.ts +9 -0
- package/src/styles-areas/canvas-hud.css +2 -2
- package/src/styles-areas/kanban.css +31 -0
- package/src/styles-areas/palette.css +3 -3
- package/src/styles-areas/workbench.css +15 -13
- package/src/styles-utilities.css +42 -0
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
subscribePointerDragMonitor,
|
|
13
13
|
TOUCH_ACTIVATION_DELAY_MS,
|
|
14
14
|
TOUCH_ACTIVATION_TOLERANCE_PX,
|
|
15
|
+
registerTouchEdgeScroll,
|
|
16
|
+
TOUCH_EDGE_MAX_STEP_PX,
|
|
17
|
+
touchEdgeStep,
|
|
15
18
|
wireTouchDragSource,
|
|
16
19
|
withinTolerance,
|
|
17
20
|
} from './pointerDnd.js';
|
|
@@ -239,17 +242,41 @@ describe('createTouchDragSession', () => {
|
|
|
239
242
|
});
|
|
240
243
|
|
|
241
244
|
describe('wireTouchDragSource (DOM glue)', () => {
|
|
242
|
-
test('
|
|
245
|
+
test('leaves the element pannable: a move before the long press is a scroll, one after it is the drag', async () => {
|
|
243
246
|
const element = document.createElement('div');
|
|
244
247
|
element.style.touchAction = 'pan-y';
|
|
248
|
+
document.body.append(element);
|
|
245
249
|
const cleanup = wireTouchDragSource({
|
|
246
250
|
element,
|
|
247
251
|
getInitialData: () => ({}),
|
|
248
252
|
onDragStateChange: () => {},
|
|
253
|
+
delayMs: 5,
|
|
249
254
|
});
|
|
250
|
-
|
|
251
|
-
cleanup();
|
|
255
|
+
// The page keeps its own touch-action — `none` here made a phone board unscrollable from a card.
|
|
252
256
|
expect(element.style.touchAction).toBe('pan-y');
|
|
257
|
+
|
|
258
|
+
const down = new Event('pointerdown', { bubbles: true, cancelable: true });
|
|
259
|
+
Object.assign(down, { pointerType: 'touch', isPrimary: true, pointerId: 7, clientX: 10, clientY: 10 });
|
|
260
|
+
const touchMove = () => {
|
|
261
|
+
const e = new Event('touchmove', { bubbles: true, cancelable: true });
|
|
262
|
+
element.dispatchEvent(e);
|
|
263
|
+
return e.defaultPrevented;
|
|
264
|
+
};
|
|
265
|
+
const menu = () => {
|
|
266
|
+
const e = new Event('contextmenu', { bubbles: true, cancelable: true });
|
|
267
|
+
element.dispatchEvent(e);
|
|
268
|
+
return e.defaultPrevented;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
expect(menu()).toBe(false); // no press, no interference
|
|
272
|
+
element.dispatchEvent(down);
|
|
273
|
+
expect(touchMove()).toBe(false); // still waiting: the browser may scroll
|
|
274
|
+
expect(menu()).toBe(true); // the long press is ours, not Android's menu
|
|
275
|
+
await Bun.sleep(20);
|
|
276
|
+
expect(touchMove()).toBe(true); // the drag is live: the pan is cancelled
|
|
277
|
+
cleanup();
|
|
278
|
+
expect(touchMove()).toBe(false);
|
|
279
|
+
element.remove();
|
|
253
280
|
});
|
|
254
281
|
|
|
255
282
|
test('a long-press touch drag reaches the shared monitor and toggles the target', async () => {
|
|
@@ -309,3 +336,62 @@ describe('wireTouchDragSource (DOM glue)', () => {
|
|
|
309
336
|
target.remove();
|
|
310
337
|
});
|
|
311
338
|
});
|
|
339
|
+
|
|
340
|
+
describe('touch edge auto-scroll', () => {
|
|
341
|
+
const box = { left: 0, right: 400, top: 100, bottom: 700 };
|
|
342
|
+
|
|
343
|
+
test('nothing in the middle; faster the nearer the edge; full speed past it, while level with the box', () => {
|
|
344
|
+
expect(touchEdgeStep(box, 200, 400)).toEqual({ dx: 0, dy: 0 });
|
|
345
|
+
expect(touchEdgeStep(box, 399, 400).dx).toBe(TOUCH_EDGE_MAX_STEP_PX);
|
|
346
|
+
expect(touchEdgeStep(box, 376, 400).dx).toBeGreaterThan(0);
|
|
347
|
+
expect(touchEdgeStep(box, 376, 400).dx).toBeLessThan(TOUCH_EDGE_MAX_STEP_PX);
|
|
348
|
+
expect(touchEdgeStep(box, 2, 400).dx).toBeLessThan(0);
|
|
349
|
+
expect(touchEdgeStep(box, 200, 690).dy).toBeGreaterThan(0);
|
|
350
|
+
// Past the right side (a thumb at the screen edge, the scroller inset by a gutter): full speed.
|
|
351
|
+
expect(touchEdgeStep(box, 420, 400)).toEqual({ dx: TOUCH_EDGE_MAX_STEP_PX, dy: 0 });
|
|
352
|
+
// Above the box and past its side: level with neither, so it scrolls only toward the top.
|
|
353
|
+
expect(touchEdgeStep(box, 420, 50)).toEqual({ dx: 0, dy: 0 });
|
|
354
|
+
expect(touchEdgeStep(box, 200, 50)).toEqual({ dx: 0, dy: -TOUCH_EDGE_MAX_STEP_PX });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("a touch drag held at a scroller's edge scrolls it, snapping off until the drop", async () => {
|
|
358
|
+
const container = document.createElement('div');
|
|
359
|
+
container.style.scrollSnapType = 'x mandatory';
|
|
360
|
+
const card = document.createElement('div');
|
|
361
|
+
container.append(card);
|
|
362
|
+
document.body.append(container);
|
|
363
|
+
container.getBoundingClientRect = () => ({ ...box, x: 0, y: 100, width: 400, height: 600, toJSON: () => ({}) }) as DOMRect;
|
|
364
|
+
const scrolled: number[] = [];
|
|
365
|
+
container.scrollBy = ((x: number) => scrolled.push(x)) as typeof container.scrollBy;
|
|
366
|
+
const originalFromPoint = document.elementsFromPoint;
|
|
367
|
+
document.elementsFromPoint = () => [];
|
|
368
|
+
|
|
369
|
+
const off = registerTouchEdgeScroll(container);
|
|
370
|
+
const unwire = wireTouchDragSource({ element: card, getInitialData: () => ({}), onDragStateChange: () => {}, delayMs: 5 });
|
|
371
|
+
const pointer = (type: string, x: number, y: number) => {
|
|
372
|
+
const e = new Event(type, { bubbles: true, cancelable: true });
|
|
373
|
+
Object.assign(e, { pointerType: 'touch', isPrimary: true, pointerId: 3, clientX: x, clientY: y });
|
|
374
|
+
return e;
|
|
375
|
+
};
|
|
376
|
+
try {
|
|
377
|
+
card.dispatchEvent(pointer('pointerdown', 200, 400));
|
|
378
|
+
await Bun.sleep(20);
|
|
379
|
+
window.dispatchEvent(pointer('pointermove', 395, 400)); // carried to the right edge, then held
|
|
380
|
+
await Bun.sleep(120);
|
|
381
|
+
expect(scrolled.length).toBeGreaterThan(1);
|
|
382
|
+
expect(scrolled.every((dx) => dx > 0)).toBe(true);
|
|
383
|
+
expect(container.style.scrollSnapType).toBe('none');
|
|
384
|
+
window.dispatchEvent(pointer('pointerup', 395, 400));
|
|
385
|
+
const after = scrolled.length;
|
|
386
|
+
await Bun.sleep(60);
|
|
387
|
+
expect(scrolled.length).toBe(after); // the drop stops it
|
|
388
|
+
expect(container.style.scrollSnapType).toBe('x mandatory');
|
|
389
|
+
} finally {
|
|
390
|
+
unwire();
|
|
391
|
+
off();
|
|
392
|
+
document.elementsFromPoint = originalFromPoint;
|
|
393
|
+
container.remove();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
@@ -324,9 +324,15 @@ function elementsFromPointSafe(x: number, y: number): Element[] {
|
|
|
324
324
|
/**
|
|
325
325
|
* Wire touch-drag onto one draggable element, using the shared registry + monitor bus.
|
|
326
326
|
* Only `touch` pointers are handled — mouse/pen still drive pragmatic's native adapter,
|
|
327
|
-
* so the two engines never fight over the same gesture.
|
|
328
|
-
*
|
|
329
|
-
*
|
|
327
|
+
* so the two engines never fight over the same gesture.
|
|
328
|
+
*
|
|
329
|
+
* 🔴 The element stays PANNABLE. A finger that moves before the long press fires is a scroll,
|
|
330
|
+
* and the browser runs it; only once the drag is live does a non-passive `touchmove` on the
|
|
331
|
+
* element cancel the browser's pan, so the finger drives the drag instead — the way
|
|
332
|
+
* `@dnd-kit`'s TouchSensor does it. This used to set `touch-action: none` on the element, and
|
|
333
|
+
* on a phone a board whose columns are full of cards could not be swiped or scrolled from any
|
|
334
|
+
* card (forge task 3033, seen on vault-stage at 390). While a press is in flight the
|
|
335
|
+
* long-press `contextmenu` (Android's menu) is cancelled too.
|
|
330
336
|
*
|
|
331
337
|
* The `pointerdown` listener lives on the element; the `pointermove`/`up`/`cancel`/
|
|
332
338
|
* `keydown` listeners are only attached on the `window` for the life of one interaction
|
|
@@ -358,8 +364,6 @@ export function wireTouchDragSource(config: WireTouchDragConfig): CleanupFn {
|
|
|
358
364
|
|
|
359
365
|
const onPointerMove = (event: PointerEvent): void => {
|
|
360
366
|
if (event.pointerId !== activePointerId) return;
|
|
361
|
-
// Once dragging, suppress the browser's scroll so the finger drives the drag.
|
|
362
|
-
if (session.isActive() && event.cancelable) event.preventDefault();
|
|
363
367
|
session.pointerMove({ x: event.clientX, y: event.clientY });
|
|
364
368
|
if (session.status() === 'idle') detach(); // a pre-activation move was a scroll
|
|
365
369
|
};
|
|
@@ -406,14 +410,105 @@ export function wireTouchDragSource(config: WireTouchDragConfig): CleanupFn {
|
|
|
406
410
|
session.pointerDown({ x: event.clientX, y: event.clientY });
|
|
407
411
|
};
|
|
408
412
|
|
|
409
|
-
|
|
410
|
-
element.
|
|
413
|
+
// Touch events go to the element the finger came down on for the whole gesture, so the
|
|
414
|
+
// element's own listener sees every move. Non-passive: it must be able to cancel the pan.
|
|
415
|
+
const onTouchMove = (event: TouchEvent): void => {
|
|
416
|
+
if (session.isActive() && event.cancelable) event.preventDefault();
|
|
417
|
+
};
|
|
418
|
+
const onContextMenu = (event: Event): void => {
|
|
419
|
+
if (session.status() !== 'idle') event.preventDefault();
|
|
420
|
+
};
|
|
411
421
|
element.addEventListener('pointerdown', onPointerDown);
|
|
422
|
+
element.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
423
|
+
element.addEventListener('contextmenu', onContextMenu);
|
|
412
424
|
|
|
413
425
|
return () => {
|
|
414
426
|
session.cancel();
|
|
415
427
|
detach();
|
|
416
|
-
element.style.touchAction = prevTouchAction;
|
|
417
428
|
element.removeEventListener('pointerdown', onPointerDown);
|
|
429
|
+
element.removeEventListener('touchmove', onTouchMove);
|
|
430
|
+
element.removeEventListener('contextmenu', onContextMenu);
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- Edge auto-scroll for touch drags -------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/** How near a scroller's edge (px) a touch drag must come before it scrolls, and its top speed. */
|
|
437
|
+
export const TOUCH_EDGE_PX = 48;
|
|
438
|
+
export const TOUCH_EDGE_MAX_STEP_PX = 14;
|
|
439
|
+
|
|
440
|
+
interface EdgeRect {
|
|
441
|
+
left: number;
|
|
442
|
+
right: number;
|
|
443
|
+
top: number;
|
|
444
|
+
bottom: number;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* How far one frame scrolls a scroller whose box is `rect` while a finger drags at (x, y):
|
|
449
|
+
* nothing in the middle, faster the nearer the edge, full speed at or PAST it — a thumb at the
|
|
450
|
+
* screen's edge is often outside a scroller inset by the page's gutter (vault-stage at 390: the
|
|
451
|
+
* strip ends at 374, the thumb sat at 385). An axis only moves while the finger is level with the
|
|
452
|
+
* box on the other one. Pure, so the feel is tested without a layout.
|
|
453
|
+
*/
|
|
454
|
+
export function touchEdgeStep(rect: EdgeRect, x: number, y: number): { dx: number; dy: number } {
|
|
455
|
+
const ramp = (into: number) => Math.round(TOUCH_EDGE_MAX_STEP_PX * Math.min(1, 1 - into / TOUCH_EDGE_PX));
|
|
456
|
+
let dx = 0;
|
|
457
|
+
let dy = 0;
|
|
458
|
+
if (y >= rect.top && y <= rect.bottom) {
|
|
459
|
+
if (x < rect.left + TOUCH_EDGE_PX) dx = -ramp(x - rect.left);
|
|
460
|
+
else if (x > rect.right - TOUCH_EDGE_PX) dx = ramp(rect.right - x);
|
|
461
|
+
}
|
|
462
|
+
if (x >= rect.left && x <= rect.right) {
|
|
463
|
+
if (y < rect.top + TOUCH_EDGE_PX) dy = -ramp(y - rect.top);
|
|
464
|
+
else if (y > rect.bottom - TOUCH_EDGE_PX) dy = ramp(rect.bottom - y);
|
|
465
|
+
}
|
|
466
|
+
return { dx: dx || 0, dy: dy || 0 };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Scroll `container` while a TOUCH drag holds near its edge — what pragmatic's
|
|
471
|
+
* `autoScrollForElements` does for a mouse, which the touch bridge never reaches. Without it a
|
|
472
|
+
* phone board whose columns are each a screen wide could not carry a card to any other column
|
|
473
|
+
* (forge task 3033). A finger held still keeps scrolling (one step a frame); scroll snapping is
|
|
474
|
+
* suspended while it does, and the container settles back onto its snap points on the drop.
|
|
475
|
+
*/
|
|
476
|
+
export function registerTouchEdgeScroll(container: HTMLElement): CleanupFn {
|
|
477
|
+
let point: PointerPoint | null = null;
|
|
478
|
+
let frame = 0;
|
|
479
|
+
let snapWas: string | null = null;
|
|
480
|
+
const tick = (): void => {
|
|
481
|
+
frame = 0;
|
|
482
|
+
if (!point) return;
|
|
483
|
+
const { dx, dy } = touchEdgeStep(container.getBoundingClientRect(), point.x, point.y);
|
|
484
|
+
if (dx !== 0 || dy !== 0) {
|
|
485
|
+
if (snapWas === null) {
|
|
486
|
+
snapWas = container.style.scrollSnapType;
|
|
487
|
+
container.style.scrollSnapType = 'none';
|
|
488
|
+
}
|
|
489
|
+
container.scrollBy(dx, dy);
|
|
490
|
+
}
|
|
491
|
+
frame = requestAnimationFrame(tick);
|
|
492
|
+
};
|
|
493
|
+
const stop = (): void => {
|
|
494
|
+
point = null;
|
|
495
|
+
if (frame) cancelAnimationFrame(frame);
|
|
496
|
+
frame = 0;
|
|
497
|
+
if (snapWas !== null) {
|
|
498
|
+
container.style.scrollSnapType = snapWas;
|
|
499
|
+
snapWas = null;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
const unsubscribe = subscribePointerDragMonitor((event) => {
|
|
503
|
+
if (event.phase === 'drop') {
|
|
504
|
+
stop();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
point = { x: event.current.input.clientX, y: event.current.input.clientY };
|
|
508
|
+
if (!frame) frame = requestAnimationFrame(tick);
|
|
509
|
+
});
|
|
510
|
+
return () => {
|
|
511
|
+
unsubscribe();
|
|
512
|
+
stop();
|
|
418
513
|
};
|
|
419
514
|
}
|
|
@@ -23,6 +23,7 @@ import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hi
|
|
|
23
23
|
import { useEffect, useRef, useState } from 'react';
|
|
24
24
|
import {
|
|
25
25
|
registerPointerDropTarget,
|
|
26
|
+
registerTouchEdgeScroll,
|
|
26
27
|
subscribePointerDragMonitor,
|
|
27
28
|
wireTouchDragSource,
|
|
28
29
|
} from './pointerDnd.js';
|
|
@@ -105,11 +106,21 @@ export function findScrollContainer(start: Element | null): Element | null {
|
|
|
105
106
|
// auto-scroll exactly ONCE (double-registering would compound the scroll velocity).
|
|
106
107
|
const autoScrollRegistry = new Map<Element, { count: number; cleanup: CleanupFn }>();
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Register `element` as an auto-scroll container for the drag engine (ref-counted) — for a
|
|
111
|
+
* mouse (pragmatic's own) and for a touch drag (`registerTouchEdgeScroll`, forge task 3033).
|
|
112
|
+
*/
|
|
109
113
|
export function registerAutoScroll(element: Element): CleanupFn {
|
|
110
114
|
const existing = autoScrollRegistry.get(element);
|
|
111
115
|
if (existing) existing.count += 1;
|
|
112
|
-
else
|
|
116
|
+
else
|
|
117
|
+
autoScrollRegistry.set(element, {
|
|
118
|
+
count: 1,
|
|
119
|
+
cleanup: combine(
|
|
120
|
+
autoScrollForElements({ element }),
|
|
121
|
+
element instanceof HTMLElement ? registerTouchEdgeScroll(element) : () => {},
|
|
122
|
+
),
|
|
123
|
+
});
|
|
113
124
|
let released = false;
|
|
114
125
|
return () => {
|
|
115
126
|
if (released) return;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Publishes the bottom chrome's height (dock + `PlaceBar` + safe area) as
|
|
5
|
+
* `--cb-bottom-chrome-h` on the root, so what floats over the page — the `Toaster`'s stack
|
|
6
|
+
* (OVL-9) — rises above the navigation instead of covering it. A `PlaceBar` hidden at `md` up
|
|
7
|
+
* measures 0, so the desktop stack drops back to the corner by itself.
|
|
8
|
+
*
|
|
9
|
+
* `AppShell` uses it for its dock and `PlaceBar`; an app that draws its own bottom bar without
|
|
10
|
+
* the shell (the vault's phone `PlaceBar`, forge task 3033) puts the returned ref on the bar's
|
|
11
|
+
* wrapper. Its own module so that app does not pull the shell into its entry chunk.
|
|
12
|
+
*/
|
|
13
|
+
export function usePublishedBottomChrome(shown: boolean) {
|
|
14
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
15
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `shown` mounts or drops the element the ref points at.
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
const el = ref.current;
|
|
18
|
+
const root = document.documentElement;
|
|
19
|
+
if (!el) return;
|
|
20
|
+
const publish = () => root.style.setProperty('--cb-bottom-chrome-h', `${el.offsetHeight}px`);
|
|
21
|
+
publish();
|
|
22
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(publish);
|
|
23
|
+
observer?.observe(el);
|
|
24
|
+
return () => {
|
|
25
|
+
observer?.disconnect();
|
|
26
|
+
root.style.removeProperty('--cb-bottom-chrome-h');
|
|
27
|
+
};
|
|
28
|
+
}, [shown]);
|
|
29
|
+
return ref;
|
|
30
|
+
}
|
|
@@ -110,17 +110,19 @@ describe('useScrollMemory', () => {
|
|
|
110
110
|
*/
|
|
111
111
|
test('a list growing page by page is followed past the deadline, as long as each page lands within it', async () => {
|
|
112
112
|
const s = fakeScroller(20_000);
|
|
113
|
-
const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={
|
|
113
|
+
const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={150} />);
|
|
114
114
|
s.scrollTo(15_000);
|
|
115
115
|
const list = { state: window.history.state, path: '/' };
|
|
116
116
|
push('/item/7');
|
|
117
|
-
rerender(<Shell at='/item/7' el={s.el} deadlineMs={
|
|
117
|
+
rerender(<Shell at='/item/7' el={s.el} deadlineMs={150} />);
|
|
118
118
|
|
|
119
119
|
s.grow(3_000);
|
|
120
120
|
back(list);
|
|
121
|
-
rerender(<Shell at='/' el={s.el} deadlineMs={
|
|
122
|
-
//
|
|
123
|
-
|
|
121
|
+
rerender(<Shell at='/' el={s.el} deadlineMs={150} />);
|
|
122
|
+
// Eight pages, 40ms apart: 320ms in all — over twice the 150ms deadline from the start, and
|
|
123
|
+
// never near 150ms without a page. (A 60ms deadline left a 40ms sleep 1.5x headroom, and it
|
|
124
|
+
// went red under a busy machine's full gate, 2026-10-13.)
|
|
125
|
+
for (const height of [5_000, 7_000, 9_000, 11_000, 13_000, 15_000, 17_000, 20_000]) {
|
|
124
126
|
await act(() => Bun.sleep(40));
|
|
125
127
|
s.grow(height);
|
|
126
128
|
}
|
package/src/react/index.ts
CHANGED
|
@@ -127,6 +127,7 @@ export {
|
|
|
127
127
|
// state, task 2275) must not have to import the media gallery — and its optional spreadsheet
|
|
128
128
|
// peer — to hear a `RailSection` toggle.
|
|
129
129
|
export { onPreferenceSeed, type PreferenceSeedListener } from './media-gallery/preferenceBus.js';
|
|
130
|
+
export { useRememberedPreference } from './media-gallery/rememberedPreference.js';
|
|
130
131
|
export { onPreferenceWrite, type PreferenceListener, seedPreferences } from './media-gallery/preferenceSync.js';
|
|
131
132
|
export {
|
|
132
133
|
type ScrollMemoryOptions,
|
|
@@ -139,6 +140,7 @@ export {
|
|
|
139
140
|
type AmbientIntensity,
|
|
140
141
|
} from './components/AmbientBackdrop.js';
|
|
141
142
|
export { AppShell, type AppShellProps } from './components/AppShell.js';
|
|
143
|
+
export { usePublishedBottomChrome } from './hooks/usePublishedBottomChrome.js';
|
|
142
144
|
export { BannerLane, type BannerLaneProps, type ShellBanner } from './components/BannerLane.js';
|
|
143
145
|
export { AuthGate, type AuthGateProps, notifyUnauthorized } from './AuthGate.js';
|
|
144
146
|
export { AutocompleteField, type AutocompleteProps } from './components/Autocomplete.js';
|
|
@@ -155,6 +157,7 @@ export {
|
|
|
155
157
|
} from './components/ChartTablePanel.js';
|
|
156
158
|
export * from './companion-link/index.js';
|
|
157
159
|
export { CardTapTarget, type CardTapTargetProps } from './components/CardTapTarget.js';
|
|
160
|
+
export { KanbanBoard, type KanbanBoardProps, type KanbanColumn } from './components/KanbanBoard.js';
|
|
158
161
|
export { EntityCard, type EntityCardProps } from './components/EntityCard.js';
|
|
159
162
|
export { CardDisplayControls, type CardDisplayControlsProps } from './components/CardDisplayControls.js';
|
|
160
163
|
export {
|
|
@@ -27,7 +27,7 @@ describe('the floating layer — DESIGN.md §5 containment', () => {
|
|
|
27
27
|
'components/Popover.tsx',
|
|
28
28
|
'components/DropdownMenu.tsx',
|
|
29
29
|
'components/HoverCard.tsx',
|
|
30
|
-
'components/
|
|
30
|
+
'components/SelectFieldImpl.tsx',
|
|
31
31
|
'components/ComboboxField.tsx',
|
|
32
32
|
'components/Autocomplete.tsx',
|
|
33
33
|
'components/TooltipImpl.tsx',
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from './cn.js';
|
|
3
|
+
import { type ControlSize, fieldControlClass } from './field.js';
|
|
4
|
+
|
|
5
|
+
/** Shared by `components/SelectField.tsx` (the light trigger) and `components/SelectFieldImpl.tsx`
|
|
6
|
+
* (the lazily loaded Radix select) — here so the two draw the same box and neither widens the
|
|
7
|
+
* public surface. */
|
|
8
|
+
|
|
9
|
+
export interface SelectOption {
|
|
10
|
+
value: string;
|
|
11
|
+
label: ReactNode;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SelectGroup {
|
|
17
|
+
label?: string;
|
|
18
|
+
options: SelectOption[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SelectFieldProps {
|
|
22
|
+
value?: string;
|
|
23
|
+
onValueChange?: (value: string) => void;
|
|
24
|
+
options?: SelectOption[];
|
|
25
|
+
groups?: SelectGroup[];
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/** Use the full field style (16px, rounded-md, border, focus ring). Default true. */
|
|
29
|
+
fieldStyle?: boolean;
|
|
30
|
+
/** Trigger height on the shared control-height scale (`--control-h-*`, task
|
|
31
|
+
* cbux-41). Default `md`. No-op when `fieldStyle={false}`. */
|
|
32
|
+
size?: ControlSize;
|
|
33
|
+
/** Forwarded to the trigger so a visible `<label htmlFor>` can target it. */
|
|
34
|
+
id?: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
required?: boolean;
|
|
37
|
+
className?: string;
|
|
38
|
+
'aria-label'?: string;
|
|
39
|
+
'aria-labelledby'?: string;
|
|
40
|
+
/**
|
|
41
|
+
* What to say when there is nothing to choose — *"No lists yet"*.
|
|
42
|
+
*
|
|
43
|
+
* 🔴 Required in practice for any control whose options come from DATA rather than
|
|
44
|
+
* from a constant: an empty popup is a dead end that reads as a broken control, and
|
|
45
|
+
* the `choice-needs-empty-state` guardrail reports one. Rendered as plain text, not
|
|
46
|
+
* as an item, so it can never be picked or keyboard-focused.
|
|
47
|
+
*/
|
|
48
|
+
emptyLabel?: ReactNode;
|
|
49
|
+
/** The second line — what belongs in this list, or how to get one. */
|
|
50
|
+
emptyHint?: ReactNode;
|
|
51
|
+
/** Set by {@link Field} for inline validation — paints the field-token invalid
|
|
52
|
+
* border (via `aria-invalid`) and links the description/error to the trigger. */
|
|
53
|
+
'aria-invalid'?: boolean | 'true' | 'false';
|
|
54
|
+
'aria-describedby'?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the light trigger hands the real select when it replaces it. Internal. */
|
|
58
|
+
export interface SelectFieldImplProps extends SelectFieldProps {
|
|
59
|
+
/** The trigger was pressed (or Enter/Space/an arrow) before the implementation arrived. */
|
|
60
|
+
initialOpen?: boolean;
|
|
61
|
+
/** The trigger had keyboard focus: keep it on the real one. */
|
|
62
|
+
initialFocus?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The keys Radix Select opens on from its trigger (`OPEN_KEYS` in `@radix-ui/react-select`). */
|
|
66
|
+
export const SELECT_OPEN_KEYS: readonly string[] = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
|
|
67
|
+
|
|
68
|
+
/** The trigger's classes — one function, so the light trigger and Radix's are the same box and the
|
|
69
|
+
* swap between them moves nothing. */
|
|
70
|
+
export function selectTriggerClass({
|
|
71
|
+
fieldStyle = true,
|
|
72
|
+
size = 'md',
|
|
73
|
+
className,
|
|
74
|
+
}: Pick<SelectFieldProps, 'fieldStyle' | 'size' | 'className'>): string {
|
|
75
|
+
return cn(
|
|
76
|
+
fieldStyle && fieldControlClass(size),
|
|
77
|
+
'flex items-center justify-between gap-2 data-[placeholder]:text-muted-foreground',
|
|
78
|
+
className,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The value's text wrapper. One line, ellipsized, left-aligned: a long value (a deep path) used to
|
|
83
|
+
* wrap to three centred lines and spill out of a 36px trigger at 390px (Radix Themes' Select
|
|
84
|
+
* truncates the same way, forge task 2594). Radix's Value drops `className`, so the truncation
|
|
85
|
+
* lives on a wrapper. */
|
|
86
|
+
export const SELECT_VALUE_CLASS = 'block min-w-0 flex-1 truncate text-left';
|
|
87
|
+
|
|
88
|
+
/** `groups` when given, else `options` as one unlabelled group. */
|
|
89
|
+
export function selectGroupsOf({ options = [], groups = [] }: Pick<SelectFieldProps, 'options' | 'groups'>): SelectGroup[] {
|
|
90
|
+
return groups.length > 0 ? groups : options.length > 0 ? [{ options }] : [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Radix Select shows the placeholder for exactly these values (`shouldShowPlaceholder`). */
|
|
94
|
+
export function selectShowsPlaceholder(value: string | undefined): boolean {
|
|
95
|
+
return value === undefined || value === '';
|
|
96
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Button } from '../components/Button.js';
|
|
|
7
7
|
import { InfoHint } from '../components/InfoHint.js';
|
|
8
8
|
import { Input } from '../components/Input.js';
|
|
9
9
|
import { Modal } from '../components/Modal.js';
|
|
10
|
+
import { Kbd } from '../palette/Kbd.js';
|
|
10
11
|
import {
|
|
11
12
|
chapterProblem,
|
|
12
13
|
chapterSpans,
|
|
@@ -360,11 +361,11 @@ export function VideoChapterEditor({
|
|
|
360
361
|
<InfoHint title='Naming the parts of a video' align='right'>
|
|
361
362
|
<p>
|
|
362
363
|
Play or scrub to where a part begins, type what it is about, and press{' '}
|
|
363
|
-
<strong>Mark here</strong> (or <
|
|
364
|
+
<strong>Mark here</strong> (or <Kbd>Enter</Kbd> in the box).
|
|
364
365
|
</p>
|
|
365
366
|
<p>
|
|
366
367
|
Each section runs until the next one starts. Click a section's time to jump there,
|
|
367
|
-
edit a name in place, and <
|
|
368
|
+
edit a name in place, and <Kbd>Space</Kbd> plays and pauses.
|
|
368
369
|
</p>
|
|
369
370
|
<p>
|
|
370
371
|
Nothing here changes your video — these are names the player shows on the scrub
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type VideoCut,
|
|
22
22
|
} from 'cursedbelt-core/media/video-cuts';
|
|
23
23
|
import { cn, RAW_BUTTON } from '../lib/cn.js';
|
|
24
|
+
import { Kbd } from '../palette/Kbd.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Mark the parts of a video to cut out, and send the marks off to be executed.
|
|
@@ -324,12 +325,12 @@ export function VideoCutEditor({
|
|
|
324
325
|
*/}
|
|
325
326
|
<InfoHint title='Marking a video to trim' align='right'>
|
|
326
327
|
<p>
|
|
327
|
-
Press <strong>Cut here</strong> (or <
|
|
328
|
+
Press <strong>Cut here</strong> (or <Kbd>C</Kbd>) to divide the clip at the
|
|
328
329
|
playhead. Every piece between two cuts is a section.
|
|
329
330
|
</p>
|
|
330
331
|
<p>
|
|
331
332
|
Click <strong>✗</strong> on a section to remove it — click again to keep it after
|
|
332
|
-
all. Click a section's timecode to jump there, and <
|
|
333
|
+
all. Click a section's timecode to jump there, and <Kbd>Space</Kbd> plays and
|
|
333
334
|
pauses.
|
|
334
335
|
</p>
|
|
335
336
|
<p>
|
|
@@ -779,6 +779,16 @@ describe('MediaGallery — the playback run', () => {
|
|
|
779
779
|
throw new Error(`the playback button never reached ${label}`);
|
|
780
780
|
}
|
|
781
781
|
|
|
782
|
+
/** Pick a slideshow speed the way a keyboard does: open the dwell field, take the option. */
|
|
783
|
+
function pickDwell(
|
|
784
|
+
getByLabelText: ReturnType<typeof render>['getByLabelText'],
|
|
785
|
+
getByRole: ReturnType<typeof render>['getByRole'],
|
|
786
|
+
label: string,
|
|
787
|
+
) {
|
|
788
|
+
fireEvent.keyDown(getByLabelText('How long each picture holds the screen'), { key: 'Enter' });
|
|
789
|
+
fireEvent.click(getByRole('option', { name: label }));
|
|
790
|
+
}
|
|
791
|
+
|
|
782
792
|
test('🔴 the button cycles all FIVE settings, including from a picture', () => {
|
|
783
793
|
// Gated on `category === 'video'` before this, so a folder of photographs — the item most
|
|
784
794
|
// albums open on — had no way to start a slideshow at all.
|
|
@@ -814,9 +824,7 @@ describe('MediaGallery — the playback run', () => {
|
|
|
814
824
|
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
815
825
|
cycleTo(getByRole, /Play all/);
|
|
816
826
|
// The shortest rung on the ladder, so the test waits two seconds rather than five.
|
|
817
|
-
|
|
818
|
-
target: { value: '2' },
|
|
819
|
-
});
|
|
827
|
+
pickDwell(getByLabelText, getByRole, '2s');
|
|
820
828
|
// `beach.jpg` is the newest, so the default sort puts `clip.mp4` next.
|
|
821
829
|
await waitFor(
|
|
822
830
|
() => expect(container.querySelector('.cbgd-watch-title')?.textContent).toBe('clip.mp4'),
|
|
@@ -830,9 +838,7 @@ describe('MediaGallery — the playback run', () => {
|
|
|
830
838
|
const { getByRole, getByLabelText, container } = render(<MediaGallery items={ITEMS} />);
|
|
831
839
|
fireEvent.click(getByRole('button', { name: 'Open clip.mp4' }));
|
|
832
840
|
cycleTo(getByRole, /Play all/);
|
|
833
|
-
|
|
834
|
-
target: { value: '2' },
|
|
835
|
-
});
|
|
841
|
+
pickDwell(getByLabelText, getByRole, '2s');
|
|
836
842
|
await new Promise((resolve) => setTimeout(resolve, 2600));
|
|
837
843
|
expect(container.querySelector('.cbgd-watch-title')?.textContent).toBe('clip.mp4');
|
|
838
844
|
}, 8000);
|
|
@@ -841,16 +847,14 @@ describe('MediaGallery — the playback run', () => {
|
|
|
841
847
|
const first = render(<MediaGallery items={ITEMS} />);
|
|
842
848
|
fireEvent.click(first.getByRole('button', { name: 'Open beach.jpg' }));
|
|
843
849
|
cycleTo(first.getByRole, /Play all/);
|
|
844
|
-
|
|
845
|
-
target: { value: '30' },
|
|
846
|
-
});
|
|
850
|
+
pickDwell(first.getByLabelText, first.getByRole, '30s');
|
|
847
851
|
first.unmount();
|
|
848
852
|
|
|
849
853
|
const second = render(<MediaGallery items={ITEMS} />);
|
|
850
854
|
fireEvent.click(second.getByRole('button', { name: 'Open beach.jpg' }));
|
|
851
855
|
expect(
|
|
852
|
-
|
|
853
|
-
).toBe('
|
|
856
|
+
second.getByLabelText('How long each picture holds the screen').textContent,
|
|
857
|
+
).toBe('30s');
|
|
854
858
|
});
|
|
855
859
|
});
|
|
856
860
|
|
|
@@ -138,6 +138,7 @@ import { GalleryTable, durationLabel } from './GalleryTable.js';
|
|
|
138
138
|
import { nextInRun, runAdvances, runRepeatsOne, stepId } from './playbackRun.js';
|
|
139
139
|
import type { MediaGalleryItem, MediaGalleryItemMedia, MediaGalleryOrder, MediaPending } from './types.js';
|
|
140
140
|
import { createdLabel, type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia.js';
|
|
141
|
+
import { SelectField } from '../components/SelectField.js';
|
|
141
142
|
import { Tooltip } from '../components/Tooltip.js';
|
|
142
143
|
|
|
143
144
|
import { TruncatedText } from '../components/TruncatedText.js';
|
|
@@ -151,6 +152,9 @@ import { cn, RAW_BUTTON } from '../lib/cn.js';
|
|
|
151
152
|
// `MediaGalleryItem` and keep auth/signing/deletion on their side.
|
|
152
153
|
|
|
153
154
|
// Heavy viewers stay off the critical path — each loads only when a matching file opens.
|
|
155
|
+
/** The slideshow speeds, as the dwell field offers them. */
|
|
156
|
+
const DWELL_CHOICES = IMAGE_DWELL_OPTIONS.map((seconds) => ({ value: String(seconds), label: `${seconds}s` }));
|
|
157
|
+
|
|
154
158
|
const LazyVideoPlayer = lazyWithReload(() =>
|
|
155
159
|
import('../media/VideoPlayer.js').then((m) => ({ default: m.VideoPlayer })),
|
|
156
160
|
);
|
|
@@ -2901,34 +2905,28 @@ export function MediaGallery({
|
|
|
2901
2905
|
happen. Owner: "The time should be configurable for slow to fast slideshows." */}
|
|
2902
2906
|
{runAdvances(playbackEnd) ? (
|
|
2903
2907
|
<Tooltip content='How long each picture holds the screen before the next item'>
|
|
2904
|
-
<
|
|
2908
|
+
<span className='cbgd-dwell'>
|
|
2905
2909
|
{/* The word `Hold` was the control's NAME and is now the hourglass; the
|
|
2906
2910
|
`5s` beside it is its VALUE and stays printed, because a duration you
|
|
2907
|
-
have to hover to read is a duration you cannot compare. The
|
|
2908
|
-
keeps its own `aria-label`, so the glyph is decoration.
|
|
2911
|
+
have to hover to read is a duration you cannot compare. The field
|
|
2912
|
+
keeps its own `aria-label`, so the glyph is decoration. A SelectField, not
|
|
2913
|
+
a `<select>`: the OS drew that list, outside the kit (forge task 3097). */}
|
|
2909
2914
|
<span className='cbgd-dwell-label'>
|
|
2910
2915
|
<Timer aria-hidden />
|
|
2911
2916
|
</span>
|
|
2912
|
-
<
|
|
2913
|
-
|
|
2914
|
-
// self-contained plain-CSS control, for the same reason `.cbgd-search`
|
|
2915
|
-
// states — cursedbelt's Tailwind field layer would fight it on cascade.
|
|
2917
|
+
<SelectField
|
|
2918
|
+
fieldStyle={false}
|
|
2916
2919
|
className='cbgd-dwell-select'
|
|
2917
|
-
value={imageDwell}
|
|
2918
|
-
|
|
2919
|
-
const next = Number(
|
|
2920
|
+
value={String(imageDwell)}
|
|
2921
|
+
onValueChange={(value) => {
|
|
2922
|
+
const next = Number(value);
|
|
2920
2923
|
setImageDwell(next);
|
|
2921
2924
|
writeImageDwell(next);
|
|
2922
2925
|
}}
|
|
2926
|
+
options={DWELL_CHOICES}
|
|
2923
2927
|
aria-label='How long each picture holds the screen'
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
<option key={seconds} value={seconds}>
|
|
2927
|
-
{seconds}s
|
|
2928
|
-
</option>
|
|
2929
|
-
))}
|
|
2930
|
-
</select>
|
|
2931
|
-
</label>
|
|
2928
|
+
/>
|
|
2929
|
+
</span>
|
|
2932
2930
|
</Tooltip>
|
|
2933
2931
|
) : null}
|
|
2934
2932
|
{/*
|