@jsenv/navi 0.29.359 → 0.29.361

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.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, createInternalCustomEvent, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, getScrollContainer, watchWheelTravel, scrollRoomTowards, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, trapScrollInside, parsePositionArea, snapToPixel, trapFocusInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, createInternalCustomEvent, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, getScrollContainer, watchWheelTravel, scrollRoomTowards, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, trapScrollInside, parsePositionArea, snapToPixel, trapFocusInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { isValidElement, createContext, render, h, toChildArray, options, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
@@ -4635,9 +4635,12 @@ const findControlHost = (el) => {
4635
4635
  * `findControlHost` above answers for a control's own DOM (itself, or the native
4636
4636
  * element it wraps). This one is for something that is not part of a control but
4637
4637
  * has to reach one — an interaction declared on a box (see
4638
- * interaction/interactions.js), which may be the control, may hold it, or may sit
4639
- * inside it. Nearest wins in that order, and the answer is null when there is no
4640
- * control anywhere: not every box lives in one.
4638
+ * interaction/interactions.js), which may be the control, may sit inside it, or
4639
+ * may be a wrapper around exactly one. Nearest wins in that order, and the answer
4640
+ * is null when there is no control the box belongs to: not every box lives in
4641
+ * one, and a box laying out several controls (a row of badges, a toolbar) is
4642
+ * held by none of them — its interactions are its own, and a press on its empty
4643
+ * part is not a press on the first control it happens to contain.
4641
4644
  */
4642
4645
  const findNearestControlHost = (el) => {
4643
4646
  // Itself, then upwards — the box is inside a button, or is one.
@@ -4645,8 +4648,22 @@ const findNearestControlHost = (el) => {
4645
4648
  if (selfOrAncestor) {
4646
4649
  return selfOrAncestor;
4647
4650
  }
4648
- // …then downwards: the box holds the control rather than being held by it.
4649
- return el.querySelector("[navi-control-host]");
4651
+ // …then downwards, only when the box wraps a single control. Counted by
4652
+ // control, not by host: a picker holds the hosts of its popup content, and a
4653
+ // box around that one picker is still around one control.
4654
+ let single = null;
4655
+ for (const host of el.querySelectorAll("[navi-control-host]")) {
4656
+ const controlRoot = host.closest("[navi-control]") || host;
4657
+ const controlAbove = controlRoot.parentElement.closest("[navi-control]");
4658
+ if (controlAbove && el.contains(controlAbove)) {
4659
+ continue;
4660
+ }
4661
+ if (single && single !== controlRoot) {
4662
+ return null;
4663
+ }
4664
+ single = controlRoot;
4665
+ }
4666
+ return single ? findControlHost(single) : null;
4650
4667
  };
4651
4668
  const isControlRoot = (el) => {
4652
4669
  return el.hasAttribute("navi-control");
@@ -10957,9 +10974,10 @@ const interactionsDisputeThePress = (interactions) => {
10957
10974
  *
10958
10975
  * The control is not passed in: it is found from the element, which is what lets
10959
10976
  * `interactions` live on a Box rather than on the control itself. A Box that IS a
10960
- * control (a Button) is its own; a Box around one or inside one reaches it; a Box
10961
- * with no control anywhere near it can still answer with a callback of the
10962
- * caller's, and only "request_action" has nothing to ask.
10977
+ * control (a Button) is its own; a Box inside one, or wrapping exactly one,
10978
+ * reaches it; a Box with no control anywhere near it or laying out several,
10979
+ * which belongs to none of them still answers with a callback of the caller's,
10980
+ * and only "request_action" has nothing to ask.
10963
10981
  *
10964
10982
  * Set up once per element rather than on every render, which is what lets a
10965
10983
  * detector be a plain `setup`/teardown pair. So the interactions themselves are
@@ -11007,7 +11025,7 @@ const useInteractionsEffect = (ref, interactionsRef) => {
11007
11025
  if (!controlHost) {
11008
11026
  {
11009
11027
  console.warn(
11010
- `interactions: "${type}" asks for an action, but there is no control around it to ask. Put the interaction on a control (or on a box that holds one), or give it a callback.`,
11028
+ `interactions: "${type}" asks for an action, but there is no control around it to ask. Put the interaction on a control (or on a box that wraps exactly one), or give it a callback.`,
11011
11029
  );
11012
11030
  }
11013
11031
  return null;
@@ -49292,7 +49310,8 @@ const css$R = /* css */`.navi_expandable {
49292
49310
  }
49293
49311
 
49294
49312
  &:not([aria-expanded="true"])[data-settled] > .navi_expandable_content_container {
49295
- clip-path: inset(0);
49313
+ clip-path: none;
49314
+ overflow: clip;
49296
49315
  }
49297
49316
 
49298
49317
  &[data-content-scrolls] > .navi_expandable_content_container > .navi_expandable_content_sizer > .navi_expandable_content {
@@ -49326,6 +49345,7 @@ const useExpandableContext = partName => {
49326
49345
  * layout?: "row" | "column",
49327
49346
  * openDirection?: "down" | "up" | "right" | "left",
49328
49347
  * autoFocus?: boolean,
49348
+ * keepInView?: boolean,
49329
49349
  * maxContentHeight?: string | number,
49330
49350
  * mount?: "always" | "idle" | "from-first-open" | "while-opened",
49331
49351
  * arrowKeyShortcuts?: boolean,
@@ -49383,6 +49403,15 @@ const useExpandableContext = partName => {
49383
49403
  * came from. An expandable that mounts already open never takes it. Whatever
49384
49404
  * the setting, closing while the focus sits inside the content hands it back
49385
49405
  * to the UI part (it would otherwise be lost to the closed, inert content).
49406
+ * @param keepInView - Off by default. `true` scrolls the nearest scroller
49407
+ * that can scroll so the content revealed past its edge is on screen: an
49408
+ * expandable opening at the bottom of a list otherwise grows below the
49409
+ * fold, and the user sees the row grow but not what it grew with. Measured
49410
+ * on the open layout and done once per opening, alongside the reveal
49411
+ * (smoothly when `animation` plays, at once otherwise). An expandable
49412
+ * taller than the scroller keeps its UI part on screen and the content
49413
+ * follows as far as it fits. Only the nearest scroller moves — a popup or
49414
+ * a page around it stays where it is.
49386
49415
  * @param maxContentHeight - Caps the content height; taller content scrolls
49387
49416
  * inside the expandable instead of growing it.
49388
49417
  * @param mount - When the content is built and thrown away, a popup's own
@@ -49418,6 +49447,7 @@ const Expandable = props => {
49418
49447
  layout,
49419
49448
  openDirection,
49420
49449
  autoFocus,
49450
+ keepInView = false,
49421
49451
  maxContentHeight,
49422
49452
  mount = MOUNT_DEFAULT,
49423
49453
  arrowKeyShortcuts = true,
@@ -49435,6 +49465,9 @@ const Expandable = props => {
49435
49465
  const contentId = useId();
49436
49466
  const isColumn = layout === "column";
49437
49467
  const closedContentSized = isColumn && mount === "always";
49468
+ // Only the two directions the layout has room for: a column layout reveals
49469
+ // sideways, a stacked one vertically.
49470
+ const revealDirection = isColumn ? openDirection === "left" ? "left" : "right" : openDirection === "up" ? "up" : "down";
49438
49471
  const hasAction = Boolean(action);
49439
49472
  const effectiveAction = useAction(action);
49440
49473
  const {
@@ -49527,6 +49560,18 @@ const Expandable = props => {
49527
49560
  } else {
49528
49561
  contentElement.style.height = `${finalRect.height}px`;
49529
49562
  }
49563
+ if (keepInView) {
49564
+ // Here and not once the reveal settles: the layout is the open one at
49565
+ // this point, so where the content ends is known before the movement
49566
+ // starts, and the scroll can travel along with it. The frozen content
49567
+ // overflowing the collapsed track is still scrollable overflow (the
49568
+ // one-sided clip-path, see the CSS), so the scroller can reach that
49569
+ // position from the first frame.
49570
+ scrollExpandableIntoView(rootRef.current, {
49571
+ revealDirection,
49572
+ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"
49573
+ });
49574
+ }
49530
49575
  // Put the tracks back where the last paint left them and let the
49531
49576
  // transition play from there. In fr — px does not interpolate with fr.
49532
49577
  const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
@@ -49581,6 +49626,10 @@ const Expandable = props => {
49581
49626
  if (revealing) {
49582
49627
  armReveal(startRect);
49583
49628
  watchSettle();
49629
+ } else if (keepInView && !silent && contentContainer) {
49630
+ scrollExpandableIntoView(rootRef.current, {
49631
+ revealDirection
49632
+ });
49584
49633
  }
49585
49634
  if (!silent) {
49586
49635
  rootRef.current.dispatchEvent(createToggleEvent(true));
@@ -49761,9 +49810,6 @@ const Expandable = props => {
49761
49810
  }
49762
49811
  };
49763
49812
 
49764
- // Only the two directions the layout has room for: a column layout reveals
49765
- // sideways, a stacked one vertically.
49766
- const revealDirection = isColumn ? openDirection === "left" ? "left" : "right" : openDirection === "up" ? "up" : "down";
49767
49813
  // While open the marker points at the content, while closed always right.
49768
49814
  // In a column layout it points back toward the UI part whichever side the
49769
49815
  // content took, "right" being the closed direction already.
@@ -49964,6 +50010,44 @@ Expandable.Content = ExpandableContent;
49964
50010
  // control, not at the row. The UI part itself matches [role='button'] and is
49965
50011
  // the one exception, excluded at the call site.
49966
50012
  const UI_INTERACTIVE_SELECTOR = ["a[href]", "button", "input", "select", "textarea", "label", "[role='button']", "[contenteditable='']", "[contenteditable='true']", "audio[controls]", "video[controls]"].join(", ");
50013
+
50014
+ // Scrolls the nearest scroller that can scroll so the whole expandable — UI
50015
+ // part and revealed content — is in view. Measured on the layout as it stands,
50016
+ // so the caller calls it while the open layout is the one laid out (see
50017
+ // openEffect). A root taller than the view is aligned on its UI side instead:
50018
+ // what the user pressed stays on screen, and the content follows as far as it
50019
+ // fits. One scroller only — a popup or a page around it is left where it is.
50020
+ const scrollExpandableIntoView = (rootEl, {
50021
+ revealDirection,
50022
+ behavior
50023
+ }) => {
50024
+ let scrollerEl = null;
50025
+ for (const scrollContainer of getScrollContainerSet(rootEl)) {
50026
+ if (isScrollable(scrollContainer)) {
50027
+ scrollerEl = scrollContainer;
50028
+ break;
50029
+ }
50030
+ }
50031
+ if (!scrollerEl) {
50032
+ return;
50033
+ }
50034
+ const rootRect = rootEl.getBoundingClientRect();
50035
+ let block = "nearest";
50036
+ let inline = "nearest";
50037
+ if (revealDirection === "down" || revealDirection === "up") {
50038
+ if (rootRect.height > scrollerEl.clientHeight) {
50039
+ block = revealDirection === "down" ? "start" : "end";
50040
+ }
50041
+ } else if (rootRect.width > scrollerEl.clientWidth) {
50042
+ inline = revealDirection === "right" ? "start" : "end";
50043
+ }
50044
+ scrollIntoViewScoped(rootEl, {
50045
+ container: scrollerEl,
50046
+ block,
50047
+ inline,
50048
+ behavior
50049
+ });
50050
+ };
49967
50051
  const createToggleEvent = open => {
49968
50052
  const newState = open ? "open" : "closed";
49969
50053
  const oldState = open ? "closed" : "open";