@jsenv/dom 0.16.0 → 0.17.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 +126 -41
- package/package.json +3 -3
package/dist/jsenv_dom.js
CHANGED
|
@@ -4592,13 +4592,6 @@ const DEFAULT_BEHAVIORS = [
|
|
|
4592
4592
|
},
|
|
4593
4593
|
// no fallback: only claims Tab, other keys continue to next entries
|
|
4594
4594
|
},
|
|
4595
|
-
{
|
|
4596
|
-
// Escape natively dismisses only <dialog> elements
|
|
4597
|
-
test: (el) => el.tagName === "DIALOG" || Boolean(el.closest("dialog")),
|
|
4598
|
-
keys: {
|
|
4599
|
-
escape: "dismiss",
|
|
4600
|
-
},
|
|
4601
|
-
},
|
|
4602
4595
|
{
|
|
4603
4596
|
test: (el) => el.matches("input[type='radio'], input[type='checkbox']"),
|
|
4604
4597
|
keys: {
|
|
@@ -4748,6 +4741,18 @@ const DEFAULT_BEHAVIORS = [
|
|
|
4748
4741
|
enter: "activate",
|
|
4749
4742
|
},
|
|
4750
4743
|
},
|
|
4744
|
+
{
|
|
4745
|
+
// Escape natively dismisses only <dialog> elements. Deliberately late in
|
|
4746
|
+
// the list: the focused element gets first claim on Escape, because the
|
|
4747
|
+
// browser resolves the close request innermost-first. A non-empty
|
|
4748
|
+
// <input type="search"> inside a dialog consumes the first Escape to clear
|
|
4749
|
+
// itself and only a second one reaches the dialog — reporting "dismiss"
|
|
4750
|
+
// here would let our own Escape-to-close shortcuts fire on the first press.
|
|
4751
|
+
test: (el) => el.tagName === "DIALOG" || Boolean(el.closest("dialog")),
|
|
4752
|
+
keys: {
|
|
4753
|
+
escape: "dismiss",
|
|
4754
|
+
},
|
|
4755
|
+
},
|
|
4751
4756
|
{
|
|
4752
4757
|
// Non-interactive elements: browser scrolls on Space and arrow keys
|
|
4753
4758
|
test: () => true,
|
|
@@ -5550,7 +5555,22 @@ const performTabNavigation = (
|
|
|
5550
5555
|
if (hasNegativeTabIndex(element)) {
|
|
5551
5556
|
return false;
|
|
5552
5557
|
}
|
|
5553
|
-
|
|
5558
|
+
if (!elementIsFocusable(element, { excludeAriaHidden })) {
|
|
5559
|
+
return false;
|
|
5560
|
+
}
|
|
5561
|
+
// Native radio-group semantics: within a named radio group only ONE radio
|
|
5562
|
+
// is a Tab stop — the checked one, or the first focusable one when none is
|
|
5563
|
+
// checked. The rest are reachable with arrow keys, not Tab. Without this,
|
|
5564
|
+
// tabbing into a group would land on its first radio instead of its checked
|
|
5565
|
+
// value (e.g. tabbing between two wheels of a WheelGroup).
|
|
5566
|
+
if (
|
|
5567
|
+
element.matches?.('input[type="radio"]') &&
|
|
5568
|
+
element.name &&
|
|
5569
|
+
!radioIsGroupTabStop(element)
|
|
5570
|
+
) {
|
|
5571
|
+
return false;
|
|
5572
|
+
}
|
|
5573
|
+
return true;
|
|
5554
5574
|
};
|
|
5555
5575
|
|
|
5556
5576
|
// A focus group "owns" the activeElement when activeElement is inside it.
|
|
@@ -5701,6 +5721,43 @@ const performTabNavigation = (
|
|
|
5701
5721
|
}
|
|
5702
5722
|
};
|
|
5703
5723
|
|
|
5724
|
+
// Whether a radio is the single Tab stop of its native radio group (checked, or
|
|
5725
|
+
// the first enabled radio when none is checked). Mirrors how the browser puts
|
|
5726
|
+
// only one radio of a group in the Tab order.
|
|
5727
|
+
const radioIsGroupTabStop = (radio) => {
|
|
5728
|
+
const scope = radio.form || radio.getRootNode();
|
|
5729
|
+
if (!scope || !scope.querySelectorAll) {
|
|
5730
|
+
return true;
|
|
5731
|
+
}
|
|
5732
|
+
const sameName = scope.querySelectorAll(
|
|
5733
|
+
`input[type="radio"][name="${CSS.escape(radio.name)}"]`,
|
|
5734
|
+
);
|
|
5735
|
+
const radioForm = radio.form || null;
|
|
5736
|
+
let checked = null;
|
|
5737
|
+
let firstEnabled = null;
|
|
5738
|
+
let groupSize = 0;
|
|
5739
|
+
for (const candidate of sameName) {
|
|
5740
|
+
// Radios only form one group when they share the same form owner.
|
|
5741
|
+
if ((candidate.form || null) !== radioForm) {
|
|
5742
|
+
continue;
|
|
5743
|
+
}
|
|
5744
|
+
groupSize++;
|
|
5745
|
+
if (candidate.disabled) {
|
|
5746
|
+
continue;
|
|
5747
|
+
}
|
|
5748
|
+
if (!firstEnabled) {
|
|
5749
|
+
firstEnabled = candidate;
|
|
5750
|
+
}
|
|
5751
|
+
if (candidate.checked && !checked) {
|
|
5752
|
+
checked = candidate;
|
|
5753
|
+
}
|
|
5754
|
+
}
|
|
5755
|
+
if (groupSize <= 1) {
|
|
5756
|
+
return true;
|
|
5757
|
+
}
|
|
5758
|
+
return radio === (checked || firstEnabled);
|
|
5759
|
+
};
|
|
5760
|
+
|
|
5704
5761
|
const isTabEvent$1 = (event) => event.key === "Tab" || event.keyCode === 9;
|
|
5705
5762
|
|
|
5706
5763
|
const hasNegativeTabIndex = (element) => {
|
|
@@ -7176,49 +7233,61 @@ const getPaddingSizes = (element) => {
|
|
|
7176
7233
|
*/
|
|
7177
7234
|
const trapScrollInside = (element) => {
|
|
7178
7235
|
const cleanupCallbackSet = new Set();
|
|
7179
|
-
|
|
7236
|
+
|
|
7237
|
+
// Collect every element to lock first (preceding scrollable siblings + all
|
|
7238
|
+
// ancestor scroll containers).
|
|
7239
|
+
const elementsToLock = [];
|
|
7240
|
+
let previous = element.previousSibling;
|
|
7241
|
+
while (previous) {
|
|
7242
|
+
if (previous.nodeType === 1 && isScrollable(previous)) {
|
|
7243
|
+
elementsToLock.push(previous);
|
|
7244
|
+
}
|
|
7245
|
+
previous = previous.previousSibling;
|
|
7246
|
+
}
|
|
7247
|
+
for (const selfOrAncestorScroll of getSelfAndAncestorScrolls(element)) {
|
|
7248
|
+
elementsToLock.push(selfOrAncestorScroll.scrollContainer);
|
|
7249
|
+
}
|
|
7250
|
+
|
|
7251
|
+
// Phase 1 — MEASURE. Batch every layout/style read (scrollTop, scrollbar
|
|
7252
|
+
// size, padding) before any style write, so the layout that showModal
|
|
7253
|
+
// invalidated is recomputed once rather than thrashing between each write and
|
|
7254
|
+
// the next read. (measureScrollbar still forces its own reflow per element via
|
|
7255
|
+
// its probe node — that one is inherent.)
|
|
7256
|
+
const plans = elementsToLock.map((el) => {
|
|
7180
7257
|
const savedScrollTop = el.scrollTop;
|
|
7181
7258
|
const savedScrollLeft = el.scrollLeft;
|
|
7182
7259
|
const scrollbarGutter = getStyle(el, "scrollbar-gutter");
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
el.scrollLeft = savedScrollLeft;
|
|
7192
|
-
});
|
|
7193
|
-
return;
|
|
7260
|
+
if (scrollbarGutter && scrollbarGutter !== "auto") {
|
|
7261
|
+
// The element manages its own gutter — just hide overflow, no padding.
|
|
7262
|
+
return {
|
|
7263
|
+
el,
|
|
7264
|
+
savedScrollTop,
|
|
7265
|
+
savedScrollLeft,
|
|
7266
|
+
styles: { overflow: "hidden" },
|
|
7267
|
+
};
|
|
7194
7268
|
}
|
|
7195
7269
|
const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
|
|
7196
7270
|
const { right, bottom } = getPaddingSizes(el);
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7271
|
+
return {
|
|
7272
|
+
el,
|
|
7273
|
+
savedScrollTop,
|
|
7274
|
+
savedScrollLeft,
|
|
7275
|
+
styles: {
|
|
7276
|
+
"padding-right": `${right + scrollbarWidth}px`,
|
|
7277
|
+
"padding-bottom": `${bottom + scrollbarHeight}px`,
|
|
7278
|
+
"overflow": "hidden",
|
|
7279
|
+
},
|
|
7280
|
+
};
|
|
7281
|
+
});
|
|
7282
|
+
|
|
7283
|
+
// Phase 2 — MUTATE. All style writes together.
|
|
7284
|
+
for (const { el, savedScrollTop, savedScrollLeft, styles } of plans) {
|
|
7285
|
+
const removeScrollLockStyles = setStyles(el, styles);
|
|
7202
7286
|
cleanupCallbackSet.add(() => {
|
|
7203
7287
|
removeScrollLockStyles();
|
|
7204
7288
|
el.scrollTop = savedScrollTop;
|
|
7205
7289
|
el.scrollLeft = savedScrollLeft;
|
|
7206
7290
|
});
|
|
7207
|
-
};
|
|
7208
|
-
let previous = element.previousSibling;
|
|
7209
|
-
while (previous) {
|
|
7210
|
-
if (previous.nodeType === 1) {
|
|
7211
|
-
if (isScrollable(previous)) {
|
|
7212
|
-
lockScroll(previous);
|
|
7213
|
-
}
|
|
7214
|
-
}
|
|
7215
|
-
previous = previous.previousSibling;
|
|
7216
|
-
}
|
|
7217
|
-
|
|
7218
|
-
const selfAndAncestorScrolls = getSelfAndAncestorScrolls(element);
|
|
7219
|
-
for (const selfOrAncestorScroll of selfAndAncestorScrolls) {
|
|
7220
|
-
const elementToScrollLock = selfOrAncestorScroll.scrollContainer;
|
|
7221
|
-
lockScroll(elementToScrollLock);
|
|
7222
7291
|
}
|
|
7223
7292
|
|
|
7224
7293
|
return () => {
|
|
@@ -11504,6 +11573,10 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
|
|
|
11504
11573
|
*
|
|
11505
11574
|
* A bit like https://tetherjs.dev/ but different
|
|
11506
11575
|
*/
|
|
11576
|
+
// The event type observeSize() reports with — recognized by check() as "the
|
|
11577
|
+
// change is in another element, not in the tracked rect".
|
|
11578
|
+
const OBSERVED_ELEMENT_SIZE_CHANGE = "observed_element_size_change";
|
|
11579
|
+
|
|
11507
11580
|
const visibleRectEffect = (
|
|
11508
11581
|
element,
|
|
11509
11582
|
update,
|
|
@@ -11746,6 +11819,18 @@ const visibleRectEffect = (
|
|
|
11746
11819
|
});
|
|
11747
11820
|
};
|
|
11748
11821
|
|
|
11822
|
+
// An observeSize() delivery reports a size change in some *other*
|
|
11823
|
+
// element — this one's own rect and the viewport are both typically
|
|
11824
|
+
// untouched by it, so the dedup below would skip every single one,
|
|
11825
|
+
// defeating the whole point of observeSize (a popover reconsidering its
|
|
11826
|
+
// placement once its own content shrinks/grows, a callout re-measuring
|
|
11827
|
+
// against its message body).
|
|
11828
|
+
if (event.type === OBSERVED_ELEMENT_SIZE_CHANGE) {
|
|
11829
|
+
lastVisibleRect = visibleRect;
|
|
11830
|
+
lastViewportRect = viewportRect;
|
|
11831
|
+
notify();
|
|
11832
|
+
return;
|
|
11833
|
+
}
|
|
11749
11834
|
const visibleRectChanged =
|
|
11750
11835
|
!lastVisibleRect ||
|
|
11751
11836
|
lastVisibleRect.left !== visibleRect.left ||
|
|
@@ -12150,7 +12235,7 @@ const visibleRectEffect = (
|
|
|
12150
12235
|
pendingFrame = requestAnimationFrame(() => {
|
|
12151
12236
|
pendingFrame = null;
|
|
12152
12237
|
check(
|
|
12153
|
-
new CustomEvent(
|
|
12238
|
+
new CustomEvent(OBSERVED_ELEMENT_SIZE_CHANGE, {
|
|
12154
12239
|
detail: { width, height },
|
|
12155
12240
|
}),
|
|
12156
12241
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DOM utilities for writing frontend code",
|
|
6
6
|
"repository": {
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"@jsenv/core": "../../../",
|
|
41
41
|
"@jsenv/navi": "../navi",
|
|
42
42
|
"@jsenv/snapshot": "../../tooling/snapshot",
|
|
43
|
-
"@preact/signals": "2.9.
|
|
44
|
-
"preact": "11.0.0-beta.
|
|
43
|
+
"@preact/signals": "2.9.4",
|
|
44
|
+
"preact": "11.0.0-beta.2"
|
|
45
45
|
},
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"access": "public"
|