@cssints/headless 0.17.0 → 0.19.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/index.mjs CHANGED
@@ -2939,6 +2939,165 @@ const createDrawer = (options = {}) => {
2939
2939
  handleProps: { "aria-hidden": "true" }
2940
2940
  };
2941
2941
  };
2942
+ // src/dropzone.ts
2943
+ const REASON_TEXT = {
2944
+ type: "unsupported type",
2945
+ size: "too large"
2946
+ };
2947
+ const tokensOf = (accept) => (accept ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
2948
+ /** The HTML `accept` grammar: a token starting with "." is an extension, else a MIME type, `*` allowed. */
2949
+ const accepts = (accept, file) => {
2950
+ const tokens = tokensOf(accept);
2951
+ if (tokens.length === 0) return true;
2952
+ const name = file.name.toLowerCase();
2953
+ const [fileType = "", fileSubtype = ""] = file.type.toLowerCase().split("/");
2954
+ return tokens.some((token) => {
2955
+ if (token.startsWith(".")) return name.endsWith(token);
2956
+ const [type = "", subtype = ""] = token.split("/");
2957
+ return type === fileType && (subtype === "*" || subtype === fileSubtype);
2958
+ });
2959
+ };
2960
+ const refusal = (options, file) => {
2961
+ if (!accepts(options.accept, file)) return "type";
2962
+ if (options.maxSize !== void 0 && file.size > options.maxSize) return "size";
2963
+ };
2964
+ const messageOf = (batch) => {
2965
+ if (batch === void 0) return;
2966
+ const files = batch.total === 1 ? "1 file" : `${batch.total} files`;
2967
+ if (batch.rejected.length === 0) return files;
2968
+ const reasons = [...new Set(batch.rejected)].map((reason) => REASON_TEXT[reason]).join(", ");
2969
+ return `${files}, ${batch.rejected.length} rejected: ${reasons}`;
2970
+ };
2971
+ /**
2972
+ * A file drop zone. There is no APG pattern and no Quint spec for one: the
2973
+ * contract is HTML's file input plus WAI-ARIA's live region, and
2974
+ * `test/dropzone.browser.test.tsx` is its oracle.
2975
+ *
2976
+ * Decisions:
2977
+ * - **The zone is never drag-only.** `browseProps` spreads onto a real
2978
+ * `<button type="button">` that clicks the hidden input, so the picker is the
2979
+ * keyboard path (Enter and Space are the platform's) and a pointer user can
2980
+ * click as well as drag. The drop handlers add nothing the button cannot do.
2981
+ * - **Validation happens once per file**, in the order type then size, and a
2982
+ * refused file reaches `onReject` with the reason the host shows. `accept` is
2983
+ * the HTML grammar (`.` extension, MIME type, `*`); `maxSize` is bytes.
2984
+ * - **Dropping a folder or a `DataTransfer` with no files is a no-op**: no
2985
+ * `onFiles`, no `onReject`, no message, and the previous announcement stays.
2986
+ * - **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so the
2987
+ * state is a depth counter: it clears only when the drag leaves the zone
2988
+ * itself, or on a drop.
2989
+ * - **The zone is `role=group`** so `labels.zone` is exposed; `dragging()` is
2990
+ * the only drag state a skin reads (no `data-dragging`).
2991
+ * - `disabled` marks the zone `aria-disabled`, disables the input natively and
2992
+ * the browse button, and drops every drag before it becomes a batch.
2993
+ */
2994
+ const createDropZone = (options) => {
2995
+ const id = createUniqueId();
2996
+ const [depth, setDepth] = createSignal(0);
2997
+ const [batch, setBatch] = createSignal();
2998
+ const disabled = () => options.disabled === true;
2999
+ const deliver = (files) => {
3000
+ if (disabled() || files.length === 0) return;
3001
+ const accepted = [];
3002
+ const rejected = [];
3003
+ for (const file of files) {
3004
+ const reason = refusal(options, file);
3005
+ if (reason === void 0) accepted.push(file);
3006
+ else {
3007
+ rejected.push(reason);
3008
+ options.onReject?.({
3009
+ file,
3010
+ reason
3011
+ });
3012
+ }
3013
+ }
3014
+ setBatch({
3015
+ total: files.length,
3016
+ rejected
3017
+ });
3018
+ if (accepted.length > 0) options.onFiles(accepted);
3019
+ };
3020
+ const dropped = (event) => {
3021
+ event.preventDefault();
3022
+ setDepth(0);
3023
+ if (disabled()) return;
3024
+ const transfer = event.dataTransfer;
3025
+ if (transfer === null) return;
3026
+ const items = Array.from(transfer.items).filter((item) => item.kind === "file");
3027
+ const files = Array.from(transfer.files);
3028
+ if (files.length === 0) return;
3029
+ if (items.length > 0 && items.every((item) => item.webkitGetAsEntry?.()?.isDirectory === true)) return;
3030
+ deliver(files);
3031
+ };
3032
+ const picked = (event) => {
3033
+ const input = event.currentTarget;
3034
+ const files = Array.from(input.files ?? []);
3035
+ input.value = "";
3036
+ deliver(files);
3037
+ };
3038
+ const browse = () => {
3039
+ if (disabled()) return;
3040
+ document.getElementById(id)?.click();
3041
+ };
3042
+ return {
3043
+ dragging: () => depth() > 0,
3044
+ message: () => messageOf(batch()),
3045
+ inputProps: {
3046
+ id,
3047
+ type: "file",
3048
+ get accept() {
3049
+ return options.accept;
3050
+ },
3051
+ get multiple() {
3052
+ return options.multiple === true ? true : void 0;
3053
+ },
3054
+ get disabled() {
3055
+ return disabled() ? true : void 0;
3056
+ },
3057
+ hidden: true,
3058
+ onChange: picked
3059
+ },
3060
+ zoneProps: {
3061
+ role: "group",
3062
+ get "aria-label"() {
3063
+ return options.labels?.zone;
3064
+ },
3065
+ get "aria-disabled"() {
3066
+ return disabled() ? "true" : void 0;
3067
+ },
3068
+ onDragEnter: (event) => {
3069
+ event.preventDefault();
3070
+ if (!disabled()) setDepth(depth() + 1);
3071
+ },
3072
+ onDragOver: (event) => {
3073
+ event.preventDefault();
3074
+ },
3075
+ onDragLeave: (event) => {
3076
+ event.preventDefault();
3077
+ if (!disabled()) setDepth(Math.max(0, depth() - 1));
3078
+ },
3079
+ onDrop: dropped
3080
+ },
3081
+ browseProps: {
3082
+ type: "button",
3083
+ get disabled() {
3084
+ return disabled() ? true : void 0;
3085
+ },
3086
+ get "aria-disabled"() {
3087
+ return disabled() ? "true" : void 0;
3088
+ },
3089
+ get "aria-label"() {
3090
+ return options.labels?.browse;
3091
+ },
3092
+ onClick: browse
3093
+ },
3094
+ statusProps: {
3095
+ role: "status",
3096
+ "aria-live": "polite",
3097
+ "aria-atomic": "true"
3098
+ }
3099
+ };
3100
+ };
2942
3101
  // src/feed.ts
2943
3102
  /**
2944
3103
  * APG Feed (https://www.w3.org/WAI/ARIA/apg/patterns/feed/): state plus prop
@@ -3266,6 +3425,304 @@ const createPagination = (options) => {
3266
3425
  }
3267
3426
  };
3268
3427
  };
3428
+ // src/panzoom.ts
3429
+ /** Arrow-key pan distance, in CSS px; Shift multiplies it. */
3430
+ const PAN_STEP = 24;
3431
+ const PAN_SHIFT = 4;
3432
+ /** A ctrl/meta wheel notch. */
3433
+ const WHEEL_FACTOR = 1.1;
3434
+ const axis = (value, limit) => Math.min(limit, Math.max(-limit, value));
3435
+ /**
3436
+ * Pan and zoom for one image in a viewport: scale, offset and the prop objects
3437
+ * for a focusable viewport, the transformed image and a live value.
3438
+ *
3439
+ * **There is no APG pattern for pan-zoom.** The accessible contract is the three
3440
+ * parts this returns: a viewport that takes focus (`role=group`, `tabindex=0`,
3441
+ * `aria-roledescription="zoomable image"`), keyboard operations that stand in
3442
+ * for every pointer gesture (`+` / `=` / `-` zoom, arrows pan, `0` resets), and a
3443
+ * polite live region (`aria-describedby` of the viewport) that carries the
3444
+ * current scale. The pointer gestures are an addition for mouse and touch, never
3445
+ * the only way in. That contract is this module's own: the case it replaces
3446
+ * (kocharin-art-3's `Zoomable`, ~110 lines of pointer maths) had no keyboard path
3447
+ * and no ARIA at all, and `createCanvas` is the wrong model here — an infinite
3448
+ * canvas moves a camera over a world, while this is one image fitted to its
3449
+ * viewport.
3450
+ *
3451
+ * Wheel is zoom-with-a-modifier only: `ctrl` / `meta` (the browser's own pinch
3452
+ * arrives as `ctrl+wheel`) zooms about the cursor; a bare wheel is never
3453
+ * `preventDefault`ed, so the page keeps scrolling. Trackpad gestures differ per
3454
+ * platform and a swallowed wheel is the one thing that is never acceptable.
3455
+ *
3456
+ * Pointer: a drag pans, a second pointer pinches (about its midpoint), a double
3457
+ * click resets. Capture is best-effort (a synthetic pointer cannot be captured).
3458
+ *
3459
+ * `bounds: "clamp"` bounds the offset by half the viewport's scaled overflow, so
3460
+ * the image is expected to fill the viewport at `min` (the usual
3461
+ * `width: 100%; height: 100%` / `object-fit: cover` box). A letterboxed image
3462
+ * smaller than its viewport cannot be held gap-free; give it a viewport of its
3463
+ * own or `bounds: "free"`.
3464
+ *
3465
+ * Request/commit (`rt1p`): uncontrolled by default (`defaultScale`); pass `scale`
3466
+ * + `onScaleChange` to own the value. The offset has no controlled prop, so a
3467
+ * hosting page that rejects a scale request also gets the offset resolved for the
3468
+ * scale it refused — accept the request, or pass `bounds: "free"` and own the
3469
+ * pan elsewhere.
3470
+ *
3471
+ * The host owns the box and the looks: size the viewport, put `touch-action:
3472
+ * none` on it if a touch drag should pan, centre the image
3473
+ * (`transform-origin: 50% 50%` is the default this maths assumes), and hide the
3474
+ * live region visually rather than with `hidden` (a `hidden` live region is not
3475
+ * announced).
3476
+ */
3477
+ const createPanZoom = (options = {}) => {
3478
+ const min = options.min ?? 1;
3479
+ const max = options.max ?? 4;
3480
+ const step = options.step ?? .25;
3481
+ const bounds = () => options.bounds ?? "clamp";
3482
+ const valueId = createUniqueId();
3483
+ const clamp = (next) => Math.min(max, Math.max(min, next));
3484
+ const [scale, requestScale] = createControllable({
3485
+ value: () => options.scale,
3486
+ initial: clamp(options.defaultScale ?? min),
3487
+ onChange: () => options.onScaleChange
3488
+ });
3489
+ const [version, setVersion] = createSignal(0);
3490
+ let last = {
3491
+ x: 0,
3492
+ y: 0
3493
+ };
3494
+ const offset = () => {
3495
+ version();
3496
+ return last;
3497
+ };
3498
+ const setOffset = (next) => {
3499
+ last = next;
3500
+ setVersion((count) => count + 1);
3501
+ };
3502
+ let box = {
3503
+ left: 0,
3504
+ top: 0,
3505
+ width: 0,
3506
+ height: 0
3507
+ };
3508
+ const measure = (event) => {
3509
+ if (!(event.currentTarget instanceof Element)) return;
3510
+ const rect = event.currentTarget.getBoundingClientRect();
3511
+ box = {
3512
+ left: rect.left,
3513
+ top: rect.top,
3514
+ width: rect.width,
3515
+ height: rect.height
3516
+ };
3517
+ };
3518
+ const limits = (at) => ({
3519
+ x: Math.max(0, box.width * (at - 1) / 2),
3520
+ y: Math.max(0, box.height * (at - 1) / 2)
3521
+ });
3522
+ const move = (next, at = scale()) => {
3523
+ const allowed = limits(at);
3524
+ const target = bounds() === "free" ? next : {
3525
+ x: axis(next.x, allowed.x),
3526
+ y: axis(next.y, allowed.y)
3527
+ };
3528
+ const previous = offset();
3529
+ if (target.x === previous.x && target.y === previous.y) return;
3530
+ setOffset(target);
3531
+ options.onPanChange?.(target);
3532
+ };
3533
+ const setScale = (next, origin) => {
3534
+ const before = scale();
3535
+ const target = clamp(next);
3536
+ if (target === before) return;
3537
+ requestScale(target);
3538
+ const at = origin ?? {
3539
+ x: box.width / 2,
3540
+ y: box.height / 2
3541
+ };
3542
+ const o = {
3543
+ x: at.x - box.width / 2,
3544
+ y: at.y - box.height / 2
3545
+ };
3546
+ const ratio = before > 0 ? target / before : 1;
3547
+ const current = offset();
3548
+ move({
3549
+ x: o.x - (o.x - current.x) * ratio,
3550
+ y: o.y - (o.y - current.y) * ratio
3551
+ }, target);
3552
+ };
3553
+ const zoomIn = () => setScale(scale() + step);
3554
+ const zoomOut = () => setScale(scale() - step);
3555
+ const reset = () => {
3556
+ requestScale(min);
3557
+ move({
3558
+ x: 0,
3559
+ y: 0
3560
+ }, min);
3561
+ };
3562
+ const pointers = /* @__PURE__ */ new Map();
3563
+ let drag;
3564
+ let pinch;
3565
+ const distance = () => {
3566
+ const [a, b] = [...pointers.values()];
3567
+ return a === void 0 || b === void 0 ? 0 : Math.hypot(a.x - b.x, a.y - b.y);
3568
+ };
3569
+ const midpoint = () => {
3570
+ const [a, b] = [...pointers.values()];
3571
+ if (a === void 0 || b === void 0) return {
3572
+ x: box.left + box.width / 2,
3573
+ y: box.top + box.height / 2
3574
+ };
3575
+ return {
3576
+ x: (a.x + b.x) / 2,
3577
+ y: (a.y + b.y) / 2
3578
+ };
3579
+ };
3580
+ const local = (point) => ({
3581
+ x: point.x - box.left,
3582
+ y: point.y - box.top
3583
+ });
3584
+ const pointerDown = (event) => {
3585
+ measure(event);
3586
+ if (event.button !== 0) return;
3587
+ try {
3588
+ if (event.currentTarget instanceof Element) event.currentTarget.setPointerCapture(event.pointerId);
3589
+ } catch {}
3590
+ const at = {
3591
+ x: event.clientX,
3592
+ y: event.clientY
3593
+ };
3594
+ pointers.set(event.pointerId, at);
3595
+ drag = {
3596
+ at,
3597
+ from: offset()
3598
+ };
3599
+ if (pointers.size === 2) pinch = {
3600
+ distance: distance(),
3601
+ scale: scale()
3602
+ };
3603
+ };
3604
+ const pointerMove = (event) => {
3605
+ if (!pointers.has(event.pointerId)) return;
3606
+ pointers.set(event.pointerId, {
3607
+ x: event.clientX,
3608
+ y: event.clientY
3609
+ });
3610
+ if (pointers.size === 2 && pinch !== void 0) {
3611
+ if (pinch.distance > 0) setScale(pinch.scale * (distance() / pinch.distance), local(midpoint()));
3612
+ return;
3613
+ }
3614
+ if (drag !== void 0) move({
3615
+ x: drag.from.x + event.clientX - drag.at.x,
3616
+ y: drag.from.y + event.clientY - drag.at.y
3617
+ });
3618
+ };
3619
+ const pointerUp = (event) => {
3620
+ pointers.delete(event.pointerId);
3621
+ pinch = void 0;
3622
+ const rest = [...pointers.values()][0];
3623
+ drag = rest === void 0 ? void 0 : {
3624
+ at: rest,
3625
+ from: offset()
3626
+ };
3627
+ };
3628
+ const wheel = (event) => {
3629
+ if (!event.ctrlKey && !event.metaKey) return;
3630
+ event.preventDefault();
3631
+ measure(event);
3632
+ const factor = event.deltaY < 0 ? WHEEL_FACTOR : 1 / WHEEL_FACTOR;
3633
+ setScale(scale() * factor, {
3634
+ x: event.clientX - box.left,
3635
+ y: event.clientY - box.top
3636
+ });
3637
+ };
3638
+ const keyDown = (event) => {
3639
+ measure(event);
3640
+ const by = PAN_STEP * (event.shiftKey ? PAN_SHIFT : 1);
3641
+ const current = offset();
3642
+ switch (event.key) {
3643
+ case "+":
3644
+ case "=":
3645
+ zoomIn();
3646
+ break;
3647
+ case "-":
3648
+ case "_":
3649
+ zoomOut();
3650
+ break;
3651
+ case "0":
3652
+ reset();
3653
+ break;
3654
+ case "ArrowLeft":
3655
+ move({
3656
+ x: current.x - by,
3657
+ y: current.y
3658
+ });
3659
+ break;
3660
+ case "ArrowRight":
3661
+ move({
3662
+ x: current.x + by,
3663
+ y: current.y
3664
+ });
3665
+ break;
3666
+ case "ArrowUp":
3667
+ move({
3668
+ x: current.x,
3669
+ y: current.y - by
3670
+ });
3671
+ break;
3672
+ case "ArrowDown":
3673
+ move({
3674
+ x: current.x,
3675
+ y: current.y + by
3676
+ });
3677
+ break;
3678
+ default: return;
3679
+ }
3680
+ event.preventDefault();
3681
+ };
3682
+ return {
3683
+ scale,
3684
+ offset,
3685
+ reset,
3686
+ zoomIn,
3687
+ zoomOut,
3688
+ setScale,
3689
+ value: () => `${Math.round(scale() * 100)}%`,
3690
+ viewportProps: {
3691
+ tabindex: 0,
3692
+ role: "group",
3693
+ "aria-roledescription": "zoomable image",
3694
+ get "aria-label"() {
3695
+ return options.labels?.viewport ?? "Image";
3696
+ },
3697
+ get "aria-describedby"() {
3698
+ return valueId;
3699
+ },
3700
+ onKeyDown: keyDown,
3701
+ onWheel: wheel,
3702
+ onPointerDown: pointerDown,
3703
+ onPointerMove: pointerMove,
3704
+ onPointerUp: pointerUp,
3705
+ onPointerCancel: pointerUp,
3706
+ onDblClick: (event) => {
3707
+ measure(event);
3708
+ reset();
3709
+ }
3710
+ },
3711
+ imageProps: {
3712
+ draggable: false,
3713
+ get style() {
3714
+ const at = offset();
3715
+ return { transform: `translate(${at.x}px, ${at.y}px) scale(${scale()})` };
3716
+ }
3717
+ },
3718
+ valueProps: {
3719
+ id: valueId,
3720
+ role: "status",
3721
+ "aria-live": "polite",
3722
+ "aria-atomic": "true"
3723
+ }
3724
+ };
3725
+ };
3269
3726
  // src/popover.ts
3270
3727
  const FLIPPED = {
3271
3728
  "bottom-start": "top-start",
@@ -3833,6 +4290,109 @@ const createGrid = (options = {}) => {
3833
4290
  }
3834
4291
  };
3835
4292
  };
4293
+ // src/hotkeys.ts
4294
+ const MODIFIERS = /* @__PURE__ */ new Set([
4295
+ "mod",
4296
+ "ctrl",
4297
+ "alt",
4298
+ "shift",
4299
+ "meta"
4300
+ ]);
4301
+ const SHIFTED = /* @__PURE__ */ new Set("~!@#$%^&*()_+{}|:\"<>?");
4302
+ const detectsApple = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
4303
+ const parse = (keys, isApple) => {
4304
+ const parts = keys.toLowerCase().split("+");
4305
+ const key = parts.pop() ?? "";
4306
+ if (key === "" || MODIFIERS.has(key)) throw new TypeError(`hotkeys: "${keys}" ends with no key`);
4307
+ const chord = {
4308
+ ctrl: false,
4309
+ meta: false,
4310
+ alt: false,
4311
+ shift: SHIFTED.has(key),
4312
+ key
4313
+ };
4314
+ for (const part of parts) switch (part) {
4315
+ case "mod":
4316
+ if (isApple) chord.meta = true;
4317
+ else chord.ctrl = true;
4318
+ break;
4319
+ case "ctrl":
4320
+ chord.ctrl = true;
4321
+ break;
4322
+ case "meta":
4323
+ chord.meta = true;
4324
+ break;
4325
+ case "alt":
4326
+ chord.alt = true;
4327
+ break;
4328
+ case "shift":
4329
+ chord.shift = true;
4330
+ break;
4331
+ default: throw new TypeError(`hotkeys: "${part}" in "${keys}" is not a modifier`);
4332
+ }
4333
+ return chord;
4334
+ };
4335
+ const matches = (chord, event) => event.key.toLowerCase() === chord.key && event.ctrlKey === chord.ctrl && event.metaKey === chord.meta && event.altKey === chord.alt && event.shiftKey === chord.shift;
4336
+ const editable = (target) => target instanceof HTMLElement && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
4337
+ const inScope = (scope, target) => scope === void 0 || target instanceof Node && scope.contains(target);
4338
+ /**
4339
+ * One `keydown` listener in the capture phase, one list of chords. A chord
4340
+ * fires when its modifiers match exactly, its `when` holds, its scope contains
4341
+ * the event's target, and the focus is not in a text field (unless
4342
+ * `allowInEditable`). The most recently bound match is the only one that fires,
4343
+ * so a modal's `"/"` overrides the page's and unbinding it brings the page's
4344
+ * back; `preventDefault` is on by default for the bindings whose chord the
4345
+ * browser would otherwise act on (Cmd/Ctrl+S, Cmd/Ctrl+K).
4346
+ */
4347
+ const createHotkeys = (options = {}) => {
4348
+ const isApple = options.isApple ?? detectsApple();
4349
+ const bindings = [];
4350
+ let listening = false;
4351
+ let disposed = false;
4352
+ const onKeyDown = (event) => {
4353
+ for (let i = bindings.length - 1; i >= 0; i--) {
4354
+ const entry = bindings[i];
4355
+ if (entry === void 0) continue;
4356
+ const { chord, binding } = entry;
4357
+ if (!matches(chord, event) || binding.when?.() === false) continue;
4358
+ if (!inScope(binding.scope, event.target)) continue;
4359
+ if (binding.allowInEditable !== true && editable(event.target)) continue;
4360
+ if (binding.preventDefault !== false) event.preventDefault();
4361
+ binding.onTrigger(event);
4362
+ return;
4363
+ }
4364
+ };
4365
+ const stop = () => {
4366
+ if (!listening) return;
4367
+ listening = false;
4368
+ document.removeEventListener("keydown", onKeyDown, true);
4369
+ };
4370
+ return {
4371
+ bind: (binding) => {
4372
+ if (disposed) return () => {};
4373
+ const entry = {
4374
+ chord: parse(binding.keys, isApple),
4375
+ binding
4376
+ };
4377
+ bindings.push(entry);
4378
+ if (!listening) {
4379
+ listening = true;
4380
+ document.addEventListener("keydown", onKeyDown, true);
4381
+ }
4382
+ return () => {
4383
+ const at = bindings.indexOf(entry);
4384
+ if (at === -1) return;
4385
+ bindings.splice(at, 1);
4386
+ if (bindings.length === 0) stop();
4387
+ };
4388
+ },
4389
+ dispose: () => {
4390
+ disposed = true;
4391
+ bindings.length = 0;
4392
+ stop();
4393
+ }
4394
+ };
4395
+ };
3836
4396
  // src/link.ts
3837
4397
  /**
3838
4398
  * APG Link (https://www.w3.org/WAI/ARIA/apg/patterns/link/): a native `<a href>`
@@ -5449,6 +6009,69 @@ const createResizablePanels = (options) => {
5449
6009
  separatorProps
5450
6010
  };
5451
6011
  };
6012
+ // src/scrollspy.ts
6013
+ /**
6014
+ * A scrollspy: a table of contents whose current entry is the last heading that
6015
+ * has reached the top of the scrollport, marked `aria-current="location"` on
6016
+ * its link (and nowhere else: exactly one link is current).
6017
+ *
6018
+ * **No APG pattern exists for a scrollspy**, so there is no Quint spec and no
6019
+ * ported scenarios: the contract is WAI-ARIA (`aria-current="location"`, the
6020
+ * `list` / `listitem` roles, the navigation landmark and its name) and HTML,
6021
+ * and `test/scrollspy.browser.test.tsx` is its oracle.
6022
+ *
6023
+ * **A scroll listener, not an `IntersectionObserver`.** "The last heading at or
6024
+ * above the offset" is a total order over every heading, while an observer only
6025
+ * reports what crossed its band: a band observer has to rebuild the order from
6026
+ * its enter/leave state, and it misreports after a jump (an anchor click, a
6027
+ * restored scroll position). The listener is passive, the measurements are
6028
+ * reads only (no writes, so no layout thrash), and a docs page has a dozen
6029
+ * headings — the same cost as the listener the page already keeps.
6030
+ *
6031
+ * Smooth scrolling is the host's (`scroll-behavior: smooth`): the links are
6032
+ * plain `#id` targets and nothing here moves the scrollport, so no
6033
+ * `prefers-reduced-motion` branch is needed either.
6034
+ */
6035
+ const createScrollspy = (options) => {
6036
+ const offset = options.offset ?? 0;
6037
+ const first = options.entries[0]?.id;
6038
+ const [active, setActive] = createSignal(first);
6039
+ const at = () => {
6040
+ const container = options.root?.()?.getBoundingClientRect().top ?? 0;
6041
+ let id = first;
6042
+ for (const entry of options.entries) {
6043
+ const element = document.getElementById(entry.id);
6044
+ if (element === null) continue;
6045
+ if (element.getBoundingClientRect().top - container <= offset) id = entry.id;
6046
+ }
6047
+ return id;
6048
+ };
6049
+ const commit = () => {
6050
+ const next = at();
6051
+ if (next === active()) return;
6052
+ setActive(next);
6053
+ options.onChange?.(next);
6054
+ };
6055
+ return {
6056
+ active,
6057
+ navProps: { "aria-label": "On this page" },
6058
+ listProps: { role: "list" },
6059
+ itemProps: () => ({ role: "listitem" }),
6060
+ linkProps: (id) => ({
6061
+ href: `#${id}`,
6062
+ get "aria-current"() {
6063
+ return id === active() ? "location" : void 0;
6064
+ }
6065
+ }),
6066
+ attach: () => {
6067
+ const target = options.root?.() ?? document;
6068
+ const listener = () => commit();
6069
+ target.addEventListener("scroll", listener, { passive: true });
6070
+ commit();
6071
+ return () => target.removeEventListener("scroll", listener);
6072
+ }
6073
+ };
6074
+ };
5452
6075
  // src/slider.ts
5453
6076
  const valueTextFor$1 = (value) => value <= 3 ? "low" : value <= 7 ? "medium" : "high";
5454
6077
  /**
@@ -5466,6 +6089,11 @@ const valueTextFor$1 = (value) => value <= 3 ? "low" : value <= 7 ? "medium" : "
5466
6089
  * pass `value` + `onValueChange` to own the value. A key or rail press is a
5467
6090
  * request the host may ignore.
5468
6091
  *
6092
+ * `disabled` is a widget-level extension of the pattern (the spec lists it
6093
+ * under its deliberate omissions): the thumb keeps its tab stop and announces
6094
+ * `aria-disabled`, and every key and rail press is dropped before it becomes a
6095
+ * request.
6096
+ *
5469
6097
  * ponytail: dragging (pointer capture/move) is not modelled — the spec
5470
6098
  * deliberately only fixes a discrete rail press.
5471
6099
  */
@@ -5484,6 +6112,7 @@ const createSlider = (options = {}) => {
5484
6112
  const [focused, setFocused] = createSignal(false);
5485
6113
  const orientation = () => options.orientation ?? "horizontal";
5486
6114
  const direction = () => options.direction ?? "ltr";
6115
+ const disabled = () => options.disabled === true;
5487
6116
  const flipped = () => orientation() === "horizontal" && direction() === "rtl";
5488
6117
  const commit = (next) => request(clamp(next));
5489
6118
  const delta = (key) => {
@@ -5498,7 +6127,7 @@ const createSlider = (options = {}) => {
5498
6127
  }
5499
6128
  };
5500
6129
  const keyDown = (key) => {
5501
- if (!focused()) return false;
6130
+ if (!focused() || disabled()) return false;
5502
6131
  if (key === "Home") {
5503
6132
  commit(min);
5504
6133
  return true;
@@ -5513,6 +6142,7 @@ const createSlider = (options = {}) => {
5513
6142
  return true;
5514
6143
  };
5515
6144
  const pointerDown = (position) => {
6145
+ if (disabled()) return;
5516
6146
  const steps = Math.round(position * (max - min) / step);
5517
6147
  commit(min + steps * step);
5518
6148
  setFocused(true);
@@ -5539,6 +6169,9 @@ const createSlider = (options = {}) => {
5539
6169
  get "aria-orientation"() {
5540
6170
  return orientation();
5541
6171
  },
6172
+ get "aria-disabled"() {
6173
+ return disabled() ? "true" : void 0;
6174
+ },
5542
6175
  get tabindex() {
5543
6176
  return 0;
5544
6177
  },
@@ -5703,6 +6336,12 @@ const createSliderMultiThumb = (options = {}) => {
5703
6336
  * stepper buttons, Enter and blur are requests the host may ignore; typing and
5704
6337
  * Escape change no committed value.
5705
6338
  *
6339
+ * `disabled` is a widget-level extension of the pattern (the spec models the
6340
+ * steppers' bound only, and lists this under its deliberate omissions): the
6341
+ * field announces `aria-disabled` and carries the native `disabled`, every key,
6342
+ * typed draft and both steppers are dropped before they become a request, and
6343
+ * neither stepper offers itself.
6344
+ *
5706
6345
  * ponytail: commit/revert on Enter/blur/Escape is the common convention, not
5707
6346
  * APG text.
5708
6347
  */
@@ -5723,6 +6362,7 @@ const createSpinbutton = (options = {}) => {
5723
6362
  const [editing, setEditing] = createSignal(false);
5724
6363
  const [draft, setDraft] = createSignal("");
5725
6364
  const [wrap, setWrapSignal] = createSignal(options.wrap ?? false);
6365
+ const disabled = () => options.disabled === true;
5726
6366
  const draftInRange = (text) => {
5727
6367
  const number = Number(text);
5728
6368
  return text.trim() !== "" && number >= min && number <= max;
@@ -5750,7 +6390,7 @@ const createSpinbutton = (options = {}) => {
5750
6390
  commitValue(next);
5751
6391
  };
5752
6392
  const keyDown = (key) => {
5753
- if (!focused()) return false;
6393
+ if (!focused() || disabled()) return false;
5754
6394
  switch (key) {
5755
6395
  case "ArrowUp":
5756
6396
  applyStep(1, step);
@@ -5782,7 +6422,7 @@ const createSpinbutton = (options = {}) => {
5782
6422
  }
5783
6423
  };
5784
6424
  const commit = () => {
5785
- if (!focused()) return;
6425
+ if (!focused() || disabled()) return;
5786
6426
  const next = baseValue();
5787
6427
  clearDraft();
5788
6428
  commitValue(next);
@@ -5790,8 +6430,25 @@ const createSpinbutton = (options = {}) => {
5790
6430
  const revert = () => {
5791
6431
  clearDraft();
5792
6432
  };
6433
+ const blurField = () => {
6434
+ const next = baseValue();
6435
+ clearDraft();
6436
+ setFocused(false);
6437
+ if (disabled()) return;
6438
+ commitValue(next);
6439
+ };
6440
+ const increment = () => {
6441
+ if (disabled()) return;
6442
+ setFocused(true);
6443
+ applyStep(1, step);
6444
+ };
6445
+ const decrement = () => {
6446
+ if (disabled()) return;
6447
+ setFocused(true);
6448
+ applyStep(-1, step);
6449
+ };
5793
6450
  const displayed = () => editing() ? draft() : String(value());
5794
- const ariaDisabled = (atBound) => !wrap() && atBound ? "true" : void 0;
6451
+ const ariaDisabled = (atBound) => disabled() || !wrap() && atBound ? "true" : void 0;
5795
6452
  return {
5796
6453
  value,
5797
6454
  focused,
@@ -5800,28 +6457,17 @@ const createSpinbutton = (options = {}) => {
5800
6457
  displayed,
5801
6458
  setValue: commitValue,
5802
6459
  focus: () => setFocused(true),
5803
- blur: () => {
5804
- const next = baseValue();
5805
- clearDraft();
5806
- setFocused(false);
5807
- commitValue(next);
5808
- },
6460
+ blur: blurField,
5809
6461
  keyDown,
5810
6462
  typeInput: (text) => {
5811
- if (!focused()) return;
6463
+ if (!focused() || disabled()) return;
5812
6464
  setEditing(true);
5813
6465
  setDraft(text);
5814
6466
  },
5815
6467
  commit,
5816
6468
  revert,
5817
- increment: () => {
5818
- setFocused(true);
5819
- applyStep(1, step);
5820
- },
5821
- decrement: () => {
5822
- setFocused(true);
5823
- applyStep(-1, step);
5824
- },
6469
+ increment,
6470
+ decrement,
5825
6471
  setWrap: (next) => setWrapSignal(next),
5826
6472
  inputProps: {
5827
6473
  id: `${base}-input`,
@@ -5837,6 +6483,12 @@ const createSpinbutton = (options = {}) => {
5837
6483
  get "aria-invalid"() {
5838
6484
  return editing() && !draftInRange(draft()) ? "true" : void 0;
5839
6485
  },
6486
+ get "aria-disabled"() {
6487
+ return disabled() ? "true" : void 0;
6488
+ },
6489
+ get disabled() {
6490
+ return disabled();
6491
+ },
5840
6492
  get tabindex() {
5841
6493
  return 0;
5842
6494
  },
@@ -5844,18 +6496,13 @@ const createSpinbutton = (options = {}) => {
5844
6496
  return displayed();
5845
6497
  },
5846
6498
  onFocus: () => setFocused(true),
5847
- onBlur: () => {
5848
- const next = baseValue();
5849
- clearDraft();
5850
- setFocused(false);
5851
- commitValue(next);
5852
- },
6499
+ onBlur: blurField,
5853
6500
  onKeyDown: (event) => {
5854
6501
  if (keyDown(event.key)) event.preventDefault();
5855
6502
  },
5856
6503
  onInput: (event) => {
5857
6504
  const target = event.currentTarget;
5858
- if (target !== null) {
6505
+ if (target !== null && !disabled()) {
5859
6506
  setEditing(true);
5860
6507
  setDraft(target.value);
5861
6508
  }
@@ -5869,10 +6516,7 @@ const createSpinbutton = (options = {}) => {
5869
6516
  return ariaDisabled(value() === max);
5870
6517
  },
5871
6518
  onMouseDown: (event) => event.preventDefault(),
5872
- onClick: () => {
5873
- setFocused(true);
5874
- applyStep(1, step);
5875
- }
6519
+ onClick: increment
5876
6520
  },
5877
6521
  decrementProps: {
5878
6522
  role: "button",
@@ -5882,10 +6526,7 @@ const createSpinbutton = (options = {}) => {
5882
6526
  return ariaDisabled(value() === min);
5883
6527
  },
5884
6528
  onMouseDown: (event) => event.preventDefault(),
5885
- onClick: () => {
5886
- setFocused(true);
5887
- applyStep(-1, step);
5888
- }
6529
+ onClick: decrement
5889
6530
  }
5890
6531
  };
5891
6532
  };
@@ -6272,28 +6913,40 @@ const createTextArea = (options = {}) => {
6272
6913
  * Request/commit (map ticket `rt1p`): uncontrolled by default
6273
6914
  * (`defaultSelected`); pass `value` + `onValueChange` to own the selection.
6274
6915
  * Programmatic methods (`select`, `setSelected`, `keyDown`) are requests too.
6916
+ *
6917
+ * Close and rename are an **extension** (bean `cssints-gdv2`): the APG spec
6918
+ * models a `deletable` flavour but neither a callback nor a rename, and this
6919
+ * primitive owns no list. `closable` / `renamable` turn on the props; `onClose`
6920
+ * / `onRename` ask the host, which removes or renames. Focus after a close goes
6921
+ * to the tab that slid into the closed one's place, or to the previous tab when
6922
+ * the last one closed; closing the last remaining tab is refused, as the spec
6923
+ * refuses an empty tablist.
6275
6924
  */
6276
6925
  const createTabs = (options = {}) => {
6277
- const items = Math.max(1, options.items ?? 4);
6926
+ /** The live count: the host shrinks its list after a close, and the options are read lazily. */
6927
+ const count = () => Math.max(1, options.items ?? 4);
6278
6928
  const base = options.id ?? createUniqueId();
6279
6929
  const disabled = new Set(options.disabled ?? []);
6280
6930
  const focusablePanels = new Set(options.focusablePanels ?? []);
6281
6931
  const activation = () => options.activation ?? "automatic";
6282
6932
  const orientation = () => options.orientation ?? "horizontal";
6933
+ const closable = () => options.closable ?? false;
6934
+ const renamable = () => options.renamable ?? false;
6935
+ const labelOf = (index) => options.label?.(index) ?? `tab ${index + 1}`;
6283
6936
  const firstEnabled = () => {
6284
- for (let index = 0; index < items; index += 1) if (!disabled.has(index)) return index;
6937
+ for (let index = 0; index < count(); index += 1) if (!disabled.has(index)) return index;
6285
6938
  return 0;
6286
6939
  };
6287
6940
  const initial = options.defaultSelected ?? firstEnabled();
6288
6941
  const [selected, request] = createControllable({
6289
6942
  value: () => options.value,
6290
- initial: initial >= 0 && initial < items && !disabled.has(initial) ? initial : firstEnabled(),
6943
+ initial: initial >= 0 && initial < count() && !disabled.has(initial) ? initial : firstEnabled(),
6291
6944
  onChange: () => options.onValueChange
6292
6945
  });
6293
6946
  /** The tab that carries `tabindex=0`: the selected one, or the first enabled. */
6294
6947
  const roving = () => disabled.has(selected()) ? firstEnabled() : selected();
6295
6948
  const select = (index) => {
6296
- if (index < 0 || index >= items || disabled.has(index)) return;
6949
+ if (index < 0 || index >= count() || disabled.has(index)) return;
6297
6950
  request(index);
6298
6951
  };
6299
6952
  const move = (index) => {
@@ -6301,14 +6954,59 @@ const createTabs = (options = {}) => {
6301
6954
  if (activation() === "automatic") select(index);
6302
6955
  };
6303
6956
  const step = (from, delta) => {
6304
- for (let offset = 1; offset <= items; offset += 1) {
6305
- const candidate = ((from + delta * offset) % items + items) % items;
6957
+ for (let offset = 1; offset <= count(); offset += 1) {
6958
+ const candidate = ((from + delta * offset) % count() + count()) % count();
6306
6959
  if (!disabled.has(candidate)) return candidate;
6307
6960
  }
6308
6961
  return from;
6309
6962
  };
6310
6963
  const [focused, setFocused] = createSignal(-1);
6964
+ /**
6965
+ * The tab that takes focus once `onClose(index)`'s removal lands: the tab at
6966
+ * `index` in the shortened list (the following tab slid into it), or the
6967
+ * previous one when the last tab closed.
6968
+ */
6969
+ const focusAfterClose = (index) => index < count() - 1 ? index : Math.max(0, count() - 2);
6970
+ const close = (index) => {
6971
+ if (!closable() || count() <= 1 || index < 0 || index >= count()) return;
6972
+ const next = focusAfterClose(index);
6973
+ setFocused(next);
6974
+ if (selected() === index) select(next);
6975
+ options.onClose?.(index);
6976
+ };
6977
+ const [editVersion, setEditVersion] = createSignal(0);
6978
+ let editingIndex = null;
6979
+ const editing = () => {
6980
+ editVersion();
6981
+ return editingIndex;
6982
+ };
6983
+ const setEditing = (index) => {
6984
+ editingIndex = index;
6985
+ setEditVersion((version) => version + 1);
6986
+ };
6987
+ const [draft, setDraft] = createSignal("");
6988
+ const begin = (index) => {
6989
+ if (!renamable() || disabled.has(index) || index < 0 || index >= count()) return;
6990
+ setEditing(index);
6991
+ setDraft(options.label?.(index) ?? "");
6992
+ };
6993
+ const cancel = () => {
6994
+ if (editing() === null) return;
6995
+ setEditing(null);
6996
+ setDraft("");
6997
+ };
6998
+ /** Enter / blur commit; an empty label is not a name, so it cancels. */
6999
+ const commit = (label) => {
7000
+ const index = editing();
7001
+ if (index === null) return;
7002
+ setEditing(null);
7003
+ setDraft("");
7004
+ const trimmed = label.trim();
7005
+ if (trimmed === "") return;
7006
+ options.onRename?.(index, trimmed);
7007
+ };
6311
7008
  const keyDown = (key) => {
7009
+ if (editing() !== null) return;
6312
7010
  const current = focused();
6313
7011
  if (current < 0) return;
6314
7012
  const forward = orientation() === "horizontal" ? "ArrowRight" : "ArrowDown";
@@ -6324,27 +7022,34 @@ const createTabs = (options = {}) => {
6324
7022
  move(firstEnabled());
6325
7023
  break;
6326
7024
  case "End": {
6327
- let last = items - 1;
7025
+ let last = count() - 1;
6328
7026
  while (last > 0 && disabled.has(last)) last -= 1;
6329
7027
  move(last);
6330
7028
  break;
6331
7029
  }
6332
7030
  case "Enter":
6333
7031
  case " ":
6334
- case "Space": select(current);
7032
+ case "Space":
7033
+ select(current);
7034
+ break;
7035
+ case "Delete": close(current);
6335
7036
  }
6336
7037
  };
6337
7038
  return {
6338
- items,
7039
+ get items() {
7040
+ return count();
7041
+ },
6339
7042
  selected,
6340
7043
  focused,
7044
+ editing,
6341
7045
  select,
6342
7046
  setSelected: (index) => {
6343
- if (index >= 0 && index < items) request(index);
7047
+ if (index >= 0 && index < count()) request(index);
6344
7048
  },
6345
7049
  focusTab: (index) => {
6346
- if (index >= 0 && index < items) setFocused(index);
7050
+ if (index >= 0 && index < count()) setFocused(index);
6347
7051
  },
7052
+ focusAfterClose,
6348
7053
  keyDown,
6349
7054
  tablistProps: {
6350
7055
  role: "tablist",
@@ -6377,6 +7082,12 @@ const createTabs = (options = {}) => {
6377
7082
  setFocused(index);
6378
7083
  },
6379
7084
  onKeyDown: (event) => {
7085
+ if (editing() !== null) return;
7086
+ if (event.key === "Delete" && closable()) {
7087
+ event.preventDefault();
7088
+ close(index);
7089
+ return;
7090
+ }
6380
7091
  const before = focused();
6381
7092
  keyDown(event.key);
6382
7093
  if (focused() !== before || event.key === "Enter" || event.key === " ") event.preventDefault();
@@ -6392,6 +7103,43 @@ const createTabs = (options = {}) => {
6392
7103
  get hidden() {
6393
7104
  return selected() !== index;
6394
7105
  }
7106
+ }),
7107
+ closeProps: (index) => ({
7108
+ get "aria-label"() {
7109
+ return `Close ${labelOf(index)}`;
7110
+ },
7111
+ tabindex: -1,
7112
+ onClick: () => close(index)
7113
+ }),
7114
+ renameProps: (index) => ({
7115
+ onDblClick: () => begin(index),
7116
+ editing: () => editing() === index,
7117
+ begin,
7118
+ commit,
7119
+ cancel,
7120
+ inputProps: {
7121
+ type: "text",
7122
+ get value() {
7123
+ return draft();
7124
+ },
7125
+ get "aria-label"() {
7126
+ return `Rename ${labelOf(index)}`;
7127
+ },
7128
+ onInput: (event) => {
7129
+ const target = event.currentTarget;
7130
+ if (target !== null) setDraft(target.value);
7131
+ },
7132
+ onKeyDown: (event) => {
7133
+ if (event.key === "Enter") {
7134
+ event.preventDefault();
7135
+ commit(draft());
7136
+ } else if (event.key === "Escape") {
7137
+ event.preventDefault();
7138
+ cancel();
7139
+ }
7140
+ },
7141
+ onBlur: () => commit(draft())
7142
+ }
6395
7143
  })
6396
7144
  };
6397
7145
  };
@@ -6472,11 +7220,10 @@ const shrink = (x) => x <= MIN_WIDTH ? x : x - Math.floor(x / 4) < MIN_WIDTH ? M
6472
7220
  const grow = (x) => x >= DOMAIN_MAX ? x : x + Math.floor(x / 4) > DOMAIN_MAX ? DOMAIN_MAX : x + Math.floor(x / 4);
6473
7221
  const clampStart = (start, w) => start < DOMAIN_MIN ? DOMAIN_MIN : start + w > DOMAIN_MAX ? DOMAIN_MAX - w : start;
6474
7222
  /**
6475
- * Headless span timeline on Solid 2. The exported accessors are signals; the
6476
- * prop objects are getter-backed, so spreading them keeps every attribute
6477
- * reactive.
7223
+ * The demo tree: five fixed spans, collapsing, zoom. Its oracle is
7224
+ * `specs/apg/timeline.qnt` through the differential walk.
6478
7225
  */
6479
- const createTimeline = (options = {}) => {
7226
+ const createDemoTimeline = (options = {}) => {
6480
7227
  const initialExpanded = (options.defaultExpanded ?? []).reduce((mask, node) => setBit(mask, node, true), 0);
6481
7228
  const [expanded, setExpanded] = createSignal(initialExpanded);
6482
7229
  const [selected, requestSelected] = createControllable({
@@ -6631,6 +7378,172 @@ const createTimeline = (options = {}) => {
6631
7378
  }
6632
7379
  };
6633
7380
  };
7381
+ /** A zero-length span still paints: at least half a percent of the track. */
7382
+ const MIN_BAR_WIDTH = .5;
7383
+ /** Aim for about five ticks, whatever the extent. */
7384
+ const TICK_TARGET = 5;
7385
+ const percent = (value) => `${Math.round(value * 1e3) / 1e3}%`;
7386
+ /** 1, 2 or 5 times a power of ten, the step that makes the fewest labels fit. */
7387
+ const niceStep = (span) => {
7388
+ const raw = span / TICK_TARGET;
7389
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
7390
+ const normalized = raw / magnitude;
7391
+ return (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
7392
+ };
7393
+ const formatTick = (value, step, unit) => {
7394
+ const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step)));
7395
+ return `${value.toFixed(decimals)} ${unit}`;
7396
+ };
7397
+ /**
7398
+ * The rows mode: real span data on one axis. Everything hangs off `options.rows`
7399
+ * read at attribute-read time, so the host may pass a getter and a trace may
7400
+ * arrive after mount.
7401
+ */
7402
+ const createRowsTimeline = (options) => {
7403
+ const rows = () => options.rows;
7404
+ const rowOf = (id) => {
7405
+ const row = rows().find((candidate) => candidate.id === id);
7406
+ if (row === void 0) throw new Error(`createTimeline: no row ${id}`);
7407
+ return row;
7408
+ };
7409
+ const [selectedId, requestSelected] = createControllable({
7410
+ value: () => options.selected,
7411
+ initial: void 0,
7412
+ onChange: () => (id) => {
7413
+ if (id !== void 0) options.onSelect?.(id);
7414
+ }
7415
+ });
7416
+ const [focus, setFocus] = createSignal(void 0);
7417
+ const currentFocus = () => focus() ?? rows()[0]?.id;
7418
+ const select = (id) => {
7419
+ requestSelected(id);
7420
+ };
7421
+ const extent = () => {
7422
+ let start = Number.POSITIVE_INFINITY;
7423
+ let end = Number.NEGATIVE_INFINITY;
7424
+ for (const row of rows()) {
7425
+ start = Math.min(start, row.start);
7426
+ end = Math.max(end, row.end);
7427
+ }
7428
+ return start <= end ? {
7429
+ start,
7430
+ end
7431
+ } : {
7432
+ start: 0,
7433
+ end: 0
7434
+ };
7435
+ };
7436
+ const ticks = () => {
7437
+ if (rows().length === 0) return [];
7438
+ const { start, end } = extent();
7439
+ const unit = options.axis?.unit ?? "ms";
7440
+ const span = end - start;
7441
+ if (!(span > 0)) return [{
7442
+ value: start,
7443
+ left: 0,
7444
+ label: formatTick(start, 1, unit)
7445
+ }];
7446
+ const step = niceStep(span);
7447
+ const out = [];
7448
+ for (let value = Math.ceil(start / step) * step; value <= end; value += step) out.push({
7449
+ value,
7450
+ left: (value - start) / span * 100,
7451
+ label: formatTick(value, step, unit)
7452
+ });
7453
+ return out;
7454
+ };
7455
+ const barStyle = (id) => {
7456
+ const row = rowOf(id);
7457
+ const depth = String(row.depth ?? 0);
7458
+ const { start, end } = extent();
7459
+ const span = end - start;
7460
+ if (!(span > 0)) return {
7461
+ left: "0%",
7462
+ width: percent(MIN_BAR_WIDTH),
7463
+ "--depth": depth
7464
+ };
7465
+ const left = Math.max(0, (row.start - start) / span * 100);
7466
+ const width = Math.max(MIN_BAR_WIDTH, (row.end - row.start) / span * 100);
7467
+ return {
7468
+ left: percent(left),
7469
+ width: percent(width),
7470
+ "--depth": depth
7471
+ };
7472
+ };
7473
+ const onKeyDown = (id, event) => {
7474
+ const list = rows();
7475
+ const index = list.findIndex((row) => row.id === id);
7476
+ switch (event.key) {
7477
+ case "ArrowDown":
7478
+ setFocus(list[Math.min(index + 1, list.length - 1)]?.id);
7479
+ break;
7480
+ case "ArrowUp":
7481
+ setFocus(list[Math.max(index - 1, 0)]?.id);
7482
+ break;
7483
+ case "Home":
7484
+ setFocus(list[0]?.id);
7485
+ break;
7486
+ case "End":
7487
+ setFocus(list[list.length - 1]?.id);
7488
+ break;
7489
+ case "Enter":
7490
+ case " ":
7491
+ select(id);
7492
+ break;
7493
+ default: return;
7494
+ }
7495
+ event.preventDefault();
7496
+ };
7497
+ return {
7498
+ extent,
7499
+ depthOf: (id) => rowOf(id).depth ?? 0,
7500
+ listProps: {
7501
+ role: "list",
7502
+ "aria-label": "Span timeline"
7503
+ },
7504
+ rowProps: (id) => ({
7505
+ role: "listitem",
7506
+ get "aria-level"() {
7507
+ return (rowOf(id).depth ?? 0) + 1;
7508
+ },
7509
+ get "aria-current"() {
7510
+ return selectedId() === id ? "true" : void 0;
7511
+ },
7512
+ get "data-status"() {
7513
+ return rowOf(id).status ?? "unset";
7514
+ },
7515
+ get tabindex() {
7516
+ return currentFocus() === id ? 0 : -1;
7517
+ },
7518
+ onClick: () => {
7519
+ setFocus(id);
7520
+ select(id);
7521
+ },
7522
+ onKeyDown: (event) => onKeyDown(id, event)
7523
+ }),
7524
+ barProps: (id) => ({
7525
+ "aria-hidden": "true",
7526
+ get style() {
7527
+ return barStyle(id);
7528
+ }
7529
+ }),
7530
+ labelProps: (id) => ({ get style() {
7531
+ return { "--depth": String(rowOf(id).depth ?? 0) };
7532
+ } }),
7533
+ rulerProps: {
7534
+ role: "presentation",
7535
+ "aria-hidden": "true",
7536
+ get ticks() {
7537
+ return ticks();
7538
+ }
7539
+ },
7540
+ select,
7541
+ selectedId
7542
+ };
7543
+ };
7544
+ function createTimeline(options = {}) {
7545
+ return "rows" in options ? createRowsTimeline(options) : createDemoTimeline(options);
7546
+ }
6634
7547
  // src/toast.ts
6635
7548
  const DEFAULT_TIMEOUT_MS = 5e3;
6636
7549
  const DEFAULT_MAX = 3;
@@ -8361,4 +9274,4 @@ const createRearrangeableListbox = (options) => {
8361
9274
  })
8362
9275
  };
8363
9276
  };
8364
- export { ConfirmPendingError, DEFAULT_PREFS, DRAG_PX, GRID, NO_POS, SNAP_PX, TONE, TREEVIEW_NODES, TWEEN_TICKS, WORLD_BOUND, YES_POS, ZMAX, ZMIN, ZONE, cellPart, createAccordion, createAlert, createAlertDialog, createBreadcrumb, createButton, createCanvas, createCarousel, createCheckbox, createCheckboxGroup, createCombobox, createConfirm, createDialogModal, createDisclosure, createDrawer, createFeed, createField, createGrid, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createSlider, createSliderMultiThumb, createSpinbutton, createStatus, createSwitch, createTable, createTabs, createTextArea, createTextInput, createTimeline, createToast, createToolbar, createTooltipGroup, createTreegrid, createTreeview, createWindowSplitter, gridCandidates, headerPart, packSelection, packSort, rowPart, treegridCellPart, treegridFocusTargetPart, treegridRowPart, withPrefs };
9277
+ export { ConfirmPendingError, DEFAULT_PREFS, DRAG_PX, GRID, NO_POS, SNAP_PX, TONE, TREEVIEW_NODES, TWEEN_TICKS, WORLD_BOUND, YES_POS, ZMAX, ZMIN, ZONE, cellPart, createAccordion, createAlert, createAlertDialog, createBreadcrumb, createButton, createCanvas, createCarousel, createCheckbox, createCheckboxGroup, createCombobox, createConfirm, createDialogModal, createDisclosure, createDrawer, createDropZone, createFeed, createField, createGrid, createHotkeys, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPanZoom, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createScrollspy, createSlider, createSliderMultiThumb, createSpinbutton, createStatus, createSwitch, createTable, createTabs, createTextArea, createTextInput, createTimeline, createToast, createToolbar, createTooltipGroup, createTreegrid, createTreeview, createWindowSplitter, gridCandidates, headerPart, packSelection, packSort, rowPart, treegridCellPart, treegridFocusTargetPart, treegridRowPart, withPrefs };