@jsenv/dom 0.17.90 → 0.17.91

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 +365 -34
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -4987,7 +4987,9 @@ const getFocusVisibilityInfo = (node, { excludeAriaHidden } = {}) => {
4987
4987
  if (
4988
4988
  elementIsDialog(nodeOrAncestor) &&
4989
4989
  !nodeOrAncestor.open &&
4990
- !isOpenPopover
4990
+ !isOpenPopover &&
4991
+ // A closed PopupGroup is displayed in the page (see ancestor_open.js).
4992
+ !nodeOrAncestor.hasAttribute("navi-popup-group")
4991
4993
  ) {
4992
4994
  return { visible: false, reason: "inside closed dialog element" };
4993
4995
  }
@@ -5102,6 +5104,11 @@ const elementIsFocusable = (node, { excludeAriaHidden } = {}) => {
5102
5104
  if (node.hasAttribute("navi-focus-delegate")) {
5103
5105
  return false;
5104
5106
  }
5107
+ // A <dialog> because it is shown modal while one of its popups is open, not
5108
+ // a surface of its own to focus (see ancestor_open.js).
5109
+ if (node.hasAttribute("navi-popup-group")) {
5110
+ return false;
5111
+ }
5105
5112
  if (!canInteract(node)) {
5106
5113
  return false;
5107
5114
  }
@@ -5516,6 +5523,20 @@ const getKeyboardEventDefaultAction = (keyboardEvent) => {
5516
5523
  return "";
5517
5524
  };
5518
5525
 
5526
+ // Whether the keyboard follows macOS's conventions: there Home and End scroll
5527
+ // the page even from a text field, and a chord with Cmd or Option scrolls it to
5528
+ // an end or a page away.
5529
+ let isMac;
5530
+ const isMacPlatform = () => {
5531
+ if (isMac === undefined) {
5532
+ const { userAgentData, userAgent } = window.navigator;
5533
+ isMac = userAgentData
5534
+ ? userAgentData.platform === "macOS"
5535
+ : /Mac|iPhone|iPad|iPod/.test(userAgent);
5536
+ }
5537
+ return isMac;
5538
+ };
5539
+
5519
5540
  const isTypingIntent = (e) => {
5520
5541
  // Modifier keys used for shortcuts: skip
5521
5542
  if (e.metaKey || e.ctrlKey) {
@@ -5557,10 +5578,11 @@ const DEFAULT_BEHAVIORS = [
5557
5578
  return "activate";
5558
5579
  },
5559
5580
  enter: (e) => (e.target.form ? "form_submit" : ""),
5560
- arrowleft: "focus_nav",
5561
- arrowright: "focus_nav",
5562
- arrowup: "focus_nav",
5563
- arrowdown: "focus_nav",
5581
+ // Moving through a radio group; a checkbox lets them scroll.
5582
+ arrowleft: (e) => (e.target.type === "radio" ? "focus_nav" : undefined),
5583
+ arrowright: (e) => (e.target.type === "radio" ? "focus_nav" : undefined),
5584
+ arrowup: (e) => (e.target.type === "radio" ? "focus_nav" : undefined),
5585
+ arrowdown: (e) => (e.target.type === "radio" ? "focus_nav" : undefined),
5564
5586
  },
5565
5587
  },
5566
5588
  {
@@ -5583,8 +5605,10 @@ const DEFAULT_BEHAVIORS = [
5583
5605
  arrowright: (e) => (e.target.readOnly ? "scroll" : "cursor_move"),
5584
5606
  arrowup: (e) => (e.target.readOnly ? "scroll" : "cursor_move"),
5585
5607
  arrowdown: (e) => (e.target.readOnly ? "scroll" : "cursor_move"),
5586
- home: (e) => (e.target.readOnly ? "scroll" : "cursor_move"),
5587
- end: (e) => (e.target.readOnly ? "scroll" : "cursor_move"),
5608
+ home: (e) =>
5609
+ e.target.readOnly || isMacPlatform() ? "scroll" : "cursor_move",
5610
+ end: (e) =>
5611
+ e.target.readOnly || isMacPlatform() ? "scroll" : "cursor_move",
5588
5612
  },
5589
5613
  fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
5590
5614
  },
@@ -5669,13 +5693,17 @@ const DEFAULT_BEHAVIORS = [
5669
5693
  fallback: (e) => (isTypingIntent(e) ? "type" : undefined),
5670
5694
  },
5671
5695
  {
5672
- // Buttons and links: Space/Enter trigger the element's default action
5696
+ // Buttons and links: Enter triggers the element's default action, Space
5697
+ // only a button's — on a link it scrolls the page.
5673
5698
  test: (el) =>
5674
5699
  el.tagName === "BUTTON" ||
5675
5700
  el.tagName === "A" ||
5676
5701
  el.getAttribute("role") === "button",
5677
5702
  keys: {
5678
- space: "activate",
5703
+ space: (e) =>
5704
+ e.target.tagName === "A" && e.target.getAttribute("role") !== "button"
5705
+ ? undefined
5706
+ : "activate",
5679
5707
  enter: "activate",
5680
5708
  },
5681
5709
  },
@@ -7204,6 +7232,10 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
7204
7232
  return null;
7205
7233
  }
7206
7234
  if (isScrollBoundary(element)) {
7235
+ const placeholder = findEngagedPopupGroupPlaceholder(element);
7236
+ if (placeholder) {
7237
+ return getScrollContainer(placeholder, { includeHidden });
7238
+ }
7207
7239
  return getScrollingElement(element.ownerDocument);
7208
7240
  }
7209
7241
  return (
@@ -7234,12 +7266,18 @@ const findScrollContainer = (element, { includeHidden } = {}) => {
7234
7266
  if (parent === document) {
7235
7267
  return null;
7236
7268
  }
7237
- if (isScrollBoundary(parent)) {
7238
- return null;
7239
- }
7269
+ // A boundary that scrolls is the scroll container of what it holds — a
7270
+ // popup whose own box scrolls, a PopupGroup shown modal where it stands.
7240
7271
  if (isScrollable(parent, { includeHidden })) {
7241
7272
  return parent;
7242
7273
  }
7274
+ if (isScrollBoundary(parent)) {
7275
+ const placeholder = findEngagedPopupGroupPlaceholder(parent);
7276
+ if (placeholder) {
7277
+ return findScrollContainer(placeholder, { includeHidden });
7278
+ }
7279
+ return null;
7280
+ }
7243
7281
  parent = parent.parentNode;
7244
7282
  }
7245
7283
  return null;
@@ -7263,6 +7301,29 @@ const isScrollBoundary = (node) => {
7263
7301
  return getStyle(node, "position") === "fixed";
7264
7302
  };
7265
7303
 
7304
+ // Except a PopupGroup shown modal (@jsenv/navi's popup_group.jsx): fixed in the
7305
+ // top layer at the place a placeholder holds for it in the page, it follows
7306
+ // that place, so what scrolls the placeholder moves the group and what it
7307
+ // holds. The placeholder is inserted just before it; something rendered
7308
+ // before the group meanwhile lands in between.
7309
+ const findEngagedPopupGroupPlaceholder = (node) => {
7310
+ if (
7311
+ node.tagName !== "DIALOG" ||
7312
+ !node.hasAttribute("navi-popup-group") ||
7313
+ !node.matches(":modal")
7314
+ ) {
7315
+ return null;
7316
+ }
7317
+ let previous = node.previousElementSibling;
7318
+ while (previous) {
7319
+ if (previous.hasAttribute("navi-popup-group-placeholder")) {
7320
+ return previous;
7321
+ }
7322
+ previous = previous.previousElementSibling;
7323
+ }
7324
+ return null;
7325
+ };
7326
+
7266
7327
  const getSelfAndAncestorScrolls = (element, startOnParent) => {
7267
7328
  let scrollX = 0;
7268
7329
  let scrollY = 0;
@@ -8197,18 +8258,23 @@ const TOP_LAYER_SELECTOR$1 = [
8197
8258
  ":fullscreen",
8198
8259
  ].join(",");
8199
8260
 
8200
- // The keys that scroll the document when nothing focusable holds them.
8201
- const SCROLL_KEY_SET = new Set([
8202
- "ArrowUp",
8203
- "ArrowDown",
8204
- "ArrowLeft",
8205
- "ArrowRight",
8206
- "PageUp",
8207
- "PageDown",
8208
- "Home",
8209
- "End",
8210
- " ",
8261
+ // The keys that scroll the document when nothing focusable holds them, and
8262
+ // the way each one goes, as a wheel would: [deltaX, deltaY].
8263
+ const SCROLL_KEY_DELTA_MAP = new Map([
8264
+ ["ArrowUp", [0, -1]],
8265
+ ["ArrowDown", [0, 1]],
8266
+ ["ArrowLeft", [-1, 0]],
8267
+ ["ArrowRight", [1, 0]],
8268
+ ["PageUp", [0, -1]],
8269
+ ["PageDown", [0, 1]],
8270
+ ["Home", [0, -1]],
8271
+ ["End", [0, 1]],
8272
+ [" ", [0, 1]],
8211
8273
  ]);
8274
+ // What a scroll key does by itself on these, whatever it would scroll: a
8275
+ // closed <select> changes its value with the arrows, a media element's
8276
+ // controls seek with them.
8277
+ const KEY_OWNER_SELECTOR = "select, audio, video";
8212
8278
 
8213
8279
  /**
8214
8280
  * Stops the background from scrolling by cancelling the scroll gestures that
@@ -8242,14 +8308,33 @@ const SCROLL_KEY_SET = new Set([
8242
8308
  * background, when it is one of ours (a modal dialog covers it with its own
8243
8309
  * `::backdrop` and there is nothing to pass). It is in the top layer like the
8244
8310
  * overlay itself, yet a gesture on it aims at the background behind it.
8311
+ * @param {() => {left: number, top: number, right: number, bottom: number}} [options.getElementBox]
8312
+ * - Where `element` is on screen, when that is less than its border box (an
8313
+ * overlay clipped by a clip-path): a gesture outside that box but reported on
8314
+ * `element` landed on its `::backdrop`.
8315
+ * @param {boolean} [options.containInside] - Also cancel a gesture landing on
8316
+ * or inside `element` that nothing between it and `element` can scroll in
8317
+ * its direction, and a scroll key pressed there the same way. For an overlay
8318
+ * that is not a scroll container itself (a box of the page shown in the top
8319
+ * layer where it stood): its scroll chain goes straight to the viewport, and
8320
+ * no `overscroll-behavior` of its own can stop it there. Decided once what
8321
+ * the gesture landed on has had its say: what takes it from a listener of
8322
+ * its own (a control moving by transform, a drag) cancels it there, and a
8323
+ * gesture cancelled before it gets there is one it refuses.
8245
8324
  * @returns {() => void} Cleanup function removing the listeners.
8246
8325
  */
8247
8326
 
8248
8327
  const trapScrollGestureInside = (
8249
8328
  element,
8250
- { boundaryElement, backdropElement } = {},
8329
+ {
8330
+ boundaryElement,
8331
+ backdropElement,
8332
+ getElementBox = () => element.getBoundingClientRect(),
8333
+ containInside = false,
8334
+ } = {},
8251
8335
  ) => {
8252
8336
  const { ownerDocument } = element;
8337
+ const { defaultView } = ownerDocument;
8253
8338
  const lockedRegion = boundaryElement || ownerDocument.documentElement;
8254
8339
 
8255
8340
  const isBackgroundGesture = (target, clientX, clientY) => {
@@ -8264,7 +8349,7 @@ const trapScrollGestureInside = (
8264
8349
  if (target !== element) {
8265
8350
  return false;
8266
8351
  }
8267
- const { left, right, top, bottom } = element.getBoundingClientRect();
8352
+ const { left, right, top, bottom } = getElementBox();
8268
8353
  return (
8269
8354
  clientX < left || clientX > right || clientY < top || clientY > bottom
8270
8355
  );
@@ -8282,6 +8367,42 @@ const trapScrollGestureInside = (
8282
8367
  return true;
8283
8368
  };
8284
8369
 
8370
+ // Whether something on the event's way from what it landed on up to
8371
+ // `element` scrolls the way the gesture mostly goes, with room left to go
8372
+ // there. The composed path, not the parents: a scroller inside a shadow
8373
+ // root, or the shadow scroller a slotted element shows in, is on it. A box
8374
+ // in the top layer, or fixed, ends the walk: its scroll chain goes on to the
8375
+ // viewport, not to its parent in the DOM.
8376
+ const isTakenInside = (event, deltaX, deltaY) => {
8377
+ const axis = Math.abs(deltaY) >= Math.abs(deltaX) ? "y" : "x";
8378
+ const delta = axis === "y" ? deltaY : deltaX;
8379
+ for (const node of event.composedPath()) {
8380
+ if (node.nodeType !== 1) {
8381
+ continue;
8382
+ }
8383
+ if (canScroll(node, axis) && hasRoomToScroll(node, axis, delta)) {
8384
+ return true;
8385
+ }
8386
+ if (node === element) {
8387
+ return false;
8388
+ }
8389
+ if (
8390
+ node.matches(TOP_LAYER_SELECTOR$1) ||
8391
+ getComputedStyle(node).position === "fixed"
8392
+ ) {
8393
+ return false;
8394
+ }
8395
+ }
8396
+ return false;
8397
+ };
8398
+ // On `element` itself too, inside its box: on its padding, between what it
8399
+ // holds. Outside that box it is the wall, decided first (isBackgroundGesture).
8400
+ const leaksFromInside = (event, deltaX, deltaY) =>
8401
+ // A move with no direction scrolls nothing.
8402
+ (deltaX !== 0 || deltaY !== 0) &&
8403
+ element.contains(event.target) &&
8404
+ !isTakenInside(event, deltaX, deltaY);
8405
+
8285
8406
  const onWheel = (wheelEvent) => {
8286
8407
  if (wheelEvent.ctrlKey) {
8287
8408
  // Browser zoom, not a scroll — cancelling it would take zoom away from
@@ -8298,25 +8419,71 @@ const trapScrollGestureInside = (
8298
8419
  wheelEvent.preventDefault();
8299
8420
  }
8300
8421
  };
8422
+ const onWheelInside = (wheelEvent) => {
8423
+ if (
8424
+ wheelEvent.ctrlKey ||
8425
+ wheelEvent.defaultPrevented ||
8426
+ !wheelEvent.cancelable
8427
+ ) {
8428
+ return;
8429
+ }
8430
+ if (leaksFromInside(wheelEvent, wheelEvent.deltaX, wheelEvent.deltaY)) {
8431
+ wheelEvent.preventDefault();
8432
+ }
8433
+ };
8434
+ // Where the finger was at the previous touch event: a touchmove carries no
8435
+ // delta of its own. Read in the capture phase, which every touchmove goes
8436
+ // through, for the decision taken once it has been through its target.
8437
+ let previousTouch = null;
8438
+ let touchMoveDelta = null;
8439
+ const onTouchStart = (touchStartEvent) => {
8440
+ const { touches } = touchStartEvent;
8441
+ previousTouch =
8442
+ touches.length === 1
8443
+ ? { x: touches[0].clientX, y: touches[0].clientY }
8444
+ : null;
8445
+ };
8301
8446
  const onTouchMove = (touchMoveEvent) => {
8302
8447
  const { touches } = touchMoveEvent;
8303
8448
  if (touches.length !== 1) {
8304
8449
  return; // pinch
8305
8450
  }
8306
8451
  const [touch] = touches;
8452
+ // The content moves against the finger.
8453
+ touchMoveDelta = {
8454
+ touchMoveEvent,
8455
+ deltaX: previousTouch ? previousTouch.x - touch.clientX : 0,
8456
+ deltaY: previousTouch ? previousTouch.y - touch.clientY : 0,
8457
+ };
8458
+ previousTouch = { x: touch.clientX, y: touch.clientY };
8307
8459
  if (
8308
8460
  isBackgroundGesture(touchMoveEvent.target, touch.clientX, touch.clientY)
8309
8461
  ) {
8310
8462
  touchMoveEvent.preventDefault();
8311
8463
  }
8312
8464
  };
8465
+ const onTouchMoveInside = (touchMoveEvent) => {
8466
+ if (touchMoveDelta?.touchMoveEvent !== touchMoveEvent) {
8467
+ return; // pinch
8468
+ }
8469
+ const { deltaX, deltaY } = touchMoveDelta;
8470
+ touchMoveDelta = null;
8471
+ if (touchMoveEvent.defaultPrevented || !touchMoveEvent.cancelable) {
8472
+ return;
8473
+ }
8474
+ if (leaksFromInside(touchMoveEvent, deltaX, deltaY)) {
8475
+ touchMoveEvent.preventDefault();
8476
+ }
8477
+ };
8313
8478
  const onKeyDown = (keydownEvent) => {
8314
- if (!SCROLL_KEY_SET.has(keydownEvent.key)) {
8479
+ if (!SCROLL_KEY_DELTA_MAP.has(keydownEvent.key)) {
8315
8480
  return;
8316
8481
  }
8317
8482
  const { target } = keydownEvent;
8318
8483
  // Anything else focused (a field, a scrollable box, a button) owns the key
8319
- // press; only a document with nothing focused scrolls on it.
8484
+ // press; only a document with nothing focused scrolls on it. Inside
8485
+ // `element`, with containInside, what really owns it is asked
8486
+ // (onKeyDownInside).
8320
8487
  if (
8321
8488
  target !== ownerDocument.body &&
8322
8489
  target !== ownerDocument.documentElement
@@ -8327,6 +8494,37 @@ const trapScrollGestureInside = (
8327
8494
  keydownEvent.preventDefault();
8328
8495
  }
8329
8496
  };
8497
+ // A key whose default would scroll, pressed where nothing between there and
8498
+ // `element` scrolls that way.
8499
+ const onKeyDownInside = (keydownEvent) => {
8500
+ if (keydownEvent.defaultPrevented) {
8501
+ return;
8502
+ }
8503
+ const scrollDelta = readKeyScrollDelta(keydownEvent);
8504
+ if (!scrollDelta) {
8505
+ return;
8506
+ }
8507
+ const { target } = keydownEvent;
8508
+ if (!element.contains(target)) {
8509
+ return;
8510
+ }
8511
+ if (keydownEvent.composedPath()[0] !== target) {
8512
+ // Pressed in a shadow tree, whose host the target is: what the key does
8513
+ // in there is not known from here.
8514
+ return;
8515
+ }
8516
+ if (target.matches(KEY_OWNER_SELECTOR)) {
8517
+ return;
8518
+ }
8519
+ const defaultAction = getKeyboardEventDefaultAction(keydownEvent);
8520
+ if (defaultAction !== "scroll" && defaultAction !== "scroll_self") {
8521
+ return;
8522
+ }
8523
+ const [deltaX, deltaY] = scrollDelta;
8524
+ if (!isTakenInside(keydownEvent, deltaX, deltaY)) {
8525
+ keydownEvent.preventDefault();
8526
+ }
8527
+ };
8330
8528
 
8331
8529
  // Capture phase: the gesture must be cancelled before whatever it landed on
8332
8530
  // gets a chance to act on it. passive: false is what makes preventDefault
@@ -8335,7 +8533,24 @@ const trapScrollGestureInside = (
8335
8533
  ownerDocument.addEventListener("wheel", onWheel, listenerOptions);
8336
8534
  ownerDocument.addEventListener("touchmove", onTouchMove, listenerOptions);
8337
8535
  ownerDocument.addEventListener("keydown", onKeyDown, listenerOptions);
8536
+ if (containInside) {
8537
+ ownerDocument.addEventListener("touchstart", onTouchStart, {
8538
+ capture: true,
8539
+ passive: true,
8540
+ });
8541
+ // On the window, after every listener on the way: what is inside has had
8542
+ // its say. passive: false said out loud — a wheel or touch listener on the
8543
+ // window is passive unless told otherwise.
8544
+ defaultView.addEventListener("wheel", onWheelInside, { passive: false });
8545
+ defaultView.addEventListener("touchmove", onTouchMoveInside, {
8546
+ passive: false,
8547
+ });
8548
+ defaultView.addEventListener("keydown", onKeyDownInside);
8549
+ }
8338
8550
  return () => {
8551
+ ownerDocument.removeEventListener("touchstart", onTouchStart, {
8552
+ capture: true,
8553
+ });
8339
8554
  ownerDocument.removeEventListener("wheel", onWheel, listenerOptions);
8340
8555
  ownerDocument.removeEventListener(
8341
8556
  "touchmove",
@@ -8343,9 +8558,97 @@ const trapScrollGestureInside = (
8343
8558
  listenerOptions,
8344
8559
  );
8345
8560
  ownerDocument.removeEventListener("keydown", onKeyDown, listenerOptions);
8561
+ defaultView.removeEventListener("wheel", onWheelInside);
8562
+ defaultView.removeEventListener("touchmove", onTouchMoveInside);
8563
+ defaultView.removeEventListener("keydown", onKeyDownInside);
8346
8564
  };
8347
8565
  };
8348
8566
 
8567
+ // Whether `scroller` has room left on `axis` the way `delta` goes, with a
8568
+ // pixel of slack against rounded sizes. Its scroll position counts from its
8569
+ // scroll origin, which is on the right of a right-to-left box and at the bottom
8570
+ // of some vertical writing modes: from there it reads 0, then negative.
8571
+ const hasRoomToScroll = (scroller, axis, delta) => {
8572
+ const range =
8573
+ axis === "y"
8574
+ ? scroller.scrollHeight - scroller.clientHeight
8575
+ : scroller.scrollWidth - scroller.clientWidth;
8576
+ const position = axis === "y" ? scroller.scrollTop : scroller.scrollLeft;
8577
+ // From the top, or the left.
8578
+ const fromStart = isScrollOriginAtTheEnd(getComputedStyle(scroller), axis)
8579
+ ? range + position
8580
+ : position;
8581
+ return delta < 0 ? fromStart > 1 : fromStart < range - 1;
8582
+ };
8583
+ const isScrollOriginAtTheEnd = (
8584
+ { writingMode, direction, display, flexDirection, flexWrap },
8585
+ axis,
8586
+ ) => {
8587
+ const vertical =
8588
+ writingMode.startsWith("vertical") || writingMode.startsWith("sideways");
8589
+ let atTheEnd;
8590
+ if (!vertical) {
8591
+ atTheEnd = axis === "x" && direction === "rtl";
8592
+ } else if (axis === "x") {
8593
+ // Lines stacked from the right.
8594
+ atTheEnd = writingMode.endsWith("-rl");
8595
+ } else {
8596
+ // sideways-lr runs its text bottom to top.
8597
+ atTheEnd =
8598
+ writingMode === "sideways-lr" ? direction !== "rtl" : direction === "rtl";
8599
+ }
8600
+ // A reversed flex container starts at the other end of that axis: its main
8601
+ // axis for a reversed direction (a log resting on its newest item), its
8602
+ // cross axis for wrap-reverse.
8603
+ if (display === "flex" || display === "inline-flex") {
8604
+ const inlineAxis = vertical ? "y" : "x";
8605
+ const blockAxis = vertical ? "x" : "y";
8606
+ const mainAxis = flexDirection.startsWith("row") ? inlineAxis : blockAxis;
8607
+ const reversed =
8608
+ axis === mainAxis
8609
+ ? flexDirection.endsWith("-reverse")
8610
+ : flexWrap === "wrap-reverse";
8611
+ if (reversed) {
8612
+ atTheEnd = !atTheEnd;
8613
+ }
8614
+ }
8615
+ return atTheEnd;
8616
+ };
8617
+
8618
+ // The way a key scrolls, as a wheel would: [deltaX, deltaY], or null for a key
8619
+ // that scrolls nothing. With a modifier, only the platform's own chords to the
8620
+ // top, the bottom or a page away; never the arrows sideways, which go back and
8621
+ // forward in the history.
8622
+ const readKeyScrollDelta = (keydownEvent) => {
8623
+ const { key, altKey, ctrlKey, metaKey, shiftKey } = keydownEvent;
8624
+ if (!altKey && !ctrlKey && !metaKey) {
8625
+ if (key === " " && shiftKey) {
8626
+ return [0, -1];
8627
+ }
8628
+ return SCROLL_KEY_DELTA_MAP.get(key) || null;
8629
+ }
8630
+ if (isMacPlatform()) {
8631
+ if (!ctrlKey && (metaKey || altKey)) {
8632
+ if (key === "ArrowUp") {
8633
+ return [0, -1];
8634
+ }
8635
+ if (key === "ArrowDown") {
8636
+ return [0, 1];
8637
+ }
8638
+ }
8639
+ return null;
8640
+ }
8641
+ if (ctrlKey && !altKey && !metaKey) {
8642
+ if (key === "Home") {
8643
+ return [0, -1];
8644
+ }
8645
+ if (key === "End") {
8646
+ return [0, 1];
8647
+ }
8648
+ }
8649
+ return null;
8650
+ };
8651
+
8349
8652
  // "gesture": cancel the scroll gestures aimed at the background, keeping it
8350
8653
  // scrollable (see scroll_gesture_trap.js) — only possible when a backdrop
8351
8654
  // covers it.
@@ -8395,16 +8698,24 @@ const SCROLL_LOCK_STRATEGY = "gesture";
8395
8698
  * while the overlay is open: the backdrop element, or `true` when the browser
8396
8699
  * paints it itself (a modal dialog's `::backdrop`). Enables the gesture
8397
8700
  * strategy described above.
8701
+ * @param {Function} [options.getElementBox] - Forwarded to
8702
+ * trapScrollGestureInside: where `element` is on screen when that is less
8703
+ * than its border box.
8704
+ * @param {boolean} [options.containInside] - Forwarded to
8705
+ * trapScrollGestureInside: a gesture or a scroll key inside `element` that
8706
+ * nothing in it can scroll does not reach the background either.
8398
8707
  * @returns {() => void} Cleanup function that restores all modified styles.
8399
8708
  */
8400
8709
  const trapScrollInside = (
8401
8710
  element,
8402
- { boundaryElement, backdrop } = {},
8711
+ { boundaryElement, backdrop, getElementBox, containInside } = {},
8403
8712
  ) => {
8404
8713
  if (backdrop && SCROLL_LOCK_STRATEGY === "gesture") {
8405
8714
  return trapScrollGestureInside(element, {
8406
8715
  boundaryElement,
8407
8716
  backdropElement: backdrop === true ? null : backdrop,
8717
+ getElementBox,
8718
+ containInside,
8408
8719
  });
8409
8720
  }
8410
8721
 
@@ -10047,11 +10358,20 @@ const css$5 = /* css */ `[data-drag-handle], [data-drag-source] {
10047
10358
  user-select: none;
10048
10359
  }
10049
10360
 
10050
- [data-drag-on-contact] [data-drag-ignore], [data-drag-on-contact] [popover], [data-drag-on-contact] dialog, [data-drag-on-contact] :is(input:not([data-press-only]), textarea), [data-drag-on-contact] :is([contenteditable=""], [contenteditable="true"]) {
10361
+ [data-drag-on-contact] [data-drag-ignore], [data-drag-on-contact] [popover], [data-drag-on-contact] :is(input:not([data-press-only]), textarea), [data-drag-on-contact] :is([contenteditable=""], [contenteditable="true"]) {
10051
10362
  user-select: text;
10052
10363
  }
10053
10364
 
10054
- [data-drag-ignore], [data-self-interactions~="drag"], [data-self-interactions~="*"], [data-drag-source] [popover], [data-drag-source] dialog {
10365
+ [data-drag-on-contact] dialog:not([navi-popup-group]:not(:modal)) {
10366
+ user-select: text;
10367
+ }
10368
+
10369
+ [data-drag-ignore], [data-self-interactions~="drag"], [data-self-interactions~="*"], [data-drag-source] [popover] {
10370
+ -webkit-touch-callout: default;
10371
+ touch-action: auto;
10372
+ }
10373
+
10374
+ [data-drag-source] dialog:not([navi-popup-group]:not(:modal)) {
10055
10375
  -webkit-touch-callout: default;
10056
10376
  touch-action: auto;
10057
10377
  }
@@ -12612,7 +12932,8 @@ const DRAG_EXCLUDED_SELECTOR = [
12612
12932
  '[data-self-interactions~="drag"]',
12613
12933
  '[data-self-interactions~="*"]',
12614
12934
  "[popover]",
12615
- "dialog",
12935
+ // An idle PopupGroup is part of the page (see DRAG_IGNORED_SELECTOR).
12936
+ "dialog:not([navi-popup-group]:not(:modal))",
12616
12937
  ].join(",");
12617
12938
 
12618
12939
  // Which axes a box travels on, one attribute per gesture, said in the DOM by
@@ -14379,7 +14700,11 @@ const css$1 = /* css */ `.navi_drop_hint {
14379
14700
  cursor: default;
14380
14701
  }
14381
14702
 
14382
- [data-drag-ignore], [data-self-interactions~="drag"], [data-self-interactions~="*"], [data-drag-source] [popover], [data-drag-source] dialog {
14703
+ [data-drag-ignore], [data-self-interactions~="drag"], [data-self-interactions~="*"], [data-drag-source] [popover] {
14704
+ cursor: auto;
14705
+ }
14706
+
14707
+ [data-drag-source] dialog:not([navi-popup-group]:not(:modal)) {
14383
14708
  cursor: auto;
14384
14709
  }
14385
14710
 
@@ -14467,8 +14792,10 @@ import.meta.css = [css$1, "@jsenv/dom/src/interaction/drag/drag_to.js"];
14467
14792
  // stays a descendant of whatever it is anchored in (a callout next to a button,
14468
14793
  // in the card that carries both), and the press bubbles through the surface as
14469
14794
  // if it had landed on it.
14795
+ // An idle PopupGroup is a closed <dialog> that is part of the page, not a layer
14796
+ // over it (see @jsenv/navi's popup_group.jsx).
14470
14797
  const DRAG_IGNORED_SELECTOR =
14471
- '[data-drag-ignore],[data-self-interactions~="drag"],[data-self-interactions~="*"],[popover],dialog';
14798
+ '[data-drag-ignore],[data-self-interactions~="drag"],[data-self-interactions~="*"],[popover],dialog:not([navi-popup-group]:not(:modal))';
14472
14799
 
14473
14800
  /**
14474
14801
  * Creates a gesture controller that moves elements via drag.
@@ -16341,7 +16668,11 @@ const getResizeDirection = (element) => {
16341
16668
  // directions: hide when a container closes, recheck when it reopens) — the
16342
16669
  // selector/open-detection/timing primitives are identical for both, only
16343
16670
  // what each does with a transition differs.
16344
- const OPENABLE_SELECTOR = "dialog, details, [popover], [aria-expanded]";
16671
+ // Not a PopupGroup (@jsenv/navi's popup_group.jsx): a <dialog> that is a box
16672
+ // of the page, closed or shown modal where it stands. Its `open` says nothing
16673
+ // about whether what it holds is on screen.
16674
+ const OPENABLE_SELECTOR =
16675
+ "dialog:not([navi-popup-group]), details, [popover], [aria-expanded]";
16345
16676
 
16346
16677
  // An element that IS openable is closed in exactly the same way an element
16347
16678
  // inside one is — which matters for anything positioned against it, e.g. a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.17.90",
3
+ "version": "0.17.91",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {