@jsenv/dom 0.12.2 → 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 +104 -9
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -2240,6 +2240,7 @@ const cssSizeUnitSet = new Set([
2240
2240
  "in",
2241
2241
  "pt",
2242
2242
  "pc",
2243
+ "cap",
2243
2244
  ]);
2244
2245
  const cssUnitSet = new Set([
2245
2246
  ...cssSizeUnitSet,
@@ -4446,6 +4447,10 @@ const normalizeKeyboardKey = (rawKey) => {
4446
4447
 
4447
4448
  const getKeyboardEventDefaultAction = (keyboardEvent) => {
4448
4449
  const target = keyboardEvent.target;
4450
+ if (keyboardEvent.key === undefined) {
4451
+ // Happens for enter after autocomplete
4452
+ return "activate";
4453
+ }
4449
4454
  const key = normalizeKeyboardKey(keyboardEvent.key);
4450
4455
 
4451
4456
  // Nothing special occurs when the target or an ancestor is disabled/inert
@@ -4487,6 +4492,10 @@ const isTypingIntent = (e) => {
4487
4492
  if (e.metaKey || e.ctrlKey) {
4488
4493
  return false;
4489
4494
  }
4495
+ if (!e.key) {
4496
+ // can happen when pressing enter for autocomplete for instance
4497
+ return false;
4498
+ }
4490
4499
  const key = normalizeKeyboardKey(e.key);
4491
4500
  // Single printable character — the user is typing
4492
4501
  if (e.key.length === 1) {
@@ -5450,10 +5459,40 @@ const performTabNavigation = (
5450
5459
  // A focus group "owns" the activeElement when activeElement is inside it.
5451
5460
  // From the inside, Tab should exit the group (skip its remaining children).
5452
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.
5453
5472
  const activeFocusGroup =
5454
5473
  activeElement.closest?.("[navi-focus-group]") || null;
5455
- const isOwnedByActiveFocusGroup = (el) =>
5456
- 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
+ };
5457
5496
 
5458
5497
  const predicate = (candidate, skip) => {
5459
5498
  if (!isFocusableByTab(candidate)) {
@@ -5588,6 +5627,12 @@ const hasNegativeTabIndex = (element) => {
5588
5627
  * when navigating on the x axis. Omit to allow any focusable element.
5589
5628
  * @param {string} [options.ySelector] - CSS selector that candidates must match
5590
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.
5591
5636
  * @returns {{ cleanup: () => void }} Call cleanup() to remove all event listeners.
5592
5637
  */
5593
5638
  const initFocusGroup = (
@@ -5604,6 +5649,10 @@ const initFocusGroup = (
5604
5649
  // CSS selector to restrict candidates on each axis
5605
5650
  xSelector,
5606
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,
5607
5656
  } = {},
5608
5657
  ) => {
5609
5658
  const cleanupCallbackSet = new Set();
@@ -5620,10 +5669,16 @@ const initFocusGroup = (
5620
5669
  name, // Store undefined as-is for implicit grouping
5621
5670
  });
5622
5671
  cleanupCallbackSet.add(removeFocusGroup);
5623
- element.setAttribute("navi-focus-group", "");
5672
+ element.setAttribute("navi-focus-group", manages ?? "");
5624
5673
  cleanupCallbackSet.add(() => {
5625
5674
  element.removeAttribute("navi-focus-group");
5626
5675
  });
5676
+ if (manages && strictTab) {
5677
+ element.setAttribute("navi-focus-group-strict", "");
5678
+ cleanupCallbackSet.add(() => {
5679
+ element.removeAttribute("navi-focus-group-strict");
5680
+ });
5681
+ }
5627
5682
 
5628
5683
  tab: {
5629
5684
  if (!skipTab) {
@@ -5634,8 +5689,18 @@ const initFocusGroup = (
5634
5689
  // Prevent double handling of the same event + allow preventing focus nav from outside
5635
5690
  return;
5636
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);
5637
5702
  performTabNavigation(event, {
5638
- outsideOfElement: element,
5703
+ outsideOfElement: focusIsOnUnmanagedDescendant ? null : element,
5639
5704
  excludeAriaHidden,
5640
5705
  });
5641
5706
  };
@@ -11190,13 +11255,43 @@ const visibleRectEffect = (
11190
11255
  }
11191
11256
  }
11192
11257
  {
11193
- 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) => {
11194
11262
  autoCheck(e);
11195
11263
  };
11196
- window.addEventListener("resize", onWindowResize);
11197
- addTeardown(() => {
11198
- window.removeEventListener("resize", onWindowResize);
11199
- });
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
+ }
11200
11295
  }
11201
11296
  {
11202
11297
  const resizeObserver = new ResizeObserver(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {