what-core 0.12.2 → 0.12.4
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/{chunk-NCPX66TV.min.js → chunk-M5GDJRVX.min.js} +1 -1
- package/dist/chunk-T2SKNKT5.min.js +11 -0
- package/dist/index.min.js +5 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +329 -40
- package/package.json +1 -1
- package/src/a11y.js +234 -23
- package/src/agent-context.js +1 -1
- package/src/animation.js +8 -0
- package/src/data.js +730 -105
- package/src/dom.js +52 -0
- package/src/errors.js +12 -1
- package/src/form.js +329 -31
- package/src/hooks.js +20 -3
- package/src/index.js +6 -0
- package/src/render.js +872 -50
- package/src/scheduler.js +17 -0
- package/src/warnings.js +83 -0
- package/dist/chunk-RXISSKLI.min.js +0 -11
package/src/a11y.js
CHANGED
|
@@ -268,6 +268,35 @@ export function SkipLink({ href = '#main', children = 'Skip to content' }) {
|
|
|
268
268
|
|
|
269
269
|
// --- ARIA Helpers ---
|
|
270
270
|
|
|
271
|
+
// Every *Props() helper below returns ACCESSOR-valued ARIA props, never a
|
|
272
|
+
// resolved value.
|
|
273
|
+
//
|
|
274
|
+
// The obvious and documented way to consume them is a spread,
|
|
275
|
+
//
|
|
276
|
+
// <button {...buttonProps()}>
|
|
277
|
+
//
|
|
278
|
+
// and a spread evaluates its argument exactly once. When the helper read the
|
|
279
|
+
// signal itself (`'aria-expanded': expanded()`), that single evaluation baked
|
|
280
|
+
// the state at mount into a plain boolean: an accordion announced
|
|
281
|
+
// aria-expanded="false" forever, no matter how many times it opened. Components
|
|
282
|
+
// run once in What, so nothing ever re-called buttonProps() to refresh it.
|
|
283
|
+
// Handing back a thunk instead moves the read to where the renderer can track
|
|
284
|
+
// it. Both spread paths already treat a function-valued prop as a reactive
|
|
285
|
+
// accessor and wrap it in a micro-effect (render.js spread() for compiled JSX,
|
|
286
|
+
// dom.js setProp() for h()), and renderToString() calls it during SSR, so the
|
|
287
|
+
// same object works on all three.
|
|
288
|
+
//
|
|
289
|
+
// The values are coerced to the STRINGS "true"/"false" rather than left as
|
|
290
|
+
// booleans. aria-*/role are enumerated attributes: `aria-checked=""` is not a
|
|
291
|
+
// valid value and an absent aria-expanded means "unsupported" to assistive
|
|
292
|
+
// technology rather than "collapsed". The render paths normalize this too (see
|
|
293
|
+
// _isAriaAttr in dom.js), but the helper is the layer that knows the attribute
|
|
294
|
+
// is ARIA, so it should not depend on a downstream branch ordering to be
|
|
295
|
+
// correct, and callers who log or diff the returned object see the real value.
|
|
296
|
+
function ariaBool(value) {
|
|
297
|
+
return value ? 'true' : 'false';
|
|
298
|
+
}
|
|
299
|
+
|
|
271
300
|
export function useAriaExpanded(initialExpanded = false) {
|
|
272
301
|
const expanded = signal(initialExpanded);
|
|
273
302
|
|
|
@@ -277,11 +306,13 @@ export function useAriaExpanded(initialExpanded = false) {
|
|
|
277
306
|
open: () => expanded.set(true),
|
|
278
307
|
close: () => expanded.set(false),
|
|
279
308
|
buttonProps: () => ({
|
|
280
|
-
'aria-expanded': expanded(),
|
|
309
|
+
'aria-expanded': () => ariaBool(expanded()),
|
|
281
310
|
onClick: () => expanded.set(!expanded.peek()),
|
|
282
311
|
}),
|
|
283
312
|
panelProps: () => ({
|
|
284
|
-
hidden
|
|
313
|
+
// `hidden` is a genuine HTML boolean attribute, so it stays a boolean:
|
|
314
|
+
// present-or-absent is the correct serialization here, unlike ARIA.
|
|
315
|
+
hidden: () => !expanded(),
|
|
285
316
|
}),
|
|
286
317
|
};
|
|
287
318
|
}
|
|
@@ -294,7 +325,7 @@ export function useAriaSelected(initialSelected = null) {
|
|
|
294
325
|
select: (value) => selected.set(value),
|
|
295
326
|
isSelected: (value) => selected() === value,
|
|
296
327
|
itemProps: (value) => ({
|
|
297
|
-
'aria-selected': selected() === value,
|
|
328
|
+
'aria-selected': () => ariaBool(selected() === value),
|
|
298
329
|
onClick: () => selected.set(value),
|
|
299
330
|
}),
|
|
300
331
|
};
|
|
@@ -309,7 +340,7 @@ export function useAriaChecked(initialChecked = false) {
|
|
|
309
340
|
set: (value) => checked.set(value),
|
|
310
341
|
checkboxProps: () => ({
|
|
311
342
|
role: 'checkbox',
|
|
312
|
-
'aria-checked': checked(),
|
|
343
|
+
'aria-checked': () => ariaBool(checked()),
|
|
313
344
|
tabIndex: 0,
|
|
314
345
|
onClick: () => checked.set(!checked.peek()),
|
|
315
346
|
onKeyDown: (e) => {
|
|
@@ -325,49 +356,229 @@ export function useAriaChecked(initialChecked = false) {
|
|
|
325
356
|
// --- Roving Tab Index ---
|
|
326
357
|
// For keyboard navigation in lists, toolbars, etc.
|
|
327
358
|
|
|
328
|
-
export function useRovingTabIndex(itemCountOrSignal) {
|
|
359
|
+
export function useRovingTabIndex(itemCountOrSignal, options = {}) {
|
|
329
360
|
// Accept either a static number or a signal/getter for dynamic lists
|
|
330
361
|
const getCount = typeof itemCountOrSignal === 'function'
|
|
331
362
|
? itemCountOrSignal
|
|
332
363
|
: () => itemCountOrSignal;
|
|
333
364
|
const focusIndex = signal(0);
|
|
334
365
|
|
|
366
|
+
// The container role belongs to the CALLER, so this hook emits none of its
|
|
367
|
+
// own. Every call site has the shape
|
|
368
|
+
//
|
|
369
|
+
// <div role="toolbar" {...containerProps()}>
|
|
370
|
+
//
|
|
371
|
+
// and a default inside containerProps() wins that object literal by being
|
|
372
|
+
// spread last, so a hard-coded role="listbox" silently relabelled every
|
|
373
|
+
// toolbar, menu, tablist and radiogroup built on the hook. containerProps()
|
|
374
|
+
// cannot see the props written beside it, so the ONLY way for the caller's
|
|
375
|
+
// role to survive is to not emit one. A role can still be asked for
|
|
376
|
+
// explicitly, per hook (useRovingTabIndex(n, { role: 'menu' })) or per call
|
|
377
|
+
// site (containerProps({ role: 'menu' })). Roving tabindex is the shared
|
|
378
|
+
// keyboard mechanic of toolbars, menus, trees, grids, tablists, radiogroups
|
|
379
|
+
// and listboxes; guessing one of those for the caller is wrong more often
|
|
380
|
+
// than it is right.
|
|
381
|
+
const configuredRole = options?.role || null;
|
|
382
|
+
|
|
383
|
+
// --- Item registration ---
|
|
384
|
+
//
|
|
385
|
+
// The hook holds the item nodes DIRECTLY, through a ref object per index that
|
|
386
|
+
// getItemProps() hands back for the caller to install.
|
|
387
|
+
//
|
|
388
|
+
// It must not hold anything else. The previous attempt located items with
|
|
389
|
+
// document.querySelector on a `data-what-roving` group id allocated from the
|
|
390
|
+
// useId counter, and that counter is not stable in the architecture What
|
|
391
|
+
// ships as a headline feature: render.js hydrate() calls __resetIdCounter()
|
|
392
|
+
// on EVERY invocation and islands hydrate one at a time, each through its own
|
|
393
|
+
// hydrate() call. So (1) a single island on a page where anything earlier
|
|
394
|
+
// consumed a useId computes a different id than the server wrote, the
|
|
395
|
+
// selector matches nothing and focus never moves, and (2) two islands both
|
|
396
|
+
// restart the counter at 1, both compute the SAME id, and the second one's
|
|
397
|
+
// selector resolves into the first one's subtree, so ArrowRight moved focus
|
|
398
|
+
// out of the widget the user was in and into an unrelated one. Worse, the
|
|
399
|
+
// static `data-what-roving` attribute is never reconciled during hydration
|
|
400
|
+
// (hydrateElementProps skips static props), so the DOM keeps the server's id
|
|
401
|
+
// and the mismatch is invisible in the markup. An identifier that a later
|
|
402
|
+
// render can renumber cannot be the thing that identifies an element.
|
|
403
|
+
//
|
|
404
|
+
// A ref OBJECT rather than a callback ref, because all three prop paths
|
|
405
|
+
// accept an object and only two accept a function: render.js spread() treats
|
|
406
|
+
// any function-valued prop as a reactive accessor and would CALL a callback
|
|
407
|
+
// ref with no arguments. An object is assigned `.current` by dom.js
|
|
408
|
+
// applyProps (h()), render.js setProp() (compiled spread) and
|
|
409
|
+
// hydrateElementProps() (SSR) alike, and renderToString() skips `ref`
|
|
410
|
+
// entirely, so nothing lands in the HTML and there is no hydration mismatch
|
|
411
|
+
// to have.
|
|
412
|
+
const itemEls = [];
|
|
413
|
+
const itemRefs = [];
|
|
414
|
+
const callerRefs = [];
|
|
415
|
+
|
|
416
|
+
function refFor(index) {
|
|
417
|
+
let ref = itemRefs[index];
|
|
418
|
+
if (!ref) {
|
|
419
|
+
ref = {
|
|
420
|
+
get current() { return itemEls[index] || null; },
|
|
421
|
+
set current(el) {
|
|
422
|
+
itemEls[index] = el || null;
|
|
423
|
+
// Forward to a ref the caller passed through
|
|
424
|
+
// getItemProps(index, { ref }). Ours has to be the one in the props
|
|
425
|
+
// object, so wanting the node back must not cost them focus movement.
|
|
426
|
+
const caller = callerRefs[index];
|
|
427
|
+
if (typeof caller === 'function') caller(el);
|
|
428
|
+
else if (caller && typeof caller === 'object') caller.current = el;
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
itemRefs[index] = ref;
|
|
432
|
+
}
|
|
433
|
+
return ref;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Nothing clears a ref on unmount, so an index can still hold a node from a
|
|
437
|
+
// previous render. focus() on a detached node is a silent no-op that leaves
|
|
438
|
+
// document.activeElement on <body>, so an element that has left the document
|
|
439
|
+
// counts as missing. (`isConnected === false` rather than a truthiness test:
|
|
440
|
+
// a DOM shim without the property should not disable the whole hook.)
|
|
441
|
+
function itemElement(index) {
|
|
442
|
+
const el = itemEls[index];
|
|
443
|
+
if (!el) return null;
|
|
444
|
+
return el.isConnected === false ? null : el;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Move REAL DOM focus onto the active item. This is the whole point of the
|
|
448
|
+
// WAI-ARIA roving tabindex pattern and it was missing: arrow keys only
|
|
449
|
+
// shuffled a tabIndex value between props objects, so the user pressed
|
|
450
|
+
// ArrowDown and focus never left the first item (and a screen reader stayed
|
|
451
|
+
// on it). Holding the node means this is correct inside a shadow root, a
|
|
452
|
+
// portal or a second island for free, none of which a selector was.
|
|
453
|
+
function focusItemElement(index) {
|
|
454
|
+
const el = itemElement(index);
|
|
455
|
+
if (!el || typeof el.focus !== 'function') return null;
|
|
456
|
+
// Re-focusing the already-focused element would fire a redundant focus
|
|
457
|
+
// event, and the handler in getItemProps writes focusIndex on focus.
|
|
458
|
+
if (typeof document === 'undefined' || document.activeElement !== el) el.focus();
|
|
459
|
+
return el;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// True when focus is already inside this group, so a programmatic index
|
|
463
|
+
// change can follow it without yanking focus off an unrelated widget. An
|
|
464
|
+
// item may itself contain the focused node (a link inside a grid cell), so
|
|
465
|
+
// containment counts, not just identity.
|
|
466
|
+
function groupHasFocus() {
|
|
467
|
+
if (typeof document === 'undefined') return false;
|
|
468
|
+
const active = document.activeElement;
|
|
469
|
+
if (!active) return false;
|
|
470
|
+
for (let i = 0; i < itemEls.length; i++) {
|
|
471
|
+
const el = itemEls[i];
|
|
472
|
+
if (!el) continue;
|
|
473
|
+
if (el === active) return true;
|
|
474
|
+
if (typeof el.contains === 'function' && el.contains(active)) return true;
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// WAI-ARIA requires exactly one tabbable item in a roving group at all times.
|
|
480
|
+
// A dynamic list that shrinks below focusIndex (End on four items, then the
|
|
481
|
+
// list filters down to two) would otherwise leave the index pointing past the
|
|
482
|
+
// last item, every item at tabindex="-1", and the whole widget silently out
|
|
483
|
+
// of the tab order. Clamping happens on READ rather than on write because the
|
|
484
|
+
// count can shrink without anyone calling into the hook, and reading
|
|
485
|
+
// getCount() here is what makes a signal-backed count re-run the tabIndex
|
|
486
|
+
// effects. The stored index is left alone so a list that filters and then
|
|
487
|
+
// restores puts the user back where they were.
|
|
488
|
+
function clampIndex(index, count) {
|
|
489
|
+
if (!(count > 0)) return 0;
|
|
490
|
+
if (index < 0) return 0;
|
|
491
|
+
if (index > count - 1) return count - 1;
|
|
492
|
+
return index;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function activeIndex() {
|
|
496
|
+
return clampIndex(focusIndex(), getCount());
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Out-of-range indexes handed in from application code are REFUSED rather
|
|
500
|
+
// than clamped. focusItem(99) used to write 99 into the signal and return
|
|
501
|
+
// null: no item matched, so every item went to tabindex="-1" and the widget
|
|
502
|
+
// dropped out of the tab order entirely. Clamping instead would move focus
|
|
503
|
+
// somewhere the caller never asked for, which is a worse surprise than
|
|
504
|
+
// ignoring an index that does not exist.
|
|
505
|
+
function isValidIndex(i) {
|
|
506
|
+
return Number.isInteger(i) && i >= 0 && i < getCount();
|
|
507
|
+
}
|
|
508
|
+
|
|
335
509
|
function handleKeyDown(e) {
|
|
336
510
|
const count = getCount();
|
|
337
511
|
if (count <= 0) return;
|
|
512
|
+
const current = clampIndex(focusIndex.peek(), count);
|
|
513
|
+
let next = current;
|
|
338
514
|
switch (e.key) {
|
|
339
515
|
case 'ArrowDown':
|
|
340
516
|
case 'ArrowRight':
|
|
341
|
-
|
|
342
|
-
focusIndex.set((focusIndex.peek() + 1) % count);
|
|
517
|
+
next = (current + 1) % count;
|
|
343
518
|
break;
|
|
344
519
|
case 'ArrowUp':
|
|
345
520
|
case 'ArrowLeft':
|
|
346
|
-
|
|
347
|
-
focusIndex.set((focusIndex.peek() - 1 + count) % count);
|
|
521
|
+
next = (current - 1 + count) % count;
|
|
348
522
|
break;
|
|
349
523
|
case 'Home':
|
|
350
|
-
|
|
351
|
-
focusIndex.set(0);
|
|
524
|
+
next = 0;
|
|
352
525
|
break;
|
|
353
526
|
case 'End':
|
|
354
|
-
|
|
355
|
-
focusIndex.set(count - 1);
|
|
527
|
+
next = count - 1;
|
|
356
528
|
break;
|
|
529
|
+
default:
|
|
530
|
+
return;
|
|
357
531
|
}
|
|
532
|
+
e.preventDefault();
|
|
533
|
+
focusIndex.set(next);
|
|
534
|
+
focusItemElement(next);
|
|
358
535
|
}
|
|
359
536
|
|
|
360
537
|
return {
|
|
361
|
-
focusIndex: () =>
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
538
|
+
focusIndex: () => activeIndex(),
|
|
539
|
+
// Follows focus only when the group already owns it, so syncing the index
|
|
540
|
+
// from application state cannot steal focus from elsewhere on the page.
|
|
541
|
+
// Use focusItem() when moving focus is the intent.
|
|
542
|
+
setFocusIndex: (i) => {
|
|
543
|
+
if (!isValidIndex(i)) return;
|
|
544
|
+
focusIndex.set(i);
|
|
545
|
+
if (groupHasFocus()) focusItemElement(i);
|
|
546
|
+
},
|
|
547
|
+
// Explicitly move focus to an item, e.g. a menu focusing its first item on
|
|
548
|
+
// open. Returns the element it focused, or null when the index is out of
|
|
549
|
+
// range or the item is not in the DOM.
|
|
550
|
+
focusItem: (i) => {
|
|
551
|
+
if (!isValidIndex(i)) return null;
|
|
552
|
+
focusIndex.set(i);
|
|
553
|
+
return focusItemElement(i);
|
|
554
|
+
},
|
|
555
|
+
// `overrides` are spread last and win, except for `ref`, which is chained
|
|
556
|
+
// (see refFor) because this hook needs the node to move focus at all.
|
|
557
|
+
getItemProps: (index, overrides) => {
|
|
558
|
+
const { ref: callerRef, ...rest } = overrides || {};
|
|
559
|
+
callerRefs[index] = callerRef || null;
|
|
560
|
+
return {
|
|
561
|
+
ref: refFor(index),
|
|
562
|
+
// Accessor, not a snapshot: exactly one item is tabbable at a time and
|
|
563
|
+
// which one has to change as focus roves. See the ariaBool note above
|
|
564
|
+
// for why a resolved value cannot survive a spread.
|
|
565
|
+
tabIndex: () => (activeIndex() === index ? 0 : -1),
|
|
566
|
+
onKeyDown: handleKeyDown,
|
|
567
|
+
// The ref is the registration; this is a free corrective for it. A call
|
|
568
|
+
// site that spreads its own ref AFTER getItemProps() replaces ours, and
|
|
569
|
+
// an element reporting its own focus event is first-hand evidence of
|
|
570
|
+
// which node index `index` rendered to.
|
|
571
|
+
onFocus: (e) => {
|
|
572
|
+
const el = e && (e.currentTarget || e.target);
|
|
573
|
+
if (el) itemEls[index] = el;
|
|
574
|
+
focusIndex.set(index);
|
|
575
|
+
},
|
|
576
|
+
...rest,
|
|
577
|
+
};
|
|
578
|
+
},
|
|
579
|
+
containerProps: (overrides) => (
|
|
580
|
+
configuredRole ? { role: configuredRole, ...overrides } : { ...overrides }
|
|
581
|
+
),
|
|
371
582
|
};
|
|
372
583
|
}
|
|
373
584
|
|
package/src/agent-context.js
CHANGED
|
@@ -8,7 +8,7 @@ import { getCollectedErrors } from './errors.js';
|
|
|
8
8
|
// --- Version ---
|
|
9
9
|
// Keep in sync with packages/core/package.json (checked by
|
|
10
10
|
// core/test/guardrails.test.js so it can't silently go stale again).
|
|
11
|
-
const VERSION = '0.12.
|
|
11
|
+
const VERSION = '0.12.4';
|
|
12
12
|
|
|
13
13
|
// --- Component Registry ---
|
|
14
14
|
// Tracks mounted components for agent inspection.
|
package/src/animation.js
CHANGED
|
@@ -520,6 +520,14 @@ export function createTransitionClasses(name) {
|
|
|
520
520
|
}
|
|
521
521
|
|
|
522
522
|
// Apply CSS transition
|
|
523
|
+
//
|
|
524
|
+
// The write -> read -> write dance is required by CSS, not by the scheduler:
|
|
525
|
+
// the browser only animates between two style states it has actually computed,
|
|
526
|
+
// so the start class has to be committed and a layout property read (forcing a
|
|
527
|
+
// reflow) before the active class lands. That means asking for a READ from
|
|
528
|
+
// inside a WRITE, whose phase the scheduler has already drained. The scheduler
|
|
529
|
+
// re-arms a frame for work queued mid-flush, so this chain continues on the
|
|
530
|
+
// next frame instead of being dropped (see flushScheduler in scheduler.js).
|
|
523
531
|
export async function cssTransition(element, name, type = 'enter', duration = 300) {
|
|
524
532
|
const classes = createTransitionClasses(name);
|
|
525
533
|
|