@jsenv/dom 0.16.0 → 0.17.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 +97 -33
  2. package/package.json +3 -3
package/dist/jsenv_dom.js CHANGED
@@ -5550,7 +5550,22 @@ const performTabNavigation = (
5550
5550
  if (hasNegativeTabIndex(element)) {
5551
5551
  return false;
5552
5552
  }
5553
- return elementIsFocusable(element, { excludeAriaHidden });
5553
+ if (!elementIsFocusable(element, { excludeAriaHidden })) {
5554
+ return false;
5555
+ }
5556
+ // Native radio-group semantics: within a named radio group only ONE radio
5557
+ // is a Tab stop — the checked one, or the first focusable one when none is
5558
+ // checked. The rest are reachable with arrow keys, not Tab. Without this,
5559
+ // tabbing into a group would land on its first radio instead of its checked
5560
+ // value (e.g. tabbing between two wheels of a WheelGroup).
5561
+ if (
5562
+ element.matches?.('input[type="radio"]') &&
5563
+ element.name &&
5564
+ !radioIsGroupTabStop(element)
5565
+ ) {
5566
+ return false;
5567
+ }
5568
+ return true;
5554
5569
  };
5555
5570
 
5556
5571
  // A focus group "owns" the activeElement when activeElement is inside it.
@@ -5701,6 +5716,43 @@ const performTabNavigation = (
5701
5716
  }
5702
5717
  };
5703
5718
 
5719
+ // Whether a radio is the single Tab stop of its native radio group (checked, or
5720
+ // the first enabled radio when none is checked). Mirrors how the browser puts
5721
+ // only one radio of a group in the Tab order.
5722
+ const radioIsGroupTabStop = (radio) => {
5723
+ const scope = radio.form || radio.getRootNode();
5724
+ if (!scope || !scope.querySelectorAll) {
5725
+ return true;
5726
+ }
5727
+ const sameName = scope.querySelectorAll(
5728
+ `input[type="radio"][name="${CSS.escape(radio.name)}"]`,
5729
+ );
5730
+ const radioForm = radio.form || null;
5731
+ let checked = null;
5732
+ let firstEnabled = null;
5733
+ let groupSize = 0;
5734
+ for (const candidate of sameName) {
5735
+ // Radios only form one group when they share the same form owner.
5736
+ if ((candidate.form || null) !== radioForm) {
5737
+ continue;
5738
+ }
5739
+ groupSize++;
5740
+ if (candidate.disabled) {
5741
+ continue;
5742
+ }
5743
+ if (!firstEnabled) {
5744
+ firstEnabled = candidate;
5745
+ }
5746
+ if (candidate.checked && !checked) {
5747
+ checked = candidate;
5748
+ }
5749
+ }
5750
+ if (groupSize <= 1) {
5751
+ return true;
5752
+ }
5753
+ return radio === (checked || firstEnabled);
5754
+ };
5755
+
5704
5756
  const isTabEvent$1 = (event) => event.key === "Tab" || event.keyCode === 9;
5705
5757
 
5706
5758
  const hasNegativeTabIndex = (element) => {
@@ -7176,49 +7228,61 @@ const getPaddingSizes = (element) => {
7176
7228
  */
7177
7229
  const trapScrollInside = (element) => {
7178
7230
  const cleanupCallbackSet = new Set();
7179
- const lockScroll = (el) => {
7231
+
7232
+ // Collect every element to lock first (preceding scrollable siblings + all
7233
+ // ancestor scroll containers).
7234
+ const elementsToLock = [];
7235
+ let previous = element.previousSibling;
7236
+ while (previous) {
7237
+ if (previous.nodeType === 1 && isScrollable(previous)) {
7238
+ elementsToLock.push(previous);
7239
+ }
7240
+ previous = previous.previousSibling;
7241
+ }
7242
+ for (const selfOrAncestorScroll of getSelfAndAncestorScrolls(element)) {
7243
+ elementsToLock.push(selfOrAncestorScroll.scrollContainer);
7244
+ }
7245
+
7246
+ // Phase 1 — MEASURE. Batch every layout/style read (scrollTop, scrollbar
7247
+ // size, padding) before any style write, so the layout that showModal
7248
+ // invalidated is recomputed once rather than thrashing between each write and
7249
+ // the next read. (measureScrollbar still forces its own reflow per element via
7250
+ // its probe node — that one is inherent.)
7251
+ const plans = elementsToLock.map((el) => {
7180
7252
  const savedScrollTop = el.scrollTop;
7181
7253
  const savedScrollLeft = el.scrollLeft;
7182
7254
  const scrollbarGutter = getStyle(el, "scrollbar-gutter");
7183
- const hasScrollbarGutterStrategy =
7184
- scrollbarGutter && scrollbarGutter !== "auto";
7185
- if (hasScrollbarGutterStrategy) {
7186
- // The element manages its own gutter — just hide overflow, no padding needed.
7187
- const removeScrollLockStyles = setStyles(el, { overflow: "hidden" });
7188
- cleanupCallbackSet.add(() => {
7189
- removeScrollLockStyles();
7190
- el.scrollTop = savedScrollTop;
7191
- el.scrollLeft = savedScrollLeft;
7192
- });
7193
- return;
7255
+ if (scrollbarGutter && scrollbarGutter !== "auto") {
7256
+ // The element manages its own gutter — just hide overflow, no padding.
7257
+ return {
7258
+ el,
7259
+ savedScrollTop,
7260
+ savedScrollLeft,
7261
+ styles: { overflow: "hidden" },
7262
+ };
7194
7263
  }
7195
7264
  const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
7196
7265
  const { right, bottom } = getPaddingSizes(el);
7197
- const removeScrollLockStyles = setStyles(el, {
7198
- "padding-right": `${right + scrollbarWidth}px`,
7199
- "padding-bottom": `${bottom + scrollbarHeight}px`,
7200
- "overflow": "hidden",
7201
- });
7266
+ return {
7267
+ el,
7268
+ savedScrollTop,
7269
+ savedScrollLeft,
7270
+ styles: {
7271
+ "padding-right": `${right + scrollbarWidth}px`,
7272
+ "padding-bottom": `${bottom + scrollbarHeight}px`,
7273
+ "overflow": "hidden",
7274
+ },
7275
+ };
7276
+ });
7277
+
7278
+ // Phase 2 — MUTATE. All style writes together.
7279
+ for (const { el, savedScrollTop, savedScrollLeft, styles } of plans) {
7280
+ const removeScrollLockStyles = setStyles(el, styles);
7202
7281
  cleanupCallbackSet.add(() => {
7203
7282
  removeScrollLockStyles();
7204
7283
  el.scrollTop = savedScrollTop;
7205
7284
  el.scrollLeft = savedScrollLeft;
7206
7285
  });
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
7286
  }
7223
7287
 
7224
7288
  return () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
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.3",
44
- "preact": "11.0.0-beta.1"
43
+ "@preact/signals": "2.9.4",
44
+ "preact": "11.0.0-beta.2"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"