@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, 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, 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, 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, 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";
@@ -4474,9 +4474,12 @@ const findControlHost = (el) => {
4474
4474
  * `findControlHost` above answers for a control's own DOM (itself, or the native
4475
4475
  * element it wraps). This one is for something that is not part of a control but
4476
4476
  * has to reach one — an interaction declared on a box (see
4477
- * interaction/interactions.js), which may be the control, may hold it, or may sit
4478
- * inside it. Nearest wins in that order, and the answer is null when there is no
4479
- * control anywhere: not every box lives in one.
4477
+ * interaction/interactions.js), which may be the control, may sit inside it, or
4478
+ * may be a wrapper around exactly one. Nearest wins in that order, and the answer
4479
+ * is null when there is no control the box belongs to: not every box lives in
4480
+ * one, and a box laying out several controls (a row of badges, a toolbar) is
4481
+ * held by none of them — its interactions are its own, and a press on its empty
4482
+ * part is not a press on the first control it happens to contain.
4480
4483
  */
4481
4484
  const findNearestControlHost = (el) => {
4482
4485
  // Itself, then upwards — the box is inside a button, or is one.
@@ -4484,8 +4487,22 @@ const findNearestControlHost = (el) => {
4484
4487
  if (selfOrAncestor) {
4485
4488
  return selfOrAncestor;
4486
4489
  }
4487
- // …then downwards: the box holds the control rather than being held by it.
4488
- return el.querySelector("[navi-control-host]");
4490
+ // …then downwards, only when the box wraps a single control. Counted by
4491
+ // control, not by host: a picker holds the hosts of its popup content, and a
4492
+ // box around that one picker is still around one control.
4493
+ let single = null;
4494
+ for (const host of el.querySelectorAll("[navi-control-host]")) {
4495
+ const controlRoot = host.closest("[navi-control]") || host;
4496
+ const controlAbove = controlRoot.parentElement.closest("[navi-control]");
4497
+ if (controlAbove && el.contains(controlAbove)) {
4498
+ continue;
4499
+ }
4500
+ if (single && single !== controlRoot) {
4501
+ return null;
4502
+ }
4503
+ single = controlRoot;
4504
+ }
4505
+ return single ? findControlHost(single) : null;
4489
4506
  };
4490
4507
  const isControlRoot = (el) => {
4491
4508
  return el.hasAttribute("navi-control");
@@ -10600,9 +10617,10 @@ const interactionsDisputeThePress = (interactions) => {
10600
10617
  *
10601
10618
  * The control is not passed in: it is found from the element, which is what lets
10602
10619
  * `interactions` live on a Box rather than on the control itself. A Box that IS a
10603
- * control (a Button) is its own; a Box around one or inside one reaches it; a Box
10604
- * with no control anywhere near it can still answer with a callback of the
10605
- * caller's, and only "request_action" has nothing to ask.
10620
+ * control (a Button) is its own; a Box inside one, or wrapping exactly one,
10621
+ * reaches it; a Box with no control anywhere near it or laying out several,
10622
+ * which belongs to none of them still answers with a callback of the caller's,
10623
+ * and only "request_action" has nothing to ask.
10606
10624
  *
10607
10625
  * Set up once per element rather than on every render, which is what lets a
10608
10626
  * detector be a plain `setup`/teardown pair. So the interactions themselves are
@@ -48198,7 +48216,8 @@ const css$R = /* css */`.navi_expandable {
48198
48216
  }
48199
48217
 
48200
48218
  &:not([aria-expanded="true"])[data-settled] > .navi_expandable_content_container {
48201
- clip-path: inset(0);
48219
+ clip-path: none;
48220
+ overflow: clip;
48202
48221
  }
48203
48222
 
48204
48223
  &[data-content-scrolls] > .navi_expandable_content_container > .navi_expandable_content_sizer > .navi_expandable_content {
@@ -48232,6 +48251,7 @@ const useExpandableContext = partName => {
48232
48251
  * layout?: "row" | "column",
48233
48252
  * openDirection?: "down" | "up" | "right" | "left",
48234
48253
  * autoFocus?: boolean,
48254
+ * keepInView?: boolean,
48235
48255
  * maxContentHeight?: string | number,
48236
48256
  * mount?: "always" | "idle" | "from-first-open" | "while-opened",
48237
48257
  * arrowKeyShortcuts?: boolean,
@@ -48289,6 +48309,15 @@ const useExpandableContext = partName => {
48289
48309
  * came from. An expandable that mounts already open never takes it. Whatever
48290
48310
  * the setting, closing while the focus sits inside the content hands it back
48291
48311
  * to the UI part (it would otherwise be lost to the closed, inert content).
48312
+ * @param keepInView - Off by default. `true` scrolls the nearest scroller
48313
+ * that can scroll so the content revealed past its edge is on screen: an
48314
+ * expandable opening at the bottom of a list otherwise grows below the
48315
+ * fold, and the user sees the row grow but not what it grew with. Measured
48316
+ * on the open layout and done once per opening, alongside the reveal
48317
+ * (smoothly when `animation` plays, at once otherwise). An expandable
48318
+ * taller than the scroller keeps its UI part on screen and the content
48319
+ * follows as far as it fits. Only the nearest scroller moves — a popup or
48320
+ * a page around it stays where it is.
48292
48321
  * @param maxContentHeight - Caps the content height; taller content scrolls
48293
48322
  * inside the expandable instead of growing it.
48294
48323
  * @param mount - When the content is built and thrown away, a popup's own
@@ -48324,6 +48353,7 @@ const Expandable = props => {
48324
48353
  layout,
48325
48354
  openDirection,
48326
48355
  autoFocus,
48356
+ keepInView = false,
48327
48357
  maxContentHeight,
48328
48358
  mount = MOUNT_DEFAULT,
48329
48359
  arrowKeyShortcuts = true,
@@ -48341,6 +48371,9 @@ const Expandable = props => {
48341
48371
  const contentId = useId();
48342
48372
  const isColumn = layout === "column";
48343
48373
  const closedContentSized = isColumn && mount === "always";
48374
+ // Only the two directions the layout has room for: a column layout reveals
48375
+ // sideways, a stacked one vertically.
48376
+ const revealDirection = isColumn ? openDirection === "left" ? "left" : "right" : openDirection === "up" ? "up" : "down";
48344
48377
  const hasAction = Boolean(action);
48345
48378
  const effectiveAction = useAction(action);
48346
48379
  const {
@@ -48433,6 +48466,18 @@ const Expandable = props => {
48433
48466
  } else {
48434
48467
  contentElement.style.height = `${finalRect.height}px`;
48435
48468
  }
48469
+ if (keepInView) {
48470
+ // Here and not once the reveal settles: the layout is the open one at
48471
+ // this point, so where the content ends is known before the movement
48472
+ // starts, and the scroll can travel along with it. The frozen content
48473
+ // overflowing the collapsed track is still scrollable overflow (the
48474
+ // one-sided clip-path, see the CSS), so the scroller can reach that
48475
+ // position from the first frame.
48476
+ scrollExpandableIntoView(rootRef.current, {
48477
+ revealDirection,
48478
+ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"
48479
+ });
48480
+ }
48436
48481
  // Put the tracks back where the last paint left them and let the
48437
48482
  // transition play from there. In fr — px does not interpolate with fr.
48438
48483
  const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
@@ -48487,6 +48532,10 @@ const Expandable = props => {
48487
48532
  if (revealing) {
48488
48533
  armReveal(startRect);
48489
48534
  watchSettle();
48535
+ } else if (keepInView && !silent && contentContainer) {
48536
+ scrollExpandableIntoView(rootRef.current, {
48537
+ revealDirection
48538
+ });
48490
48539
  }
48491
48540
  if (!silent) {
48492
48541
  rootRef.current.dispatchEvent(createToggleEvent(true));
@@ -48667,9 +48716,6 @@ const Expandable = props => {
48667
48716
  }
48668
48717
  };
48669
48718
 
48670
- // Only the two directions the layout has room for: a column layout reveals
48671
- // sideways, a stacked one vertically.
48672
- const revealDirection = isColumn ? openDirection === "left" ? "left" : "right" : openDirection === "up" ? "up" : "down";
48673
48719
  // While open the marker points at the content, while closed always right.
48674
48720
  // In a column layout it points back toward the UI part whichever side the
48675
48721
  // content took, "right" being the closed direction already.
@@ -48870,6 +48916,44 @@ Expandable.Content = ExpandableContent;
48870
48916
  // control, not at the row. The UI part itself matches [role='button'] and is
48871
48917
  // the one exception, excluded at the call site.
48872
48918
  const UI_INTERACTIVE_SELECTOR = ["a[href]", "button", "input", "select", "textarea", "label", "[role='button']", "[contenteditable='']", "[contenteditable='true']", "audio[controls]", "video[controls]"].join(", ");
48919
+
48920
+ // Scrolls the nearest scroller that can scroll so the whole expandable — UI
48921
+ // part and revealed content — is in view. Measured on the layout as it stands,
48922
+ // so the caller calls it while the open layout is the one laid out (see
48923
+ // openEffect). A root taller than the view is aligned on its UI side instead:
48924
+ // what the user pressed stays on screen, and the content follows as far as it
48925
+ // fits. One scroller only — a popup or a page around it is left where it is.
48926
+ const scrollExpandableIntoView = (rootEl, {
48927
+ revealDirection,
48928
+ behavior
48929
+ }) => {
48930
+ let scrollerEl = null;
48931
+ for (const scrollContainer of getScrollContainerSet(rootEl)) {
48932
+ if (isScrollable(scrollContainer)) {
48933
+ scrollerEl = scrollContainer;
48934
+ break;
48935
+ }
48936
+ }
48937
+ if (!scrollerEl) {
48938
+ return;
48939
+ }
48940
+ const rootRect = rootEl.getBoundingClientRect();
48941
+ let block = "nearest";
48942
+ let inline = "nearest";
48943
+ if (revealDirection === "down" || revealDirection === "up") {
48944
+ if (rootRect.height > scrollerEl.clientHeight) {
48945
+ block = revealDirection === "down" ? "start" : "end";
48946
+ }
48947
+ } else if (rootRect.width > scrollerEl.clientWidth) {
48948
+ inline = revealDirection === "right" ? "start" : "end";
48949
+ }
48950
+ scrollIntoViewScoped(rootEl, {
48951
+ container: scrollerEl,
48952
+ block,
48953
+ inline,
48954
+ behavior
48955
+ });
48956
+ };
48873
48957
  const createToggleEvent = open => {
48874
48958
  const newState = open ? "open" : "closed";
48875
48959
  const oldState = open ? "closed" : "open";