@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/README.md +216 -1
- package/dist/index.d.mts +527 -10
- package/dist/index.mjs +963 -50
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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 <
|
|
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 <
|
|
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 >=
|
|
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 <=
|
|
6305
|
-
const candidate = ((from + delta * offset) %
|
|
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 =
|
|
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":
|
|
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 <
|
|
7047
|
+
if (index >= 0 && index < count()) request(index);
|
|
6344
7048
|
},
|
|
6345
7049
|
focusTab: (index) => {
|
|
6346
|
-
if (index >= 0 && 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
|
-
*
|
|
6476
|
-
*
|
|
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
|
|
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 };
|