@jsenv/dom 0.12.0 → 0.12.2

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.
Files changed (2) hide show
  1. package/dist/jsenv_dom.js +60 -10
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -4375,21 +4375,30 @@ const findFocusDelegateTarget = (el) => {
4375
4375
  return null;
4376
4376
  };
4377
4377
 
4378
- const findFocusable = (element) => {
4378
+ const findFocusable = (element, { exclude } = {}) => {
4379
4379
  const associatedElements = getAssociatedElements(element);
4380
4380
  if (associatedElements) {
4381
4381
  for (const associatedElement of associatedElements) {
4382
- const focusable = findFocusable(associatedElement);
4382
+ const focusable = findFocusable(associatedElement, { exclude });
4383
4383
  if (focusable) {
4384
4384
  return focusable;
4385
4385
  }
4386
4386
  }
4387
4387
  return null;
4388
4388
  }
4389
- if (elementIsFocusable(element)) {
4389
+ const isFocusable = (node) => {
4390
+ if (!elementIsFocusable(node)) {
4391
+ return false;
4392
+ }
4393
+ if (exclude && exclude(node)) {
4394
+ return false;
4395
+ }
4396
+ return true;
4397
+ };
4398
+ if (isFocusable(element)) {
4390
4399
  return element;
4391
4400
  }
4392
- const focusableDescendant = findDescendant(element, elementIsFocusable);
4401
+ const focusableDescendant = findDescendant(element, isFocusable);
4393
4402
  if (focusableDescendant) {
4394
4403
  // If the first focusable is an unchecked radio/checkbox, prefer the checked
4395
4404
  // sibling in the same group (mirrors native browser radio focus behavior
@@ -4496,10 +4505,15 @@ const DEFAULT_BEHAVIORS = [
4496
4505
  keys: {
4497
4506
  // Tab moves focus on any element
4498
4507
  tab: "focus_nav",
4499
- // Escape dismisses on any element (dialog, search clear, dropdown close, etc.)
4508
+ },
4509
+ // no fallback: only claims Tab, other keys continue to next entries
4510
+ },
4511
+ {
4512
+ // Escape natively dismisses only <dialog> elements
4513
+ test: (el) => el.tagName === "DIALOG" || Boolean(el.closest("dialog")),
4514
+ keys: {
4500
4515
  escape: "dismiss",
4501
4516
  },
4502
- // no fallback: only claims Tab/Escape, other keys continue to next entries
4503
4517
  },
4504
4518
  {
4505
4519
  test: (el) => el.matches("input[type='radio'], input[type='checkbox']"),
@@ -4518,6 +4532,12 @@ const DEFAULT_BEHAVIORS = [
4518
4532
  "input:not([type]), input[type='text'], input[type='search'], input[type='url'], input[type='email'], input[type='password'], input[type='tel']",
4519
4533
  ),
4520
4534
  keys: {
4535
+ escape: (e) => {
4536
+ if (e.target.type === "search") {
4537
+ return e.target.value ? "clear" : "";
4538
+ }
4539
+ return "";
4540
+ },
4521
4541
  enter: (e) => (e.target.form ? "form_submit" : ""),
4522
4542
  arrowleft: "cursor_move",
4523
4543
  arrowright: "cursor_move",
@@ -5427,10 +5447,36 @@ const performTabNavigation = (
5427
5447
  return elementIsFocusable(element, { excludeAriaHidden });
5428
5448
  };
5429
5449
 
5430
- const predicate = (candidate) => {
5431
- const canBeFocusedByTab = isFocusableByTab(candidate);
5432
- // debug(`Testing`, candidate, `${canBeFocusedByTab ? "✓" : "✗"}`);
5433
- return canBeFocusedByTab;
5450
+ // A focus group "owns" the activeElement when activeElement is inside it.
5451
+ // From the inside, Tab should exit the group (skip its remaining children).
5452
+ // From the outside, Tab should enter the group normally (first focusable child).
5453
+ const activeFocusGroup =
5454
+ activeElement.closest?.("[navi-focus-group]") || null;
5455
+ const isOwnedByActiveFocusGroup = (el) =>
5456
+ activeFocusGroup && activeFocusGroup.contains(el);
5457
+
5458
+ const predicate = (candidate, skip) => {
5459
+ if (!isFocusableByTab(candidate)) {
5460
+ return false;
5461
+ }
5462
+ // Focus group roots are composite widgets.
5463
+ if (candidate.hasAttribute("navi-focus-group")) {
5464
+ if (isFocusableByTab(candidate)) {
5465
+ // Root has tabindex="0": it is the single Tab stop for the group.
5466
+ // Skip its children — arrow keys handle internal navigation.
5467
+ skip?.();
5468
+ return true;
5469
+ }
5470
+ // Root is not focusable by Tab: descend into children to allow Tab entry.
5471
+ return false;
5472
+ }
5473
+ // If candidate is inside the focus group that currently owns focus, skip
5474
+ // it — Tab should exit the group. (Going *into* a different focus group
5475
+ // is allowed: only one focus group at a time has the activeElement.)
5476
+ if (isOwnedByActiveFocusGroup(candidate)) {
5477
+ return false;
5478
+ }
5479
+ return true;
5434
5480
  };
5435
5481
 
5436
5482
  const activeElementIsRoot = activeElement === rootElement;
@@ -5574,6 +5620,10 @@ const initFocusGroup = (
5574
5620
  name, // Store undefined as-is for implicit grouping
5575
5621
  });
5576
5622
  cleanupCallbackSet.add(removeFocusGroup);
5623
+ element.setAttribute("navi-focus-group", "");
5624
+ cleanupCallbackSet.add(() => {
5625
+ element.removeAttribute("navi-focus-group");
5626
+ });
5577
5627
 
5578
5628
  tab: {
5579
5629
  if (!skipTab) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {