@jsenv/dom 0.12.3 → 0.14.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.
Files changed (2) hide show
  1. package/dist/jsenv_dom.js +103 -9
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -4447,6 +4447,10 @@ const normalizeKeyboardKey = (rawKey) => {
4447
4447
 
4448
4448
  const getKeyboardEventDefaultAction = (keyboardEvent) => {
4449
4449
  const target = keyboardEvent.target;
4450
+ if (keyboardEvent.key === undefined) {
4451
+ // Happens for enter after autocomplete
4452
+ return "activate";
4453
+ }
4450
4454
  const key = normalizeKeyboardKey(keyboardEvent.key);
4451
4455
 
4452
4456
  // Nothing special occurs when the target or an ancestor is disabled/inert
@@ -4488,6 +4492,10 @@ const isTypingIntent = (e) => {
4488
4492
  if (e.metaKey || e.ctrlKey) {
4489
4493
  return false;
4490
4494
  }
4495
+ if (!e.key) {
4496
+ // can happen when pressing enter for autocomplete for instance
4497
+ return false;
4498
+ }
4491
4499
  const key = normalizeKeyboardKey(e.key);
4492
4500
  // Single printable character — the user is typing
4493
4501
  if (e.key.length === 1) {
@@ -5451,10 +5459,40 @@ const performTabNavigation = (
5451
5459
  // A focus group "owns" the activeElement when activeElement is inside it.
5452
5460
  // From the inside, Tab should exit the group (skip its remaining children).
5453
5461
  // From the outside, Tab should enter the group normally (first focusable child).
5462
+ //
5463
+ // Smart mode (navi-focus-group="[role=radio]"):
5464
+ // - activeElement directly matches the selector (IS a radio):
5465
+ // Tab skips ALL elements in the group → exits to next focusable outside.
5466
+ // - activeElement is inside a managed element but doesn't match (e.g. an
5467
+ // input inside a custom radio widget): Tab navigates freely within the
5468
+ // group, only skipping elements that directly match the managed selector.
5469
+ //
5470
+ // Strict mode (navi-focus-group with no value, or navi-focus-group-strict):
5471
+ // Tab always exits the group regardless of where focus is inside it.
5454
5472
  const activeFocusGroup =
5455
5473
  activeElement.closest?.("[navi-focus-group]") || null;
5456
- const isOwnedByActiveFocusGroup = (el) =>
5457
- activeFocusGroup && activeFocusGroup.contains(el);
5474
+ const activeFocusGroupManages = activeFocusGroup
5475
+ ? activeFocusGroup.getAttribute("navi-focus-group") || null
5476
+ : null;
5477
+ const activeFocusGroupIsStrict = activeFocusGroup
5478
+ ? !activeFocusGroupManages ||
5479
+ activeFocusGroup.hasAttribute("navi-focus-group-strict")
5480
+ : false;
5481
+ const activeElementIsManaged =
5482
+ activeFocusGroup && activeFocusGroupManages
5483
+ ? activeElement.matches(activeFocusGroupManages)
5484
+ : false;
5485
+ const isOwnedByActiveFocusGroup = (el) => {
5486
+ if (!activeFocusGroup || !activeFocusGroup.contains(el)) {
5487
+ return false;
5488
+ }
5489
+ if (activeFocusGroupIsStrict || activeElementIsManaged) {
5490
+ // Strict: skip everything inside the group so Tab exits.
5491
+ return true;
5492
+ }
5493
+ // Smart: only skip elements that are themselves managed items.
5494
+ return el.matches(activeFocusGroupManages);
5495
+ };
5458
5496
 
5459
5497
  const predicate = (candidate, skip) => {
5460
5498
  if (!isFocusableByTab(candidate)) {
@@ -5589,6 +5627,12 @@ const hasNegativeTabIndex = (element) => {
5589
5627
  * when navigating on the x axis. Omit to allow any focusable element.
5590
5628
  * @param {string} [options.ySelector] - CSS selector that candidates must match
5591
5629
  * when navigating on the y axis. Omit to allow any focusable element.
5630
+ * @param {string} [options.manages] - CSS selector declaring which descendants
5631
+ * this group "manages" for Tab navigation. When set, Tab only skips managed
5632
+ * elements; other focusable descendants (e.g. inputs inside a radio widget)
5633
+ * remain individually tabbable. When omitted, Tab skips the entire group.
5634
+ * @param {boolean} [options.strictTab=false] - When true AND manages is set,
5635
+ * Tab always exits the group regardless of where focus is inside it.
5592
5636
  * @returns {{ cleanup: () => void }} Call cleanup() to remove all event listeners.
5593
5637
  */
5594
5638
  const initFocusGroup = (
@@ -5605,6 +5649,10 @@ const initFocusGroup = (
5605
5649
  // CSS selector to restrict candidates on each axis
5606
5650
  xSelector,
5607
5651
  ySelector,
5652
+ // CSS selector declaring which elements the group "manages" for Tab purposes.
5653
+ // Defaults to ySelector ?? xSelector so arrow-nav and tab-nav stay in sync.
5654
+ manages = ySelector ?? xSelector,
5655
+ strictTab = false,
5608
5656
  } = {},
5609
5657
  ) => {
5610
5658
  const cleanupCallbackSet = new Set();
@@ -5621,10 +5669,16 @@ const initFocusGroup = (
5621
5669
  name, // Store undefined as-is for implicit grouping
5622
5670
  });
5623
5671
  cleanupCallbackSet.add(removeFocusGroup);
5624
- element.setAttribute("navi-focus-group", "");
5672
+ element.setAttribute("navi-focus-group", manages ?? "");
5625
5673
  cleanupCallbackSet.add(() => {
5626
5674
  element.removeAttribute("navi-focus-group");
5627
5675
  });
5676
+ if (manages && strictTab) {
5677
+ element.setAttribute("navi-focus-group-strict", "");
5678
+ cleanupCallbackSet.add(() => {
5679
+ element.removeAttribute("navi-focus-group-strict");
5680
+ });
5681
+ }
5628
5682
 
5629
5683
  tab: {
5630
5684
  if (!skipTab) {
@@ -5635,8 +5689,18 @@ const initFocusGroup = (
5635
5689
  // Prevent double handling of the same event + allow preventing focus nav from outside
5636
5690
  return;
5637
5691
  }
5692
+ // Smart mode: when focus is inside an unmanaged element (e.g. an input
5693
+ // inside a radio widget), do NOT skip the entire group — let Tab navigate
5694
+ // freely. The predicate in performTabNavigation will still skip managed
5695
+ // items (those matching `manages`) encountered along the way.
5696
+ const activeElement = document.activeElement;
5697
+ const focusIsOnUnmanagedDescendant =
5698
+ manages &&
5699
+ !strictTab &&
5700
+ element.contains(activeElement) &&
5701
+ !activeElement.matches(manages);
5638
5702
  performTabNavigation(event, {
5639
- outsideOfElement: element,
5703
+ outsideOfElement: focusIsOnUnmanagedDescendant ? null : element,
5640
5704
  excludeAriaHidden,
5641
5705
  });
5642
5706
  };
@@ -11191,13 +11255,43 @@ const visibleRectEffect = (
11191
11255
  }
11192
11256
  }
11193
11257
  {
11194
- const onWindowResize = (e) => {
11258
+ // visualViewport resize is a superset of window resize:
11259
+ // it also fires when the virtual keyboard opens/closes on mobile.
11260
+ // Fall back to window resize when visualViewport is unavailable.
11261
+ const onResize = (e) => {
11195
11262
  autoCheck(e);
11196
11263
  };
11197
- window.addEventListener("resize", onWindowResize);
11198
- addTeardown(() => {
11199
- window.removeEventListener("resize", onWindowResize);
11200
- });
11264
+ if (window.visualViewport) {
11265
+ window.visualViewport.addEventListener("resize", onResize);
11266
+ addTeardown(() => {
11267
+ window.visualViewport.removeEventListener("resize", onResize);
11268
+ });
11269
+ } else {
11270
+ window.addEventListener("resize", onResize);
11271
+ addTeardown(() => {
11272
+ window.removeEventListener("resize", onResize);
11273
+ });
11274
+ }
11275
+ }
11276
+ {
11277
+ // visualViewport scroll fires when the visual viewport pans independently
11278
+ // of the layout viewport (e.g. during pinch-zoom). This is distinct from
11279
+ // document scroll and must be observed separately.
11280
+ if (window.visualViewport) {
11281
+ const onVisualViewportScroll = (e) => {
11282
+ autoCheck(e);
11283
+ };
11284
+ window.visualViewport.addEventListener(
11285
+ "scroll",
11286
+ onVisualViewportScroll,
11287
+ );
11288
+ addTeardown(() => {
11289
+ window.visualViewport.removeEventListener(
11290
+ "scroll",
11291
+ onVisualViewportScroll,
11292
+ );
11293
+ });
11294
+ }
11201
11295
  }
11202
11296
  {
11203
11297
  const resizeObserver = new ResizeObserver(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.12.3",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {