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/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: !expanded(),
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
- e.preventDefault();
342
- focusIndex.set((focusIndex.peek() + 1) % count);
517
+ next = (current + 1) % count;
343
518
  break;
344
519
  case 'ArrowUp':
345
520
  case 'ArrowLeft':
346
- e.preventDefault();
347
- focusIndex.set((focusIndex.peek() - 1 + count) % count);
521
+ next = (current - 1 + count) % count;
348
522
  break;
349
523
  case 'Home':
350
- e.preventDefault();
351
- focusIndex.set(0);
524
+ next = 0;
352
525
  break;
353
526
  case 'End':
354
- e.preventDefault();
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: () => focusIndex(),
362
- setFocusIndex: (i) => focusIndex.set(i),
363
- getItemProps: (index) => ({
364
- tabIndex: focusIndex() === index ? 0 : -1,
365
- onKeyDown: handleKeyDown,
366
- onFocus: () => focusIndex.set(index),
367
- }),
368
- containerProps: () => ({
369
- role: 'listbox',
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
 
@@ -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.2';
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