@jsenv/dom 0.12.3 → 0.14.1
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/jsenv_dom.js +138 -11
- 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
|
|
5457
|
-
|
|
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
|
};
|
|
@@ -10991,12 +11055,17 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
|
|
|
10991
11055
|
const visibleRectEffect = (
|
|
10992
11056
|
element,
|
|
10993
11057
|
update,
|
|
10994
|
-
{
|
|
11058
|
+
{
|
|
11059
|
+
event: initialEvent = new CustomEvent("initialization"),
|
|
11060
|
+
skipElementResize,
|
|
11061
|
+
} = {},
|
|
10995
11062
|
) => {
|
|
10996
11063
|
const [teardown, addTeardown] = createPubSub();
|
|
10997
11064
|
const scrollContainer = getScrollContainer(element);
|
|
10998
11065
|
const scrollContainerIsDocument =
|
|
10999
11066
|
scrollContainer === document.documentElement;
|
|
11067
|
+
let lastMeasuredWidth;
|
|
11068
|
+
let lastMeasuredHeight;
|
|
11000
11069
|
let ancestorClosedCount = 0;
|
|
11001
11070
|
const check = (event) => {
|
|
11002
11071
|
|
|
@@ -11043,6 +11112,8 @@ const visibleRectEffect = (
|
|
|
11043
11112
|
|
|
11044
11113
|
// 2. Calculate element visible width/height
|
|
11045
11114
|
const { width, height } = element.getBoundingClientRect();
|
|
11115
|
+
lastMeasuredWidth = width;
|
|
11116
|
+
lastMeasuredHeight = height;
|
|
11046
11117
|
const visibleAreaWidth = scrollContainer.clientWidth;
|
|
11047
11118
|
const visibleAreaHeight = scrollContainer.clientHeight;
|
|
11048
11119
|
const visibleAreaRight = visibleAreaLeft + visibleAreaWidth;
|
|
@@ -11191,19 +11262,75 @@ const visibleRectEffect = (
|
|
|
11191
11262
|
}
|
|
11192
11263
|
}
|
|
11193
11264
|
{
|
|
11194
|
-
|
|
11265
|
+
// visualViewport resize is a superset of window resize:
|
|
11266
|
+
// it also fires when the virtual keyboard opens/closes on mobile.
|
|
11267
|
+
// Fall back to window resize when visualViewport is unavailable.
|
|
11268
|
+
const onResize = (e) => {
|
|
11195
11269
|
autoCheck(e);
|
|
11196
11270
|
};
|
|
11197
|
-
window.
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
|
|
11271
|
+
if (window.visualViewport) {
|
|
11272
|
+
window.visualViewport.addEventListener("resize", onResize);
|
|
11273
|
+
addTeardown(() => {
|
|
11274
|
+
window.visualViewport.removeEventListener("resize", onResize);
|
|
11275
|
+
});
|
|
11276
|
+
} else {
|
|
11277
|
+
window.addEventListener("resize", onResize);
|
|
11278
|
+
addTeardown(() => {
|
|
11279
|
+
window.removeEventListener("resize", onResize);
|
|
11280
|
+
});
|
|
11281
|
+
}
|
|
11201
11282
|
}
|
|
11202
11283
|
{
|
|
11284
|
+
// visualViewport scroll fires when the visual viewport pans independently
|
|
11285
|
+
// of the layout viewport (e.g. during pinch-zoom). This is distinct from
|
|
11286
|
+
// document scroll and must be observed separately.
|
|
11287
|
+
if (window.visualViewport) {
|
|
11288
|
+
const onVisualViewportScroll = (e) => {
|
|
11289
|
+
autoCheck(e);
|
|
11290
|
+
};
|
|
11291
|
+
window.visualViewport.addEventListener(
|
|
11292
|
+
"scroll",
|
|
11293
|
+
onVisualViewportScroll,
|
|
11294
|
+
);
|
|
11295
|
+
addTeardown(() => {
|
|
11296
|
+
window.visualViewport.removeEventListener(
|
|
11297
|
+
"scroll",
|
|
11298
|
+
onVisualViewportScroll,
|
|
11299
|
+
);
|
|
11300
|
+
});
|
|
11301
|
+
}
|
|
11302
|
+
}
|
|
11303
|
+
on_element_resize: {
|
|
11304
|
+
if (skipElementResize) {
|
|
11305
|
+
break on_element_resize;
|
|
11306
|
+
}
|
|
11307
|
+
|
|
11308
|
+
let isFirst = true;
|
|
11309
|
+
let handlingResize = false;
|
|
11203
11310
|
const resizeObserver = new ResizeObserver(() => {
|
|
11204
|
-
{
|
|
11311
|
+
if (isFirst) {
|
|
11312
|
+
isFirst = false;
|
|
11205
11313
|
return;
|
|
11206
11314
|
}
|
|
11315
|
+
if (handlingResize) {
|
|
11316
|
+
return;
|
|
11317
|
+
}
|
|
11318
|
+
// we use directly the result of getBoundingClientRect() instead of the resizeEntry.contentRect or resizeEntry.borderBoxSize
|
|
11319
|
+
// so that:
|
|
11320
|
+
// - We can compare the dimensions measure in the last check and the current one
|
|
11321
|
+
// - We don't have to check element boz-sizing to know what to compare
|
|
11322
|
+
// - resizeEntry.borderBoxSize browser support is not that great
|
|
11323
|
+
const { width, height } = element.getBoundingClientRect();
|
|
11324
|
+
const widthDiff = Math.abs(width - lastMeasuredWidth);
|
|
11325
|
+
const heightDiff = Math.abs(height - lastMeasuredHeight);
|
|
11326
|
+
if (widthDiff === 0 && heightDiff === 0) {
|
|
11327
|
+
return;
|
|
11328
|
+
}
|
|
11329
|
+
handlingResize = true;
|
|
11330
|
+
autoCheck(
|
|
11331
|
+
new CustomEvent("element_size_change", { detail: { width, height } }),
|
|
11332
|
+
);
|
|
11333
|
+
handlingResize = false;
|
|
11207
11334
|
});
|
|
11208
11335
|
resizeObserver.observe(element);
|
|
11209
11336
|
// Temporarily disconnect ResizeObserver to prevent feedback loops eventually caused by update function
|