@jsenv/dom 0.17.1 → 0.17.3

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 +627 -277
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -146,6 +146,14 @@ const getElementSignature = (element) => {
146
146
  return `${tagName}#${elementId}`;
147
147
  }
148
148
  if (tagName === "button") {
149
+ // The label BEFORE the text: an icon button has no text worth reading
150
+ // (an svg, a zero-width space), and its aria-label is the one thing that
151
+ // says which button it is — which is the whole point of a signature in a
152
+ // log. A labelled button says so even when it also has text.
153
+ const label = element.getAttribute("aria-label");
154
+ if (label) {
155
+ return `button[aria-label="${label}"]`;
156
+ }
149
157
  const text = element.textContent.trim();
150
158
  if (text) {
151
159
  const excerpt = text.length > 10 ? `${text.slice(0, 10)}…` : text;
@@ -2043,16 +2051,12 @@ const parseCSSTransform = (transformString, normalize) => {
2043
2051
 
2044
2052
  const transformObj = {};
2045
2053
 
2046
- // Parse transform functions
2047
- const transformPattern = /(\w+)\(([^)]+)\)/g;
2048
- let match;
2049
-
2050
- while ((match = transformPattern.exec(transformString)) !== null) {
2051
- const [, functionName, value] = match;
2052
-
2054
+ for (const { functionName, value, source } of readTransformFunctions(
2055
+ transformString,
2056
+ )) {
2053
2057
  // Handle matrix functions specially
2054
2058
  if (functionName === "matrix" || functionName === "matrix3d") {
2055
- const matrixComponents = parseMatrixTransform(match[0]);
2059
+ const matrixComponents = parseMatrixTransform(source);
2056
2060
  if (matrixComponents) {
2057
2061
  // Only add non-default values to preserve original information
2058
2062
  Object.assign(transformObj, matrixComponents);
@@ -2071,6 +2075,42 @@ const parseCSSTransform = (transformString, normalize) => {
2071
2075
  // Return undefined if no properties were extracted (preserves original information)
2072
2076
  return Object.keys(transformObj).length > 0 ? transformObj : undefined;
2073
2077
  };
2078
+
2079
+ // Cuts "translateX(10px) translateY(env(safe-area-inset-top))" into its
2080
+ // functions. Parentheses are counted rather than matched with a regex: a
2081
+ // transform value may hold calc(), env(), min()… and stopping at the first ")"
2082
+ // truncates them.
2083
+ const TRANSFORM_FUNCTION_START_REGEX = /(\w+)\(/g;
2084
+ const readTransformFunctions = (transformString) => {
2085
+ const transformFunctions = [];
2086
+ TRANSFORM_FUNCTION_START_REGEX.lastIndex = 0;
2087
+ let match;
2088
+ while ((match = TRANSFORM_FUNCTION_START_REGEX.exec(transformString))) {
2089
+ const valueStart = match.index + match[0].length;
2090
+ let depth = 1;
2091
+ let index = valueStart;
2092
+ while (index < transformString.length && depth > 0) {
2093
+ const char = transformString[index];
2094
+ if (char === "(") {
2095
+ depth++;
2096
+ } else if (char === ")") {
2097
+ depth--;
2098
+ }
2099
+ index++;
2100
+ }
2101
+ if (depth > 0) {
2102
+ // Unbalanced: nothing reliable left to read after this point.
2103
+ break;
2104
+ }
2105
+ transformFunctions.push({
2106
+ functionName: match[1],
2107
+ value: transformString.slice(valueStart, index - 1),
2108
+ source: transformString.slice(match.index, index),
2109
+ });
2110
+ TRANSFORM_FUNCTION_START_REGEX.lastIndex = index;
2111
+ }
2112
+ return transformFunctions;
2113
+ };
2074
2114
  // Parse a matrix transform and extract simple transform components when possible
2075
2115
  const parseMatrixTransform = (matrixString) => {
2076
2116
  // Match matrix() or matrix3d() functions
@@ -2316,33 +2356,6 @@ const globalCSSKeywordSet = new Set([
2316
2356
  "unset",
2317
2357
  "revert",
2318
2358
  ]);
2319
- // Keywords that should NOT get automatic units when used with properties from:
2320
- // - pxPropertySet (width, height, fontSize, etc.)
2321
- // - degPropertySet (rotate, skew, etc.)
2322
- // - unitlessPropertySet (opacity, zIndex, etc.)
2323
- // This prevents auto-unit addition: e.g., width: "auto" stays "auto", not "autopx"
2324
- const unitlessKeywordSet = new Set([
2325
- ...globalCSSKeywordSet,
2326
- // Size/dimension keywords for pxPropertySet properties
2327
- "fit-content",
2328
- "min-content",
2329
- "max-content",
2330
- // Font size keywords for fontSize
2331
- "medium",
2332
- "small",
2333
- "large",
2334
- "x-small",
2335
- "x-large",
2336
- "xx-small",
2337
- "xx-large",
2338
- "smaller",
2339
- "larger",
2340
- // Border width keywords for borderWidth properties
2341
- "thin",
2342
- "thick",
2343
- // Line height keyword (though lineHeight is handled specially)
2344
- "normal",
2345
- ]);
2346
2359
  // Keywords for backgroundImage property that should NOT be wrapped in url()
2347
2360
  // Used to prevent: background: "none" becoming background: "url(none)"
2348
2361
  const backgroundKeywordSet = new Set([
@@ -2379,10 +2392,28 @@ const getUnit = (value) => {
2379
2392
  }
2380
2393
  return "";
2381
2394
  };
2382
- // Check if value already has a unit
2383
- const isUnitless = (value) => getUnit(value) === "";
2384
2395
  const hasCSSSizeUnit = (value) => cssSizeUnitSet.has(getUnit(value));
2385
2396
 
2397
+ // A single number and nothing else — the only shape a unit may be appended to.
2398
+ // Everything else already says what it is: a unit ("2em"), a keyword ("auto"),
2399
+ // a CSS expression ("env(safe-area-inset-top)", "calc(…)") or a list of those
2400
+ // ("0 auto", "10px env(safe-area-inset-right)"). Asking "is this one number"
2401
+ // covers them all at once; listing what must be left alone (keywords, then
2402
+ // functions, then lists…) only ever covers what someone thought of.
2403
+ const isBareNumber = (value) => {
2404
+ if (value === "") {
2405
+ return false;
2406
+ }
2407
+ return !isNaN(Number(value));
2408
+ };
2409
+ // The same number, carrying exactly the unit asked for, and nothing else.
2410
+ const isBareNumberWithUnit = (value, unit) => {
2411
+ if (!value.endsWith(unit)) {
2412
+ return false;
2413
+ }
2414
+ return isBareNumber(value.slice(0, -unit.length));
2415
+ };
2416
+
2386
2417
  // url(
2387
2418
  // linear-gradient(
2388
2419
  // radial-gradient(
@@ -2642,23 +2673,21 @@ const isCSSKeyword = (value) => {
2642
2673
  };
2643
2674
  const normalizeNumber = (value, { unit, propertyName, preferedType }) => {
2644
2675
  if (typeof value === "string") {
2645
- // CSS variables and CSS functions like calc() must be passed through as-is
2646
- if (isCSSFunction(value)) {
2647
- return value;
2648
- }
2649
- // Keep strings as-is (including %, em, rem, auto, none, etc.)
2676
+ value = value.trim();
2650
2677
  if (preferedType === "string") {
2651
- if (unit && isUnitless(value) && !unitlessKeywordSet.has(value)) {
2678
+ // Everything that is not a lone number already carries its own meaning
2679
+ // and goes to the DOM untouched: "2em", "auto", "env(safe-area-inset-top)",
2680
+ // "calc(…)", "0 auto", "10px env(safe-area-inset-right)".
2681
+ if (unit && isBareNumber(value)) {
2652
2682
  return `${value}${unit}`;
2653
2683
  }
2654
2684
  return value;
2655
2685
  }
2656
- // convert to number if possible (font-size: "12px" -> fontSize:12, opacity: "0.5" -> opacity: 0.5)
2657
- if (!unit || value.endsWith(unit)) {
2658
- const numericValue = parseFloat(value);
2659
- if (!isNaN(numericValue)) {
2660
- return numericValue;
2661
- }
2686
+ // A number to work with, only when the value is exactly one:
2687
+ // "12px" -> 12, "0.5" -> 0.5. "10px 20px" is a list and "calc()" is an
2688
+ // expression parseFloat would silently keep their first number.
2689
+ if (unit ? isBareNumberWithUnit(value, unit) : isBareNumber(value)) {
2690
+ return parseFloat(value);
2662
2691
  }
2663
2692
  return value;
2664
2693
  }
@@ -3904,6 +3933,14 @@ const getLuminance = (r, g, b) => {
3904
3933
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
3905
3934
  };
3906
3935
 
3936
+ /**
3937
+ * First ancestor of `node` matching `predicate`, walking parent by parent.
3938
+ * Starts at the parent — `node` itself is never a candidate.
3939
+ *
3940
+ * @param {Node} node
3941
+ * @param {(ancestor: Node) => boolean} predicate
3942
+ * @returns {Node|null}
3943
+ */
3907
3944
  const findAncestor = (node, predicate) => {
3908
3945
  let ancestor = node.parentNode;
3909
3946
  while (ancestor) {
@@ -3915,6 +3952,21 @@ const findAncestor = (node, predicate) => {
3915
3952
  return null;
3916
3953
  };
3917
3954
 
3955
+ /**
3956
+ * First descendant of `rootNode` matching `fn`, in document order (depth
3957
+ * first). The walk is bounded to the subtree: `rootNode` itself is not a
3958
+ * candidate, and a root with no children yields nothing — never the root's
3959
+ * siblings.
3960
+ *
3961
+ * @param {Node} rootNode
3962
+ * @param {(node: Node, skip: () => void) => boolean} fn - Return true to stop
3963
+ * on `node`. Call `skip()` to not descend into `node`'s children (the walk
3964
+ * goes on with its siblings).
3965
+ * @param {object} [options]
3966
+ * @param {Node} [options.skipRoot] - A subtree to leave out entirely, itself
3967
+ * included.
3968
+ * @returns {Node|null}
3969
+ */
3918
3970
  const findDescendant = (rootNode, fn, { skipRoot } = {}) => {
3919
3971
  const iterator = createNextNodeIterator(rootNode, rootNode, skipRoot);
3920
3972
  let { done, value: node } = iterator.next();
@@ -3935,6 +3987,18 @@ const findDescendant = (rootNode, fn, { skipRoot } = {}) => {
3935
3987
  return null;
3936
3988
  };
3937
3989
 
3990
+ /**
3991
+ * Last descendant of `rootNode` matching `fn` in document order — the walk
3992
+ * starts at the subtree's deepest final node and moves backwards, so the
3993
+ * first match it meets is the last one the document holds.
3994
+ *
3995
+ * @param {Node} rootNode
3996
+ * @param {(node: Node) => boolean} fn
3997
+ * @param {object} [options]
3998
+ * @param {Node} [options.skipRoot] - A subtree to leave out entirely, itself
3999
+ * included.
4000
+ * @returns {Node|null}
4001
+ */
3938
4002
  const findLastDescendant = (rootNode, fn, { skipRoot } = {}) => {
3939
4003
  const deepestNode = getDeepestNode(rootNode, skipRoot);
3940
4004
  if (deepestNode) {
@@ -3954,6 +4018,23 @@ const findLastDescendant = (rootNode, fn, { skipRoot } = {}) => {
3954
4018
  return null;
3955
4019
  };
3956
4020
 
4021
+ /**
4022
+ * First node after `from` in document order matching `predicate`. Unlike
4023
+ * findDescendant this is anchored to a position, not a container: the walk
4024
+ * leaves `from`'s subtree and goes on through its siblings and its ancestors'
4025
+ * siblings, until `root`'s subtree is exhausted.
4026
+ *
4027
+ * @param {Node} from - The position to search from; not a candidate itself.
4028
+ * @param {(node: Node) => boolean} predicate
4029
+ * @param {object} [options]
4030
+ * @param {Node} [options.root] - Bounds the walk to its subtree; null walks to
4031
+ * the end of the tree `from` belongs to.
4032
+ * @param {Node} [options.skipRoot] - A subtree to leave out entirely, itself
4033
+ * included. A `from` inside it starts right after it.
4034
+ * @param {boolean} [options.skipChildren] - Do not look inside `from`; start
4035
+ * at what follows it.
4036
+ * @returns {Node|null}
4037
+ */
3957
4038
  const findAfter = (
3958
4039
  from,
3959
4040
  predicate,
@@ -3970,6 +4051,21 @@ const findAfter = (
3970
4051
  return null;
3971
4052
  };
3972
4053
 
4054
+ /**
4055
+ * First node before `from` in reverse document order matching `predicate` —
4056
+ * what findAfter is to "next", this is to "previous". A step back lands on
4057
+ * the previous sibling's DEEPEST last node (document order walked backwards),
4058
+ * not on the sibling itself.
4059
+ *
4060
+ * @param {Node} from - The position to search from; not a candidate itself.
4061
+ * @param {(node: Node) => boolean} predicate
4062
+ * @param {object} [options]
4063
+ * @param {Node} [options.root] - Bounds the walk to its subtree; null walks
4064
+ * back to the start of the tree `from` belongs to.
4065
+ * @param {Node} [options.skipRoot] - A subtree to leave out entirely, itself
4066
+ * included. A `from` inside it starts right before it.
4067
+ * @returns {Node|null}
4068
+ */
3973
4069
  const findBefore = (
3974
4070
  from,
3975
4071
  predicate,
@@ -4002,6 +4098,15 @@ const getNextNode = (node, rootNode, skipChild = false, skipRoot = null) => {
4002
4098
  }
4003
4099
  }
4004
4100
 
4101
+ // The traversal is bounded to rootNode's subtree: the root's own siblings
4102
+ // are not part of it. Without this, a rootNode with no children (asking
4103
+ // findDescendant about an <input>, say) steps to its next sibling and walks
4104
+ // the rest of the document from there — the parentNode guard below never
4105
+ // catches it because the walk is already outside the root.
4106
+ if (node === rootNode) {
4107
+ return null;
4108
+ }
4109
+
4005
4110
  const nextSibling = node.nextSibling;
4006
4111
  if (nextSibling) {
4007
4112
  // If next sibling is skipRoot, skip it entirely
@@ -4410,8 +4515,13 @@ const canInteract = (element) => {
4410
4515
  if (element.disabled) {
4411
4516
  return false;
4412
4517
  }
4413
- if (element.hasAttribute("inert")) {
4414
- // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert
4518
+ // closest, not hasAttribute: inert is inherited by the whole subtree — the
4519
+ // element itself may carry nothing and still be untouchable because something
4520
+ // above it is inert (a slide waiting off screen, the page behind a modal).
4521
+ // Focusing one of those does nothing at all, silently: the browser refuses and
4522
+ // the focus stays where it was, which reads as "the popup opened on nothing".
4523
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert
4524
+ if (element.closest("[inert]")) {
4415
4525
  return false;
4416
4526
  }
4417
4527
  return true;
@@ -4498,6 +4608,185 @@ const findFocusable = (element, { exclude } = {}) => {
4498
4608
  return focusableDescendant;
4499
4609
  };
4500
4610
 
4611
+ // note: keep in mind that an element with overflow: 'hidden' is scrollable
4612
+ // it can be scrolled using keyboard arrows or JavaScript properties such as scrollTop, scrollLeft
4613
+ // the only overflow that prevents scroll is "visible"
4614
+ const isScrollable = (element, { includeHidden } = {}) => {
4615
+ if (canHaveVerticalScroll(element, { includeHidden })) {
4616
+ return true;
4617
+ }
4618
+ if (canHaveHorizontalScroll(element, { includeHidden })) {
4619
+ return true;
4620
+ }
4621
+ return false;
4622
+ };
4623
+
4624
+ // Whether this element is what scrolls on that axis: it says it may (overflow)
4625
+ // and it has somewhere to go (it overflows). Both are needed — an "auto" box
4626
+ // whose content fits scrolls nothing, and `overflow-x: auto` alone makes the
4627
+ // COMPUTED overflow-y auto too (CSS does not let one axis stay visible next to
4628
+ // a scrolling one), so a box scrolling sideways declares a vertical scroll it
4629
+ // will never do.
4630
+ const canScroll = (element, axis) => {
4631
+ if (!element || element.nodeType !== 1) {
4632
+ return false;
4633
+ }
4634
+ const style = getComputedStyle(element);
4635
+ const overflow = axis === "x" ? style.overflowX : style.overflowY;
4636
+ if (overflow !== "auto" && overflow !== "scroll") {
4637
+ return false;
4638
+ }
4639
+ const scrollSize = axis === "x" ? element.scrollWidth : element.scrollHeight;
4640
+ const clientSize = axis === "x" ? element.clientWidth : element.clientHeight;
4641
+ // A pixel of slack: subpixel content rounds scrollSize up on boxes that have
4642
+ // nowhere to scroll to.
4643
+ return scrollSize - clientSize > 1;
4644
+ };
4645
+
4646
+ const canHaveVerticalScroll = (element, { includeHidden }) => {
4647
+ const verticalOverflow = getStyle(element, "overflow-y");
4648
+ if (verticalOverflow === "visible") {
4649
+ // browser returns "visible" on documentElement even if it is scrollable
4650
+ if (isDocumentElement(element)) {
4651
+ return true;
4652
+ }
4653
+ return false;
4654
+ }
4655
+ if (verticalOverflow === "hidden" || verticalOverflow === "clip") {
4656
+ return includeHidden;
4657
+ }
4658
+ const overflow = getStyle(element, "overflow");
4659
+ if (overflow === "visible") {
4660
+ // browser returns "visible" on documentElement even if it is scrollable
4661
+ if (isDocumentElement(element)) {
4662
+ return true;
4663
+ }
4664
+ return false;
4665
+ }
4666
+ if (overflow === "hidden" || overflow === "clip") {
4667
+ return includeHidden;
4668
+ }
4669
+ return true; // "auto", "scroll"
4670
+ };
4671
+ const canHaveHorizontalScroll = (element, { includeHidden }) => {
4672
+ const horizontalOverflow = getStyle(element, "overflow-x");
4673
+ if (horizontalOverflow === "visible") {
4674
+ // browser returns "visible" on documentElement even if it is scrollable
4675
+ if (isDocumentElement(element)) {
4676
+ return true;
4677
+ }
4678
+ return false;
4679
+ }
4680
+ if (horizontalOverflow === "hidden" || horizontalOverflow === "clip") {
4681
+ return includeHidden;
4682
+ }
4683
+ const overflow = getStyle(element, "overflow");
4684
+ if (overflow === "visible") {
4685
+ if (isDocumentElement(element)) {
4686
+ // browser returns "visible" on documentElement even if it is scrollable
4687
+ return true;
4688
+ }
4689
+ return false;
4690
+ }
4691
+ if (overflow === "hidden" || overflow === "clip") {
4692
+ return includeHidden;
4693
+ }
4694
+ return true; // "auto", "scroll"
4695
+ };
4696
+
4697
+ const getScrollingElement = (document) => {
4698
+ const { scrollingElement } = document;
4699
+ if (scrollingElement) {
4700
+ return scrollingElement;
4701
+ }
4702
+
4703
+ if (isCompliant(document)) {
4704
+ return document.documentElement;
4705
+ }
4706
+
4707
+ const body = document.body;
4708
+ const isFrameset = body && !/body/i.test(body.tagName);
4709
+ const possiblyScrollingElement = isFrameset ? getNextBodyElement(body) : body;
4710
+
4711
+ // If `body` is itself scrollable, it is not the `scrollingElement`.
4712
+ return possiblyScrollingElement && bodyIsScrollable(possiblyScrollingElement)
4713
+ ? null
4714
+ : possiblyScrollingElement;
4715
+ };
4716
+
4717
+ const isHidden = (element) => {
4718
+ const display = getStyle(element, "display");
4719
+ if (display === "none") {
4720
+ return false;
4721
+ }
4722
+
4723
+ if (
4724
+ display === "table-row" ||
4725
+ display === "table-group" ||
4726
+ display === "table-column"
4727
+ ) {
4728
+ return getStyle(element, "visibility") !== "collapsed";
4729
+ }
4730
+
4731
+ return true;
4732
+ };
4733
+ const isCompliant = (document) => {
4734
+ // Note: document.compatMode can be toggle at runtime by document.write
4735
+ const isStandardsMode = /^CSS1/.test(document.compatMode);
4736
+ if (isStandardsMode) {
4737
+ return testScrollCompliance(document);
4738
+ }
4739
+ return false;
4740
+ };
4741
+ const testScrollCompliance = (document) => {
4742
+ const iframe = document.createElement("iframe");
4743
+ iframe.style.height = "1px";
4744
+ const parentNode = document.body || document.documentElement || document;
4745
+ parentNode.appendChild(iframe);
4746
+ const iframeDocument = iframe.contentWindow.document;
4747
+ iframeDocument.write('<!DOCTYPE html><div style="height:9999em">x</div>');
4748
+ iframeDocument.close();
4749
+ const scrollComplianceResult =
4750
+ iframeDocument.documentElement.scrollHeight >
4751
+ iframeDocument.body.scrollHeight;
4752
+ iframe.parentNode.removeChild(iframe);
4753
+ return scrollComplianceResult;
4754
+ };
4755
+ const getNextBodyElement = (frameset) => {
4756
+ // We use this function to be correct per spec in case `document.body` is
4757
+ // a `frameset` but there exists a later `body`. Since `document.body` is
4758
+ // a `frameset`, we know the root is an `html`, and there was no `body`
4759
+ // before the `frameset`, so we just need to look at siblings after the
4760
+ // `frameset`.
4761
+ let current = frameset;
4762
+ while ((current = current.nextSibling)) {
4763
+ if (current.nodeType === 1 && isBodyElement(current)) {
4764
+ return current;
4765
+ }
4766
+ }
4767
+ return null;
4768
+ };
4769
+ const isBodyElement = (element) => element.ownerDocument.body === element;
4770
+ const bodyIsScrollable = (body) => {
4771
+ // a body element is scrollable if body and html are scrollable and rendered
4772
+ if (!isScrollable(body)) {
4773
+ return false;
4774
+ }
4775
+ if (isHidden(body)) {
4776
+ return false;
4777
+ }
4778
+
4779
+ const documentElement = body.ownerDocument.documentElement;
4780
+ if (!isScrollable(documentElement)) {
4781
+ return false;
4782
+ }
4783
+ if (isHidden(documentElement)) {
4784
+ return false;
4785
+ }
4786
+
4787
+ return true;
4788
+ };
4789
+
4501
4790
  /**
4502
4791
  * Returns the browser's default action for a keyboard event on its target element.
4503
4792
  *
@@ -4509,7 +4798,12 @@ const findFocusable = (element, { exclude } = {}) => {
4509
4798
  * - `"value_change"` — key increments/decrements the field value (range, number, date…)
4510
4799
  * - `"cursor_move"` — key moves the text cursor within the field
4511
4800
  * - `"type"` — key produces or deletes text content
4512
- * - `"scroll"` — key scrolls the page or a scrollable container
4801
+ * - `"scroll"` — key would scroll the page: nothing on the element itself
4802
+ * claims it, so it is safe to intercept
4803
+ * - `"scroll_self"` — the focused element scrolls ITSELF that way (it really
4804
+ * overflows on that axis): the key is spoken for, and
4805
+ * taking it would leave a scrollable region no way to be
4806
+ * scrolled from the keyboard
4513
4807
  * - `""` — no meaningful browser default; safe to intercept freely
4514
4808
  */
4515
4809
  const normalizeKeyboardKey = (rawKey) => {
@@ -4753,6 +5047,26 @@ const DEFAULT_BEHAVIORS = [
4753
5047
  escape: "dismiss",
4754
5048
  },
4755
5049
  },
5050
+ {
5051
+ // An element that really scrolls — a slide's own body, a scrollable panel:
5052
+ // the browser gives it the arrows (and Home/End/PageUp/PageDown) so it can
5053
+ // be read from the keyboard, and that is not a key to take. Asked per axis
5054
+ // and per element, not from a class or an attribute: what makes it true is
5055
+ // that it overflows right now.
5056
+ test: (el) => canScroll(el, "y") || canScroll(el, "x"),
5057
+ keys: {
5058
+ arrowup: (e) => (canScroll(e.target, "y") ? "scroll_self" : undefined),
5059
+ arrowdown: (e) => (canScroll(e.target, "y") ? "scroll_self" : undefined),
5060
+ arrowleft: (e) => (canScroll(e.target, "x") ? "scroll_self" : undefined),
5061
+ arrowright: (e) => (canScroll(e.target, "x") ? "scroll_self" : undefined),
5062
+ pageup: (e) => (canScroll(e.target, "y") ? "scroll_self" : undefined),
5063
+ pagedown: (e) => (canScroll(e.target, "y") ? "scroll_self" : undefined),
5064
+ home: (e) => (canScroll(e.target, "y") ? "scroll_self" : undefined),
5065
+ end: (e) => (canScroll(e.target, "y") ? "scroll_self" : undefined),
5066
+ space: (e) => (canScroll(e.target, "y") ? "scroll_self" : undefined),
5067
+ },
5068
+ // no fallback: only these keys are claimed, everything else keeps looking
5069
+ },
4756
5070
  {
4757
5071
  // Non-interactive elements: browser scrolls on Space and arrow keys
4758
5072
  test: () => true,
@@ -6033,9 +6347,15 @@ const trapFocusInside = (
6033
6347
  // A backdrop click is detected when the target is a <dialog> element —
6034
6348
  // the ::backdrop pseudo-element is not in the DOM, so the event target
6035
6349
  // becomes the dialog element itself when its content area is not hit.
6350
+ // Read through getAttribute rather than .className: on an SVG element
6351
+ // className is an SVGAnimatedString, not a string, and asking it for
6352
+ // .includes throws — which is how clicking an icon inside the trap
6353
+ // used to break. Still a substring test, because the real class names
6354
+ // are navi_dialog_backdrop / navi_popover_backdrop / ….
6355
+ const targetClass = event.target.getAttribute?.("class") || "";
6036
6356
  const isBackdropClick =
6037
6357
  event.target.tagName === "DIALOG" ||
6038
- event.target.className.includes("backdrop");
6358
+ targetClass.includes("backdrop");
6039
6359
  if (!isBackdropClick) {
6040
6360
  event.stopImmediatePropagation();
6041
6361
  }
@@ -6203,163 +6523,6 @@ const captureScrollState = (element) => {
6203
6523
  };
6204
6524
  };
6205
6525
 
6206
- // note: keep in mind that an element with overflow: 'hidden' is scrollable
6207
- // it can be scrolled using keyboard arrows or JavaScript properties such as scrollTop, scrollLeft
6208
- // the only overflow that prevents scroll is "visible"
6209
- const isScrollable = (element, { includeHidden } = {}) => {
6210
- if (canHaveVerticalScroll(element, { includeHidden })) {
6211
- return true;
6212
- }
6213
- if (canHaveHorizontalScroll(element, { includeHidden })) {
6214
- return true;
6215
- }
6216
- return false;
6217
- };
6218
-
6219
- const canHaveVerticalScroll = (element, { includeHidden }) => {
6220
- const verticalOverflow = getStyle(element, "overflow-y");
6221
- if (verticalOverflow === "visible") {
6222
- // browser returns "visible" on documentElement even if it is scrollable
6223
- if (isDocumentElement(element)) {
6224
- return true;
6225
- }
6226
- return false;
6227
- }
6228
- if (verticalOverflow === "hidden" || verticalOverflow === "clip") {
6229
- return includeHidden;
6230
- }
6231
- const overflow = getStyle(element, "overflow");
6232
- if (overflow === "visible") {
6233
- // browser returns "visible" on documentElement even if it is scrollable
6234
- if (isDocumentElement(element)) {
6235
- return true;
6236
- }
6237
- return false;
6238
- }
6239
- if (overflow === "hidden" || overflow === "clip") {
6240
- return includeHidden;
6241
- }
6242
- return true; // "auto", "scroll"
6243
- };
6244
- const canHaveHorizontalScroll = (element, { includeHidden }) => {
6245
- const horizontalOverflow = getStyle(element, "overflow-x");
6246
- if (horizontalOverflow === "visible") {
6247
- // browser returns "visible" on documentElement even if it is scrollable
6248
- if (isDocumentElement(element)) {
6249
- return true;
6250
- }
6251
- return false;
6252
- }
6253
- if (horizontalOverflow === "hidden" || horizontalOverflow === "clip") {
6254
- return includeHidden;
6255
- }
6256
- const overflow = getStyle(element, "overflow");
6257
- if (overflow === "visible") {
6258
- if (isDocumentElement(element)) {
6259
- // browser returns "visible" on documentElement even if it is scrollable
6260
- return true;
6261
- }
6262
- return false;
6263
- }
6264
- if (overflow === "hidden" || overflow === "clip") {
6265
- return includeHidden;
6266
- }
6267
- return true; // "auto", "scroll"
6268
- };
6269
-
6270
- const getScrollingElement = (document) => {
6271
- const { scrollingElement } = document;
6272
- if (scrollingElement) {
6273
- return scrollingElement;
6274
- }
6275
-
6276
- if (isCompliant(document)) {
6277
- return document.documentElement;
6278
- }
6279
-
6280
- const body = document.body;
6281
- const isFrameset = body && !/body/i.test(body.tagName);
6282
- const possiblyScrollingElement = isFrameset ? getNextBodyElement(body) : body;
6283
-
6284
- // If `body` is itself scrollable, it is not the `scrollingElement`.
6285
- return possiblyScrollingElement && bodyIsScrollable(possiblyScrollingElement)
6286
- ? null
6287
- : possiblyScrollingElement;
6288
- };
6289
-
6290
- const isHidden = (element) => {
6291
- const display = getStyle(element, "display");
6292
- if (display === "none") {
6293
- return false;
6294
- }
6295
-
6296
- if (
6297
- display === "table-row" ||
6298
- display === "table-group" ||
6299
- display === "table-column"
6300
- ) {
6301
- return getStyle(element, "visibility") !== "collapsed";
6302
- }
6303
-
6304
- return true;
6305
- };
6306
- const isCompliant = (document) => {
6307
- // Note: document.compatMode can be toggle at runtime by document.write
6308
- const isStandardsMode = /^CSS1/.test(document.compatMode);
6309
- if (isStandardsMode) {
6310
- return testScrollCompliance(document);
6311
- }
6312
- return false;
6313
- };
6314
- const testScrollCompliance = (document) => {
6315
- const iframe = document.createElement("iframe");
6316
- iframe.style.height = "1px";
6317
- const parentNode = document.body || document.documentElement || document;
6318
- parentNode.appendChild(iframe);
6319
- const iframeDocument = iframe.contentWindow.document;
6320
- iframeDocument.write('<!DOCTYPE html><div style="height:9999em">x</div>');
6321
- iframeDocument.close();
6322
- const scrollComplianceResult =
6323
- iframeDocument.documentElement.scrollHeight >
6324
- iframeDocument.body.scrollHeight;
6325
- iframe.parentNode.removeChild(iframe);
6326
- return scrollComplianceResult;
6327
- };
6328
- const getNextBodyElement = (frameset) => {
6329
- // We use this function to be correct per spec in case `document.body` is
6330
- // a `frameset` but there exists a later `body`. Since `document.body` is
6331
- // a `frameset`, we know the root is an `html`, and there was no `body`
6332
- // before the `frameset`, so we just need to look at siblings after the
6333
- // `frameset`.
6334
- let current = frameset;
6335
- while ((current = current.nextSibling)) {
6336
- if (current.nodeType === 1 && isBodyElement(current)) {
6337
- return current;
6338
- }
6339
- }
6340
- return null;
6341
- };
6342
- const isBodyElement = (element) => element.ownerDocument.body === element;
6343
- const bodyIsScrollable = (body) => {
6344
- // a body element is scrollable if body and html are scrollable and rendered
6345
- if (!isScrollable(body)) {
6346
- return false;
6347
- }
6348
- if (isHidden(body)) {
6349
- return false;
6350
- }
6351
-
6352
- const documentElement = body.ownerDocument.documentElement;
6353
- if (!isScrollable(documentElement)) {
6354
- return false;
6355
- }
6356
- if (isHidden(documentElement)) {
6357
- return false;
6358
- }
6359
-
6360
- return true;
6361
- };
6362
-
6363
6526
  // https://developer.mozilla.org/en-US/docs/Glossary/Scroll_container
6364
6527
 
6365
6528
 
@@ -7229,9 +7392,14 @@ const getPaddingSizes = (element) => {
7229
7392
  *
7230
7393
  * @param {HTMLElement} element - The overlay element being shown. Its preceding
7231
7394
  * siblings and all ancestor scroll containers will be scroll-locked.
7395
+ * @param {Object} [options]
7396
+ * @param {HTMLElement} [options.boundaryElement] - Only lock scroll containers
7397
+ * inside this element (itself included). For an overlay confined to a local
7398
+ * container rather than the viewport: the container's own scroll must stop,
7399
+ * the rest of the page keeps scrolling as usual.
7232
7400
  * @returns {() => void} Cleanup function that restores all modified styles.
7233
7401
  */
7234
- const trapScrollInside = (element) => {
7402
+ const trapScrollInside = (element, { boundaryElement } = {}) => {
7235
7403
  const cleanupCallbackSet = new Set();
7236
7404
 
7237
7405
  // Collect every element to lock first (preceding scrollable siblings + all
@@ -7245,7 +7413,11 @@ const trapScrollInside = (element) => {
7245
7413
  previous = previous.previousSibling;
7246
7414
  }
7247
7415
  for (const selfOrAncestorScroll of getSelfAndAncestorScrolls(element)) {
7248
- elementsToLock.push(selfOrAncestorScroll.scrollContainer);
7416
+ const { scrollContainer } = selfOrAncestorScroll;
7417
+ if (boundaryElement && !boundaryElement.contains(scrollContainer)) {
7418
+ continue;
7419
+ }
7420
+ elementsToLock.push(scrollContainer);
7249
7421
  }
7250
7422
 
7251
7423
  // Phase 1 — MEASURE. Batch every layout/style read (scrollTop, scrollbar
@@ -10635,47 +10807,127 @@ const moveCSSVars = (vars, fromEl, toEl) => {
10635
10807
  };
10636
10808
 
10637
10809
  installImportMetaCssBuild(import.meta);const css$1 = /* css */`
10810
+ /* IN THE PAGE, NOT IN THE LIST: the hint lands on the edge of a row, which
10811
+ for the last one is the very bottom of the scroll area — drawn inside it,
10812
+ the line would push the scrollable area a few pixels further and make a
10813
+ scrollbar appear (or hide the hint under it) exactly when one is trying to
10814
+ drop at the end. Placed in the body and positioned in viewport
10815
+ coordinates, it can sit anywhere, overhang the list, and cost nothing to
10816
+ the layout. Fixed, like the clone it accompanies. */
10638
10817
  .navi_drop_hint {
10639
- position: absolute;
10818
+ /* A popover, so it lands in the top layer: no z-index to bid against the
10819
+ page, and nothing it can be hidden behind. Shown BEFORE the clone, which
10820
+ is what puts the clone above it — the top layer stacks in the order
10821
+ things are shown, and the item being carried should pass over the line
10822
+ rather than under it. The UA styles for [popover] have to be undone:
10823
+ inset:0, margin:auto, a border and a background of its own. */
10824
+ position: fixed;
10825
+ inset: auto;
10640
10826
  top: var(--drop-hint-y);
10641
10827
  left: calc(var(--drop-target-left) + var(--drop-hint-margin-x, 0px));
10642
- z-index: 10;
10643
10828
  display: none;
10829
+ box-sizing: border-box;
10644
10830
  width: calc(var(--drop-target-width) - 2 * var(--drop-hint-margin-x, 0px));
10645
10831
  height: var(--drop-hint-size, 3px);
10832
+ margin: 0;
10833
+ padding: 0;
10834
+ color: inherit;
10646
10835
  background: var(--drop-hint-background-color, #4476ff);
10836
+ border: none;
10647
10837
  border-radius: var(--drop-hint-border-radius, 2px);
10648
10838
  transform: translateY(-50%);
10649
10839
  pointer-events: none;
10840
+ overflow: visible;
10650
10841
  }
10651
- [data-drop-edge="top"] > .navi_drop_hint {
10842
+ .navi_drop_hint[data-drop-edge]:popover-open {
10652
10843
  display: block;
10844
+ }
10845
+ .navi_drop_hint[data-drop-edge="top"] {
10653
10846
  --drop-hint-y: calc(
10654
10847
  var(--drop-target-top) - var(--drop-hint-margin-y, 0px)
10655
10848
  );
10656
10849
  }
10657
- [data-drop-edge="bottom"] > .navi_drop_hint {
10658
- display: block;
10850
+ .navi_drop_hint[data-drop-edge="bottom"] {
10659
10851
  --drop-hint-y: calc(
10660
10852
  var(--drop-target-bottom) + var(--drop-hint-margin-y, 0px)
10661
10853
  );
10662
10854
  }
10855
+ /* A chevron at each end, pointing in: the line alone is easy to lose against
10856
+ a list of borders and separators, two arrows read as "here" at a glance
10857
+ (same idea as the table's column drop preview). They overhang the line,
10858
+ which costs nothing now that the hint is out of the scrollable area — and
10859
+ the more they stick out, the easier they are to spot. */
10860
+ .navi_drop_hint_cap {
10861
+ position: absolute;
10862
+ top: 50%;
10863
+ display: flex;
10864
+ color: var(--drop-hint-background-color, #4476ff);
10865
+ translate: 0 -50%;
10866
+ }
10867
+ .navi_drop_hint_cap svg {
10868
+ width: var(--drop-hint-arrow-size, 11px);
10869
+ height: var(--drop-hint-arrow-size, 11px);
10870
+ }
10871
+ .navi_drop_hint_cap[data-side="start"] {
10872
+ left: calc(-1 * var(--drop-hint-arrow-size, 11px));
10873
+ rotate: -90deg;
10874
+ }
10875
+ .navi_drop_hint_cap[data-side="end"] {
10876
+ right: calc(-1 * var(--drop-hint-arrow-size, 11px));
10877
+ rotate: 90deg;
10878
+ }
10879
+
10880
+ /* WHO CAN START A DRAG, said in the cursor.
10881
+ A handle drags on the spot, so it shows the hand. A source only drags once
10882
+ the pointer has travelled a few pixels — a plain click stays a click — but
10883
+ the text inside it can no longer be selected (the gesture takes the
10884
+ pointer), so an I-beam over it would promise something that does not
10885
+ happen: it reads as a plain surface instead. An opted-out area keeps both
10886
+ its cursor and its selection, and never starts a drag (see the check in
10887
+ startDragToReorder).
10888
+ Controls inside a source keep their own cursor: cursor is inherited, and
10889
+ anything setting its own (a button's pointer) wins on itself.
10890
+ Only the resting cursor is set here: what it becomes once a drag is under
10891
+ way belongs to the gesture (see the backdrop in drag_gesture.js), the only
10892
+ thing that knows a drag actually started. */
10893
+ [data-drag-handle] {
10894
+ cursor: grab;
10895
+ }
10896
+ [data-drag-source] {
10897
+ cursor: default;
10898
+ user-select: none;
10899
+ }
10900
+ [data-drag-ignore] {
10901
+ cursor: auto;
10902
+ user-select: auto;
10903
+ }
10663
10904
 
10664
10905
  [navi-drag-clone-source] {
10665
10906
  visibility: hidden;
10666
10907
  }
10667
10908
 
10668
10909
  [navi-drag-clone-wrapper] {
10669
- position: absolute;
10910
+ /* Also a popover (see .navi_drop_hint): in the top layer it is over the
10911
+ page whatever the page's own stacking is, and the coordinates it is
10912
+ given are viewport ones — which is what the pointer carrying it works
10913
+ in. Same UA-style reset as the hint. */
10914
+ position: fixed;
10915
+ inset: auto;
10670
10916
  top: var(--clone-top);
10671
10917
  left: var(--clone-left);
10672
- z-index: 9999;
10918
+ box-sizing: border-box;
10673
10919
  width: var(--clone-width);
10674
10920
  height: var(--clone-height);
10921
+ margin: 0;
10922
+ padding: 0;
10923
+ color: inherit;
10924
+ background: transparent;
10925
+ border: none;
10675
10926
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
10676
10927
  opacity: 0.95;
10677
10928
  transition: box-shadow 0.15s ease;
10678
10929
  pointer-events: none;
10930
+ overflow: visible;
10679
10931
  }
10680
10932
 
10681
10933
  [navi-drag-clone] {
@@ -10694,7 +10946,10 @@ installImportMetaCssBuild(import.meta);const css$1 = /* css */`
10694
10946
  }
10695
10947
  }
10696
10948
  `;
10697
- const dragCSSVars = ["--drop-hint-size", "--drop-hint-background-color", "--drop-hint-border-radius", "--drop-hint-margin-x", "--drop-hint-margin-y", "--drag-clone-scale"];
10949
+ // At module scope, not inside startDragToReorder: the cursor rules above say who
10950
+ // can start a drag, and they have to be true BEFORE anyone drags anything.
10951
+ import.meta.css = [css$1, "@jsenv/dom/src/interaction/drag/drag_to_reorder.js"];
10952
+ const dragCSSVars = ["--drop-hint-size", "--drop-hint-background-color", "--drop-hint-border-radius", "--drop-hint-margin-x", "--drop-hint-margin-y", "--drop-hint-arrow-size", "--drag-clone-scale"];
10698
10953
 
10699
10954
  /**
10700
10955
  * Starts a drag-to-reorder interaction on a list item.
@@ -10760,7 +11015,11 @@ const startDragToReorder = (event, {
10760
11015
  },
10761
11016
  ...options
10762
11017
  }) => {
10763
- import.meta.css = [css$1, "@jsenv/dom/src/interaction/drag/drag_to_reorder.js"];
11018
+ // An area that opted out of dragging (a text one wants to select, a control
11019
+ // that owns the gesture): the press there is none of our business.
11020
+ if (event.target.closest && event.target.closest("[data-drag-ignore]")) {
11021
+ return undefined;
11022
+ }
10764
11023
  event.preventDefault();
10765
11024
  return dragAfterThreshold(event, () => {
10766
11025
  const cloneWrapper = createDragClone(draggedElement, event);
@@ -10780,21 +11039,23 @@ const startDragToReorder = (event, {
10780
11039
  // getDropTargetInfo uses gestureInfo.elementImpacted to compute the dragged rect.
10781
11040
  // Point it at the clone so drop detection tracks the clone's current position.
10782
11041
  dragGesture.gestureInfo.elementImpacted = cloneWrapper;
10783
- const scrollContainer = dragGesture.gestureInfo.scrollContainer;
10784
- const dropHintEl = document.createElement("div");
10785
- dropHintEl.className = "navi_drop_hint";
10786
- scrollContainer.appendChild(dropHintEl);
11042
+ const dropHintEl = createDropHint();
11043
+ document.body.appendChild(dropHintEl);
11044
+ // The hint first, the clone second: that order is what stacks them in the
11045
+ // top layer.
11046
+ dropHintEl.showPopover();
11047
+ cloneWrapper.showPopover();
10787
11048
 
10788
11049
  // currentBeforeElement: element before which the grabbed item will be inserted (null = end)
10789
11050
  // currentReleaseElement: the actual hovered drop target — used to snap the clone on release
10790
11051
  let currentBeforeElement;
10791
11052
  let currentReleaseElement;
10792
11053
  const clearDropHintDOM = () => {
10793
- scrollContainer.removeAttribute("data-drop-edge");
10794
- scrollContainer.style.removeProperty("--drop-target-top");
10795
- scrollContainer.style.removeProperty("--drop-target-bottom");
10796
- scrollContainer.style.removeProperty("--drop-target-left");
10797
- scrollContainer.style.removeProperty("--drop-target-width");
11054
+ dropHintEl.removeAttribute("data-drop-edge");
11055
+ dropHintEl.style.removeProperty("--drop-target-top");
11056
+ dropHintEl.style.removeProperty("--drop-target-bottom");
11057
+ dropHintEl.style.removeProperty("--drop-target-left");
11058
+ dropHintEl.style.removeProperty("--drop-target-width");
10798
11059
  };
10799
11060
  const clearDropHint = () => {
10800
11061
  currentBeforeElement = undefined;
@@ -10845,16 +11106,15 @@ const startDragToReorder = (event, {
10845
11106
  // beforeElement = X → insert before X (hint at top edge of X)
10846
11107
  const anchorEl = beforeElement || items[items.length - 1];
10847
11108
  const anchorEdge = beforeElement !== null ? "top" : "bottom";
10848
- const containerRect = scrollContainer.getBoundingClientRect();
11109
+ // Viewport coordinates, straight from the anchor row: the hint is fixed
11110
+ // in the page (see its CSS), so there is no container box to be relative
11111
+ // to and no scroll offset to add back.
10849
11112
  const anchorRect = anchorEl.getBoundingClientRect();
10850
- const isPositioned = getComputedStyle(scrollContainer).position !== "static";
10851
- const scrollOffsetLeft = isPositioned ? scrollContainer.scrollLeft : 0;
10852
- const scrollOffsetTop = isPositioned ? scrollContainer.scrollTop : 0;
10853
- scrollContainer.setAttribute("data-drop-edge", anchorEdge);
10854
- scrollContainer.style.setProperty("--drop-target-top", `${anchorRect.top - containerRect.top + scrollOffsetTop}px`);
10855
- scrollContainer.style.setProperty("--drop-target-bottom", `${anchorRect.bottom - containerRect.top + scrollOffsetTop}px`);
10856
- scrollContainer.style.setProperty("--drop-target-left", `${anchorRect.left - containerRect.left + scrollOffsetLeft}px`);
10857
- scrollContainer.style.setProperty("--drop-target-width", `${anchorRect.width}px`);
11113
+ dropHintEl.setAttribute("data-drop-edge", anchorEdge);
11114
+ dropHintEl.style.setProperty("--drop-target-top", `${anchorRect.top}px`);
11115
+ dropHintEl.style.setProperty("--drop-target-bottom", `${anchorRect.bottom}px`);
11116
+ dropHintEl.style.setProperty("--drop-target-left", `${anchorRect.left}px`);
11117
+ dropHintEl.style.setProperty("--drop-target-width", `${anchorRect.width}px`);
10858
11118
  });
10859
11119
  dragGesture.addReleaseCallback(async gestureInfo => {
10860
11120
  clearDropHintDOM();
@@ -10864,7 +11124,7 @@ const startDragToReorder = (event, {
10864
11124
  const clone = cloneWrapper.firstElementChild;
10865
11125
  // Bake the current visual position (transform included) into the CSS vars
10866
11126
  // so the clone stays where the user released it when we clear the transform.
10867
- setCloneDocumentRect(cloneWrapper, cloneWrapper);
11127
+ setCloneViewportRect(cloneWrapper, cloneWrapper);
10868
11128
  gestureInfo.cancelPosition();
10869
11129
  const fromId = getItemId(draggedElement);
10870
11130
  const toId = currentBeforeElement ? getItemId(currentBeforeElement) : null;
@@ -10873,7 +11133,7 @@ const startDragToReorder = (event, {
10873
11133
  const syncCloneWithDropTarget = () => {
10874
11134
  // Snap the CSS-var position to the drop target rect so the browser
10875
11135
  // captures the "new" state at the landing position.
10876
- setCloneDocumentRect(cloneWrapper, currentReleaseElement);
11136
+ setCloneViewportRect(cloneWrapper, currentReleaseElement);
10877
11137
  // Removing this attr drops the CSS scale(1.15), so the browser
10878
11138
  // captures the clone at scale 1 as the "new" state.
10879
11139
  clone.removeAttribute("navi-drag-clone");
@@ -10887,15 +11147,13 @@ const startDragToReorder = (event, {
10887
11147
  });
10888
11148
  };
10889
11149
 
10890
- // getBoundingClientRect() returns viewport-relative coords.
10891
- // The clone wrapper is position:absolute inside document.body, so we need
10892
- // document-relative coords (viewport coords + current page scroll).
10893
- const setCloneDocumentRect = (cloneWrapper, el) => {
11150
+ // Viewport coordinates, as getBoundingClientRect gives them: the clone is a
11151
+ // fixed-position popover, so that is the space it lives in — and the one the
11152
+ // pointer dragging it works in too.
11153
+ const setCloneViewportRect = (cloneWrapper, el) => {
10894
11154
  const rect = el.getBoundingClientRect();
10895
- const scrollLeft = document.documentElement.scrollLeft;
10896
- const scrollTop = document.documentElement.scrollTop;
10897
- cloneWrapper.style.setProperty("--clone-top", `${rect.top + scrollTop}px`);
10898
- cloneWrapper.style.setProperty("--clone-left", `${rect.left + scrollLeft}px`);
11155
+ cloneWrapper.style.setProperty("--clone-top", `${rect.top}px`);
11156
+ cloneWrapper.style.setProperty("--clone-left", `${rect.left}px`);
10899
11157
  cloneWrapper.style.setProperty("--clone-width", `${rect.width}px`);
10900
11158
  cloneWrapper.style.setProperty("--clone-height", `${rect.height}px`);
10901
11159
  };
@@ -10914,12 +11172,43 @@ const setCloneDocumentRect = (cloneWrapper, el) => {
10914
11172
  // so the element expands naturally from where the user clicked.
10915
11173
  // On release, the `navi-drag-clone` attribute is removed inside
10916
11174
  // startViewTransition to drop the scale back to 1 as the "new" state.
11175
+ // The chevron is the one the table's column drop preview uses, rotated by the
11176
+ // CSS above so each cap points into the line.
11177
+ const dropHintTemplate = /* html */`
11178
+ <div
11179
+ class="navi_drop_hint"
11180
+ popover="manual"
11181
+ >
11182
+ <span class="navi_drop_hint_cap" data-side="start">
11183
+ <svg fill="currentColor" viewBox="0 0 30.727 30.727">
11184
+ <path
11185
+ d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z"
11186
+ />
11187
+ </svg>
11188
+ </span>
11189
+ <span class="navi_drop_hint_cap" data-side="end">
11190
+ <svg fill="currentColor" viewBox="0 0 30.727 30.727">
11191
+ <path
11192
+ d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z"
11193
+ />
11194
+ </svg>
11195
+ </span>
11196
+ </div>
11197
+ `;
11198
+ const createDropHint = () => {
11199
+ const div = document.createElement("div");
11200
+ div.innerHTML = dropHintTemplate.trim();
11201
+ return div.firstElementChild;
11202
+ };
10917
11203
  const createDragClone = (element, pointerEvent) => {
10918
11204
  const rect = element.getBoundingClientRect();
10919
11205
  const wrapper = document.createElement("div");
10920
11206
  wrapper.setAttribute("navi-drag-clone-wrapper", "");
11207
+ // Manual: it is opened and closed with the drag, and must survive an Escape
11208
+ // or a click elsewhere (light dismiss would take it away mid-gesture).
11209
+ wrapper.setAttribute("popover", "manual");
10921
11210
  wrapper.viewTransitionName = "navi-drag-clone-wrapper";
10922
- setCloneDocumentRect(wrapper, element);
11211
+ setCloneViewportRect(wrapper, element);
10923
11212
  // Grab point within the element — used as transform-origin so the
10924
11213
  // scale(1.15) expands from where the user clicked, not the element center.
10925
11214
  // These offsets are element-relative so viewport coords are correct here.
@@ -11013,7 +11302,19 @@ const getResizeDirection = (element) => {
11013
11302
  // directions: hide when a container closes, recheck when it reopens) — the
11014
11303
  // selector/open-detection/timing primitives are identical for both, only
11015
11304
  // what each does with a transition differs.
11016
- const ANCESTOR_OPEN_SELECTOR = "dialog, details, [popover], [aria-expanded]";
11305
+ const OPENABLE_SELECTOR = "dialog, details, [popover], [aria-expanded]";
11306
+
11307
+ // An element that IS openable is closed in exactly the same way an element
11308
+ // inside one is — which matters for anything positioned against it, e.g. a
11309
+ // callout anchored to a dialog rather than to a field inside it. Same selector
11310
+ // as the walk up: whatever counts as openable above counts as openable here,
11311
+ // custom [aria-expanded] nodes included.
11312
+ const selfOrClosestOpenableAncestor = (element) => {
11313
+ if (element.matches?.(OPENABLE_SELECTOR)) {
11314
+ return element;
11315
+ }
11316
+ return closestOpenableAncestor(element);
11317
+ };
11017
11318
 
11018
11319
  const closestOpenableAncestor = (element) => {
11019
11320
  const parentElement = element.parentElement;
@@ -11023,7 +11324,7 @@ const closestOpenableAncestor = (element) => {
11023
11324
  if (!parentElement.closest) {
11024
11325
  return null;
11025
11326
  }
11026
- return parentElement.closest(ANCESTOR_OPEN_SELECTOR);
11327
+ return parentElement.closest(OPENABLE_SELECTOR);
11027
11328
  };
11028
11329
 
11029
11330
  const isAncestorOpen = (ancestor) => {
@@ -11575,7 +11876,10 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
11575
11876
  */
11576
11877
  // The event type observeSize() reports with — recognized by check() as "the
11577
11878
  // change is in another element, not in the tracked rect".
11578
- const OBSERVED_ELEMENT_SIZE_CHANGE = "observed_element_size_change";
11879
+ // Exported: a caller that resized the element itself (a callout whose message
11880
+ // just changed, say) has to re-check with this rather than with nothing — its
11881
+ // own rect may not have moved at all, and the dedup would drop the check.
11882
+ const ELEMENT_SIZE_CHANGE = "observed_element_size_change";
11579
11883
 
11580
11884
  const visibleRectEffect = (
11581
11885
  element,
@@ -11645,7 +11949,12 @@ const visibleRectEffect = (
11645
11949
  resizeWatchingPaused = false;
11646
11950
  publishResizeWatchingPausedChange(false);
11647
11951
  };
11648
- const check = (event) => {
11952
+ // Only so the reads below have something to read: a caller that describes
11953
+ // nothing gets no special treatment, it goes through the same dedup as any
11954
+ // other check. A caller that needs the dedup bypassed says so by passing the
11955
+ // event that means it (ELEMENT_SIZE_CHANGE).
11956
+ const UNSET_EVENT = { type: "unset" };
11957
+ const check = (event = UNSET_EVENT) => {
11649
11958
 
11650
11959
  // visualViewport, not window.innerWidth/Height: the layout viewport
11651
11960
  // doesn't shrink when the on-screen keyboard opens (same reasoning as
@@ -11825,7 +12134,7 @@ const visibleRectEffect = (
11825
12134
  // defeating the whole point of observeSize (a popover reconsidering its
11826
12135
  // placement once its own content shrinks/grows, a callout re-measuring
11827
12136
  // against its message body).
11828
- if (event.type === OBSERVED_ELEMENT_SIZE_CHANGE) {
12137
+ if (event.type === ELEMENT_SIZE_CHANGE) {
11829
12138
  lastVisibleRect = visibleRect;
11830
12139
  lastViewportRect = viewportRect;
11831
12140
  notify();
@@ -12062,7 +12371,12 @@ const visibleRectEffect = (
12062
12371
  });
12063
12372
  }
12064
12373
  {
12065
- let currentOpenableAncestor = closestOpenableAncestor(element);
12374
+ // Self-inclusive on the first step only: `element` can itself be the
12375
+ // dialog/popover that closes (a callout anchored to a dialog rather than
12376
+ // to a field inside it), and its own close hides it just as much as an
12377
+ // ancestor's would. The walk up below starts from parentElement, so the
12378
+ // chain still advances.
12379
+ let currentOpenableAncestor = selfOrClosestOpenableAncestor(element);
12066
12380
  while (currentOpenableAncestor) {
12067
12381
  const openableAncestor = currentOpenableAncestor;
12068
12382
  if (!isAncestorOpen(openableAncestor)) {
@@ -12235,7 +12549,7 @@ const visibleRectEffect = (
12235
12549
  pendingFrame = requestAnimationFrame(() => {
12236
12550
  pendingFrame = null;
12237
12551
  check(
12238
- new CustomEvent(OBSERVED_ELEMENT_SIZE_CHANGE, {
12552
+ new CustomEvent(ELEMENT_SIZE_CHANGE, {
12239
12553
  detail: { width, height },
12240
12554
  }),
12241
12555
  );
@@ -12497,7 +12811,7 @@ const toContainerAlignedPosition = (value) => {
12497
12811
  * edges instead of the page viewport's, on both axes (the Y axis otherwise has no such
12498
12812
  * clamp at all — see the clamp's own comment) — that part *is* gated on `hasValidAnchor`,
12499
12813
  * unlike the coordinate-space conversion itself.
12500
- * @returns {{ hasValidAnchor, shouldTransition, positionX, positionY, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceLeft, spaceRight, spaceAbove, spaceBelow }}
12814
+ * @returns {{ hasValidAnchor, shouldTransition, positionX, positionY, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceLeft, spaceRight, spaceAbove, spaceBelow, containerWidthAvailable, containerHeightAvailable }}
12501
12815
  */
12502
12816
  const pickPositionRelativeTo = (
12503
12817
  element,
@@ -13010,22 +13324,34 @@ const pickPositionRelativeTo = (
13010
13324
  // so the usable space includes the anchor dimension.
13011
13325
  // marginWithAnchor (gap between anchor and element) and marginWithContainer are subtracted
13012
13326
  // so callers get the net usable space directly.
13013
- const effectiveSpaceAbove =
13014
- (finalY === "inset-bottom" ? spaceAbove + anchorHeight : spaceAbove) -
13015
- (finalY === "top" ? marginWithAnchor : 0) -
13016
- marginWithContainer;
13017
- const effectiveSpaceBelow =
13018
- (finalY === "inset-top" ? spaceBelow + anchorHeight : spaceBelow) -
13019
- (finalY === "bottom" ? marginWithAnchor : 0) -
13020
- marginWithContainer;
13021
- const effectiveSpaceLeft =
13022
- (finalX === "inset-right" ? spaceLeft + anchorWidth : spaceLeft) -
13023
- (finalX === "left" ? marginWithAnchor : 0) -
13024
- marginWithContainer;
13025
- const effectiveSpaceRight =
13026
- (finalX === "inset-left" ? spaceRight + anchorWidth : spaceRight) -
13027
- (finalX === "right" ? marginWithAnchor : 0) -
13028
- marginWithContainer;
13327
+ const containerWidthAvailable = availableWidth - 2 * marginWithContainer;
13328
+ const containerHeightAvailable = availableHeight - 2 * marginWithContainer;
13329
+ // Docked to a container (no real anchor): the element is kept inside the
13330
+ // container's margin on BOTH sides — that is what the !hasValidAnchor clamp
13331
+ // above enforces — so what it has to work with is the container net of both.
13332
+ // The anchor-relative formulas below count the margin once, which is right
13333
+ // when the space really is bounded by the anchor on the other side, and
13334
+ // wrong here: it would let the far edge grow flush against the container.
13335
+ const effectiveSpaceAbove = !hasValidAnchor
13336
+ ? containerHeightAvailable
13337
+ : (finalY === "inset-bottom" ? spaceAbove + anchorHeight : spaceAbove) -
13338
+ (finalY === "top" ? marginWithAnchor : 0) -
13339
+ marginWithContainer;
13340
+ const effectiveSpaceBelow = !hasValidAnchor
13341
+ ? containerHeightAvailable
13342
+ : (finalY === "inset-top" ? spaceBelow + anchorHeight : spaceBelow) -
13343
+ (finalY === "bottom" ? marginWithAnchor : 0) -
13344
+ marginWithContainer;
13345
+ const effectiveSpaceLeft = !hasValidAnchor
13346
+ ? containerWidthAvailable
13347
+ : (finalX === "inset-right" ? spaceLeft + anchorWidth : spaceLeft) -
13348
+ (finalX === "left" ? marginWithAnchor : 0) -
13349
+ marginWithContainer;
13350
+ const effectiveSpaceRight = !hasValidAnchor
13351
+ ? containerWidthAvailable
13352
+ : (finalX === "inset-left" ? spaceRight + anchorWidth : spaceRight) -
13353
+ (finalX === "right" ? marginWithAnchor : 0) -
13354
+ marginWithContainer;
13029
13355
 
13030
13356
  return {
13031
13357
  // Whether a real anchor actually ended up used — false when there's no
@@ -13048,6 +13374,12 @@ const pickPositionRelativeTo = (
13048
13374
  spaceRight: effectiveSpaceRight,
13049
13375
  spaceAbove: effectiveSpaceAbove,
13050
13376
  spaceBelow: effectiveSpaceBelow,
13377
+ // What a centered axis has to work with: the whole container, net of the
13378
+ // margin kept on both sides. spaceLeft/spaceRight can't answer that — they
13379
+ // are measured from the anchor, which for a container-docked element is
13380
+ // the container itself, so they collapse to -marginWithContainer.
13381
+ containerWidthAvailable,
13382
+ containerHeightAvailable,
13051
13383
  };
13052
13384
  };
13053
13385
 
@@ -13168,8 +13500,16 @@ const applyNewPosition = (
13168
13500
  spaceRight,
13169
13501
  spaceAbove,
13170
13502
  spaceBelow,
13503
+ containerWidthAvailable,
13504
+ containerHeightAvailable,
13171
13505
  },
13172
13506
  ) => {
13507
+ // A centered axis is published too, from the container's own extent: leaving
13508
+ // the property unset lets the consumer's size cap fall back to its viewport
13509
+ // default, which overflows any container smaller than the viewport (a
13510
+ // dialog/popover confined to a positioned ancestor). It stays a "remaining
13511
+ // space" either way — docked: what is left on that side, centered: the whole
13512
+ // container minus its margins.
13173
13513
  if (positionY === "top" || positionY === "inset-bottom") {
13174
13514
  element.style.setProperty(
13175
13515
  "--container-position-remaining-height",
@@ -13180,8 +13520,13 @@ const applyNewPosition = (
13180
13520
  "--container-position-remaining-height",
13181
13521
  `${spaceBelow}px`,
13182
13522
  );
13183
- } else {
13523
+ } else if (containerHeightAvailable === undefined) {
13184
13524
  element.style.removeProperty("--container-position-remaining-height");
13525
+ } else {
13526
+ element.style.setProperty(
13527
+ "--container-position-remaining-height",
13528
+ `${containerHeightAvailable}px`,
13529
+ );
13185
13530
  }
13186
13531
  if (positionX === "left" || positionX === "inset-right") {
13187
13532
  element.style.setProperty(
@@ -13193,8 +13538,13 @@ const applyNewPosition = (
13193
13538
  "--container-position-remaining-width",
13194
13539
  `${spaceRight}px`,
13195
13540
  );
13196
- } else {
13541
+ } else if (containerWidthAvailable === undefined) {
13197
13542
  element.style.removeProperty("--container-position-remaining-width");
13543
+ } else {
13544
+ element.style.setProperty(
13545
+ "--container-position-remaining-width",
13546
+ `${containerWidthAvailable}px`,
13547
+ );
13198
13548
  }
13199
13549
 
13200
13550
  // A single implicit keyframe turned out not to work here: the WAAPI
@@ -16484,4 +16834,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
16484
16834
  };
16485
16835
  };
16486
16836
 
16487
- export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, applyNewPosition, captureScrollState, chainEvent, closestOpenableAncestor, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, findSelfOrAncestorFixedPosition, formatEventSideEffect, getAncestorOpenType, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPositioningScrollOffset, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isAncestorOpen, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, observeAncestorOpenState, onAncestorReopen, parsePositionArea, parseStyle, performTabNavigation, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, subscribeVisualViewportResizeSettled, subscribeWindowResizeSettled, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
16837
+ export { EASING, ELEMENT_SIZE_CHANGE, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, applyNewPosition, canScroll, captureScrollState, chainEvent, closestOpenableAncestor, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, findSelfOrAncestorFixedPosition, formatEventSideEffect, getAncestorOpenType, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPositioningScrollOffset, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isAncestorOpen, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, observeAncestorOpenState, onAncestorReopen, parsePositionArea, parseStyle, performTabNavigation, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, subscribeVisualViewportResizeSettled, subscribeWindowResizeSettled, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {