@jsenv/navi 0.29.163 → 0.29.165

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, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, isTouchDrivenEvent, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, 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, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, isTouchDrivenEvent, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { isValidElement, h, Fragment, createContext, render, toChildArray, options, cloneElement } from "preact";
@@ -28064,6 +28064,21 @@ installImportMetaCssBuild(import.meta);/**
28064
28064
  * the reverse of another, so B → A can be given a movement of its own, or
28065
28065
  * silenced with "none", by writing it (see findRelation).
28066
28066
  *
28067
+ * One shape of page has no pair to write: the one whose door is in the fixed
28068
+ * furniture — a gear in a top bar, a "+" in a tab bar — and which is therefore
28069
+ * opened from every screen and closed back onto whichever one the reader was
28070
+ * on. The relation is real and it is a single sentence; only the `from` is
28071
+ * missing, so it is left out:
28072
+ *
28073
+ * defineRouteTransition(null, SETTINGS_PAGE, "cover-top");
28074
+ *
28075
+ * Arriving there plays forward from wherever, leaving plays back to wherever,
28076
+ * and that is what lets the back button close it the way it opened — a
28077
+ * traversal carries no request, and nothing has to remember per history entry
28078
+ * what the press that created it had asked for. It is tried after every
28079
+ * written pair, and it is not defineRouteDefaultTransition: a default is about
28080
+ * every navigation nothing was said about, this is about ONE destination.
28081
+ *
28067
28082
  * The relation says WHEN something plays and which way; the transition says
28068
28083
  * WHAT plays — a movement navi ships, or a name the application defines in its
28069
28084
  * own CSS (see the JSDoc below). Said without one, the relation plays the
@@ -28252,7 +28267,7 @@ const css$11 = /* css */`:root[data-navi-route-transition] [data-navi-route-tran
28252
28267
  }
28253
28268
  }
28254
28269
 
28255
- &[data-navi-route-transition-type="cover-x"] {
28270
+ &[data-navi-route-transition-type="cover-right"] {
28256
28271
  &[data-navi-route-transition="forward"] {
28257
28272
  --navi-route-transition-leave: navi-route-transition-still;
28258
28273
  --navi-route-transition-enter: navi-route-transition-enter-from-end;
@@ -28261,14 +28276,22 @@ const css$11 = /* css */`:root[data-navi-route-transition] [data-navi-route-tran
28261
28276
  &[data-navi-route-transition="back"] {
28262
28277
  --navi-route-transition-leave: navi-route-transition-leave-towards-end;
28263
28278
  --navi-route-transition-enter: navi-route-transition-still;
28279
+ }
28280
+ }
28264
28281
 
28265
- &::view-transition-old(root), &::view-transition-old(navi-route-transition) {
28266
- z-index: 1;
28267
- }
28282
+ &[data-navi-route-transition-type="cover-left"] {
28283
+ &[data-navi-route-transition="forward"] {
28284
+ --navi-route-transition-leave: navi-route-transition-still;
28285
+ --navi-route-transition-enter: navi-route-transition-enter-from-start;
28286
+ }
28287
+
28288
+ &[data-navi-route-transition="back"] {
28289
+ --navi-route-transition-leave: navi-route-transition-leave-towards-start;
28290
+ --navi-route-transition-enter: navi-route-transition-still;
28268
28291
  }
28269
28292
  }
28270
28293
 
28271
- &[data-navi-route-transition-type="cover-y"] {
28294
+ &[data-navi-route-transition-type="cover-bottom"] {
28272
28295
  &[data-navi-route-transition="forward"] {
28273
28296
  --navi-route-transition-leave: navi-route-transition-still;
28274
28297
  --navi-route-transition-enter: navi-route-transition-enter-from-bottom;
@@ -28277,7 +28300,23 @@ const css$11 = /* css */`:root[data-navi-route-transition] [data-navi-route-tran
28277
28300
  &[data-navi-route-transition="back"] {
28278
28301
  --navi-route-transition-leave: navi-route-transition-leave-towards-bottom;
28279
28302
  --navi-route-transition-enter: navi-route-transition-still;
28303
+ }
28304
+ }
28305
+
28306
+ &[data-navi-route-transition-type="cover-top"] {
28307
+ &[data-navi-route-transition="forward"] {
28308
+ --navi-route-transition-leave: navi-route-transition-still;
28309
+ --navi-route-transition-enter: navi-route-transition-enter-from-top;
28310
+ }
28311
+
28312
+ &[data-navi-route-transition="back"] {
28313
+ --navi-route-transition-leave: navi-route-transition-leave-towards-top;
28314
+ --navi-route-transition-enter: navi-route-transition-still;
28315
+ }
28316
+ }
28280
28317
 
28318
+ &[data-navi-route-transition-type="cover-right"], &[data-navi-route-transition-type="cover-left"], &[data-navi-route-transition-type="cover-bottom"], &[data-navi-route-transition-type="cover-top"] {
28319
+ &[data-navi-route-transition="back"] {
28281
28320
  &::view-transition-old(root), &::view-transition-old(navi-route-transition) {
28282
28321
  z-index: 1;
28283
28322
  }
@@ -28300,7 +28339,7 @@ const css$11 = /* css */`:root[data-navi-route-transition] [data-navi-route-tran
28300
28339
  }
28301
28340
  }
28302
28341
 
28303
- &[data-navi-route-transition-type="slide-x"], &[data-navi-route-transition-type="slide-y"], &[data-navi-route-transition-type="cover-x"], &[data-navi-route-transition-type="cover-y"], &[data-navi-route-transition-type="zoom"] {
28342
+ &[data-navi-route-transition-type="slide-x"], &[data-navi-route-transition-type="slide-y"], &[data-navi-route-transition-type="cover-right"], &[data-navi-route-transition-type="cover-left"], &[data-navi-route-transition-type="cover-bottom"], &[data-navi-route-transition-type="cover-top"], &[data-navi-route-transition-type="zoom"] {
28304
28343
  &::view-transition-old(root), &::view-transition-old(navi-route-transition) {
28305
28344
  animation-name: var(--navi-route-transition-leave);
28306
28345
  }
@@ -28310,7 +28349,7 @@ const css$11 = /* css */`:root[data-navi-route-transition] [data-navi-route-tran
28310
28349
  }
28311
28350
  }
28312
28351
 
28313
- &[data-navi-route-transition-type="slide-x"], &[data-navi-route-transition-type="slide-y"], &[data-navi-route-transition-type="cover-x"], &[data-navi-route-transition-type="cover-y"] {
28352
+ &[data-navi-route-transition-type="slide-x"], &[data-navi-route-transition-type="slide-y"], &[data-navi-route-transition-type="cover-right"], &[data-navi-route-transition-type="cover-left"], &[data-navi-route-transition-type="cover-bottom"], &[data-navi-route-transition-type="cover-top"] {
28314
28353
  &::view-transition-old(root), &::view-transition-new(root), &::view-transition-old(navi-route-transition), &::view-transition-new(navi-route-transition) {
28315
28354
  animation-timing-function: ease;
28316
28355
  }
@@ -28435,10 +28474,17 @@ const RouteTransitionArea = ({
28435
28474
  };
28436
28475
 
28437
28476
  /**
28438
- * Declare how a pair of routes moves against each other.
28439
- *
28440
- * @param {object} from - a route, or `{ route, params }` when the page is a
28441
- * param of a route rather than a route of its own.
28477
+ * Declare how a pair of routes moves against each other — or how one route is
28478
+ * entered, from wherever its door happens to be.
28479
+ *
28480
+ * @param {object|null} from - a route, or `{ route, params }` when the page is
28481
+ * a param of a route rather than a route of its own. `null` says the page is
28482
+ * reached FROM ANYWHERE: its door is in the furniture (a gear in the top
28483
+ * bar, a "+" in the tab bar) rather than on a screen, so there is no pair to
28484
+ * write it on. Arriving at `to` from any page then plays forward and leaving
28485
+ * it for any page plays back, which is what makes the back button close it
28486
+ * the way it opened. Tried last, after every written pair: a pair naming the
28487
+ * same destination still owns its crossing.
28442
28488
  * @param {object} to - same forms. Going from `from` to `to` plays forward,
28443
28489
  * the reverse plays back — unless the reverse is written as a relation of
28444
28490
  * its own, which then owns that way (a movement of its own, or `"none"` for
@@ -28452,8 +28498,11 @@ const RouteTransitionArea = ({
28452
28498
  * Omitted, the browser's own cross-fade. Shipped with navi:
28453
28499
  * - `"slide-x"`, `"slide-y"`: the two pages slide past each other, forward
28454
28500
  * towards the start of the axis;
28455
- * - `"cover-x"`, `"cover-y"`: the page arriving slides in OVER one that does
28456
- * not move, and slides off it on the way back;
28501
+ * - `"cover-right"`, `"cover-left"`, `"cover-bottom"`, `"cover-top"`: the
28502
+ * page arriving slides in OVER one that does not move, and slides off it
28503
+ * on the way back. The edge named is the one it comes IN from, which is
28504
+ * where its door is: a page reached from a bottom bar covers from the
28505
+ * bottom, one pulled down from a top bar covers from the top;
28457
28506
  * - `"zoom"`: the deeper page is the closer one — it lands from slightly too
28458
28507
  * big, and grows away when left;
28459
28508
  * - `"none"`: nothing, said out loud — written on one way of a pair, it cuts
@@ -28484,6 +28533,9 @@ const RouteTransitionArea = ({
28484
28533
  const defineRouteTransition = (from, to, transition) => {
28485
28534
  import.meta.css = [css$11, "@jsenv/navi/src/nav/route_transition.jsx"];
28486
28535
  installTransitionWindowCss();
28536
+ if (!to) {
28537
+ throw new TypeError(`defineRouteTransition needs a destination: "to" is ${to}. The page reached from anywhere is written defineRouteTransition(null, THAT_PAGE, ...) — there is no relation the other way round, a page LEFT for anywhere being the back half of that one.`);
28538
+ }
28487
28539
  const {
28488
28540
  type,
28489
28541
  duration
@@ -28652,13 +28704,15 @@ const rebuildWatcher = () => {
28652
28704
  }
28653
28705
  // Every page any relation mentions, each once: the position of the current
28654
28706
  // page in this list is what turns "some signal moved" into "the document
28655
- // went from page A to page B".
28707
+ // went from page A to page B". A relation written from anywhere mentions one
28708
+ // page only — the other end is whatever the reader was on, and -1 is how it
28709
+ // reads here.
28656
28710
  const pages = [];
28657
28711
  for (const {
28658
28712
  from,
28659
28713
  to
28660
28714
  } of relations) {
28661
- if (pageIndexOf$1(pages, from) === -1) {
28715
+ if (from && pageIndexOf$1(pages, from) === -1) {
28662
28716
  pages.push(from);
28663
28717
  }
28664
28718
  if (pageIndexOf$1(pages, to) === -1) {
@@ -28678,10 +28732,15 @@ const rebuildWatcher = () => {
28678
28732
  firstReading = false;
28679
28733
  return;
28680
28734
  }
28681
- if (index === -1 || fromIndex === -1 || fromIndex === index) {
28735
+ if (fromIndex === index) {
28682
28736
  return;
28683
28737
  }
28684
- const found = findRelation(pages[fromIndex], pages[index]);
28738
+ // A page in no relation at all is a real end of the crossing, not a
28739
+ // missing one: it is the "anywhere" a page reached from the furniture is
28740
+ // opened over and closed back onto (see findRelation).
28741
+ const fromPage = fromIndex === -1 ? null : pages[fromIndex];
28742
+ const toPage = index === -1 ? null : pages[index];
28743
+ const found = findRelation(fromPage, toPage);
28685
28744
  if (!found && !navigationRequest) {
28686
28745
  // No relation says anything about these two and this navigation asked
28687
28746
  // for nothing: they are side by side, and silence is the fact — not a
@@ -28700,7 +28759,7 @@ const rebuildWatcher = () => {
28700
28759
  return;
28701
28760
  }
28702
28761
  beginTransition({
28703
- page: pages[index],
28762
+ page: toPage,
28704
28763
  url: navigationUrl,
28705
28764
  // Which way it plays: what the navigation itself said first — the link
28706
28765
  // being pressed is where the way the app is being walked is known — then
@@ -28777,13 +28836,23 @@ observeAfterRouting(() => {
28777
28836
  releaseRoutingRenderingHold();
28778
28837
  });
28779
28838
 
28780
- // The exact way travelled first, over the whole registry, and only then the
28781
- // reverses: a relation written B → A owns that way, and being the reverse of
28782
- // one written A → B never outranks it. This is what makes reciprocity a
28783
- // default rather than a decree — write the way back to give it a movement of
28784
- // its own, or "none" to silence it.
28839
+ // The exact way travelled first, over the whole registry, then the reverses,
28840
+ // and last the pages written from anywhere.
28841
+ //
28842
+ // A relation written B → A owns that way, and being the reverse of one written
28843
+ // A → B never outranks it. This is what makes reciprocity a default rather
28844
+ // than a decree — write the way back to give it a movement of its own, or
28845
+ // "none" to silence it.
28846
+ //
28847
+ // A page reached from anywhere is tried after every pair, so a pair naming the
28848
+ // same destination still owns its crossing — the map, where it was drawn, is
28849
+ // more precise than "from wherever". Arriving is read before leaving: between
28850
+ // two such pages, the one being opened says what plays.
28785
28851
  const findRelation = (fromPage, toPage) => {
28786
28852
  for (const relation of relations) {
28853
+ if (!relation.from) {
28854
+ continue;
28855
+ }
28787
28856
  if (samePage$1(relation.from, fromPage) && samePage$1(relation.to, toPage)) {
28788
28857
  return {
28789
28858
  direction: "forward",
@@ -28792,6 +28861,9 @@ const findRelation = (fromPage, toPage) => {
28792
28861
  }
28793
28862
  }
28794
28863
  for (const relation of relations) {
28864
+ if (!relation.from) {
28865
+ continue;
28866
+ }
28795
28867
  if (samePage$1(relation.from, toPage) && samePage$1(relation.to, fromPage)) {
28796
28868
  return {
28797
28869
  direction: "back",
@@ -28799,6 +28871,28 @@ const findRelation = (fromPage, toPage) => {
28799
28871
  };
28800
28872
  }
28801
28873
  }
28874
+ for (const relation of relations) {
28875
+ if (relation.from) {
28876
+ continue;
28877
+ }
28878
+ if (samePage$1(relation.to, toPage)) {
28879
+ return {
28880
+ direction: "forward",
28881
+ relation
28882
+ };
28883
+ }
28884
+ }
28885
+ for (const relation of relations) {
28886
+ if (relation.from) {
28887
+ continue;
28888
+ }
28889
+ if (samePage$1(relation.to, fromPage)) {
28890
+ return {
28891
+ direction: "back",
28892
+ relation
28893
+ };
28894
+ }
28895
+ }
28802
28896
  return null;
28803
28897
  };
28804
28898
 
@@ -28906,10 +29000,11 @@ const beginTransition = ({
28906
29000
  // rendered has already resolved the wait by the next line.
28907
29001
  releaseRendering();
28908
29002
  if (page === null) {
28909
- // A default transition: which page is arriving is unknown, and some
28910
- // navigations render no route at all (a search param bound to a
28911
- // signal) — waited on, those would freeze the page until the browser
28912
- // gives up. The wait is raced with a short timer instead.
29003
+ // Which page is arriving is unknown — a default transition, or a page
29004
+ // reached from anywhere being left for wherever — and some navigations
29005
+ // render no route at all (a search param bound to a signal): waited
29006
+ // on, those would freeze the page until the browser gives up. The wait
29007
+ // is raced with a short timer instead.
28913
29008
  await Promise.race([renderWait.rendered, waitMs(50)]);
28914
29009
  } else if (pageIsCurrent$1(page)) {
28915
29010
  await renderWait.rendered;
@@ -29034,10 +29129,18 @@ const armRouteRenderWait$1 = () => {
29034
29129
  };
29035
29130
  };
29036
29131
  const waitMs = ms => new Promise(resolve => setTimeout(resolve, ms));
29037
- const normalizePage$1 = page => page.isRoute ? {
29038
- route: page,
29039
- params: undefined
29040
- } : page;
29132
+
29133
+ // A page written as the route itself, and the page written nowhere: `from`
29134
+ // may be left out, which is a relation about arriving at `to` from anywhere.
29135
+ const normalizePage$1 = page => {
29136
+ if (!page) {
29137
+ return null;
29138
+ }
29139
+ return page.isRoute ? {
29140
+ route: page,
29141
+ params: undefined
29142
+ } : page;
29143
+ };
29041
29144
 
29042
29145
  // Two pages are the same page when they select the same thing, not when they
29043
29146
  // were written by the same hand.
@@ -30594,6 +30697,117 @@ const unnameAfterTravel = element => {
30594
30697
  element.style.viewTransitionName = "";
30595
30698
  };
30596
30699
 
30700
+ /**
30701
+ * What covers the box a route movement plays in, from INSIDE the document: a
30702
+ * sticky row of tabs above a `<RouteTravel>`, a header pinned to the top of a
30703
+ * scroller. The element says it is there, this measures what it takes and
30704
+ * publishes it under --navi-transition-cover-* — the slot transition_window.js
30705
+ * declares and explains, and which nothing but the element covering the box can
30706
+ * fill: the safe area answers for what is pinned to the WINDOW's edges, and a
30707
+ * sticky row is none of those.
30708
+ *
30709
+ * The pictures of a transition are drawn in the top layer, above every z-index
30710
+ * of the document, so a band left uncounted here is a band the pictures slide
30711
+ * over instead of being cut at.
30712
+ *
30713
+ * Same shape as the room a fixed bar gives back (layout/fixed_bar/
30714
+ * fixed_bar_space.js): measured rather than declared, and the largest of the
30715
+ * elements sharing an edge rather than their sum — during a route movement the
30716
+ * outgoing and the incoming row are both mounted, pinned to the same edge, one
30717
+ * over the other. Nothing takes its layout from these variables, though, so the
30718
+ * write happens where it is measured, with no frame to wait for.
30719
+ */
30720
+
30721
+
30722
+ const sizeMapByEdge = new Map();
30723
+ // What is currently on <html> for each edge (absent = the variable is not set).
30724
+ // Several elements sharing an edge means most calls compute the same largest
30725
+ // size again, and a size that has not changed is a style recalculation of the
30726
+ // whole document for nothing.
30727
+ const writtenValueByEdge = new Map();
30728
+
30729
+ /**
30730
+ * @param {import("preact").RefObject<Element>} elementRef - The element
30731
+ * covering the box.
30732
+ * @param {"top"|"right"|"bottom"|"left"} [edge="top"] - Which side of the box
30733
+ * it covers. Its size across that edge is what gets published; the edge's own
30734
+ * band (the safe area, the fixed bars) is already counted, so an element
30735
+ * states its own size and nothing else.
30736
+ */
30737
+ const useTransitionCover = (elementRef, edge = "top") => {
30738
+ installTransitionWindowCss();
30739
+
30740
+ const measureCover = (element) => {
30741
+ const { width, height } = element.getBoundingClientRect();
30742
+ return edge === "left" || edge === "right" ? width : height;
30743
+ };
30744
+
30745
+ // Anything a render can change — the row's content, a size prop, a theme
30746
+ // variable — is measured in that same commit: a movement starting on the
30747
+ // render that grew the row is cut at the row as it now is.
30748
+ useLayoutEffect(() => {
30749
+ const element = elementRef.current;
30750
+ if (!element) {
30751
+ return;
30752
+ }
30753
+ writeCover(edge, element, measureCover(element));
30754
+ });
30755
+
30756
+ // What no render caused: a font arriving late, content coming from outside, a
30757
+ // rotation.
30758
+ useLayoutEffect(() => {
30759
+ const element = elementRef.current;
30760
+ if (!element) {
30761
+ return undefined;
30762
+ }
30763
+ const resizeObserver = new ResizeObserver(() => {
30764
+ writeCover(edge, element, measureCover(element));
30765
+ });
30766
+ resizeObserver.observe(element);
30767
+ return () => {
30768
+ resizeObserver.disconnect();
30769
+ writeCover(edge, element, null);
30770
+ };
30771
+ }, [edge]);
30772
+ };
30773
+
30774
+ const writeCover = (edge, element, size) => {
30775
+ let sizeMap = sizeMapByEdge.get(edge);
30776
+ if (!sizeMap) {
30777
+ sizeMap = new Map();
30778
+ sizeMapByEdge.set(edge, sizeMap);
30779
+ }
30780
+ if (size === null) {
30781
+ sizeMap.delete(element);
30782
+ } else {
30783
+ sizeMap.set(element, size);
30784
+ }
30785
+ let largestSize = 0;
30786
+ for (const coverSize of sizeMap.values()) {
30787
+ if (coverSize > largestSize) {
30788
+ largestSize = coverSize;
30789
+ }
30790
+ }
30791
+ const property = `--navi-transition-cover-${edge}`;
30792
+ const { style } = document.documentElement;
30793
+ if (sizeMap.size === 0) {
30794
+ // Back to the zero the sheet declares, rather than a zero written over it:
30795
+ // a page with no sticky row must read what navi says, not what the last one
30796
+ // left behind.
30797
+ if (writtenValueByEdge.has(edge)) {
30798
+ writtenValueByEdge.delete(edge);
30799
+ style.removeProperty(property);
30800
+ }
30801
+ return;
30802
+ }
30803
+ const value = `${largestSize}px`;
30804
+ if (writtenValueByEdge.get(edge) === value) {
30805
+ return;
30806
+ }
30807
+ writtenValueByEdge.set(edge, value);
30808
+ style.setProperty(property, value);
30809
+ };
30810
+
30597
30811
  const routeAction = (
30598
30812
  routeOrRoutes,
30599
30813
  action,
@@ -39632,6 +39846,12 @@ const shouldInjectSpacingBetween = (left, right) => {
39632
39846
  * An invisible placeholder rendered with those styles reserves the space; the
39633
39847
  * real visible text is layered on top via `position: absolute`.
39634
39848
  * Best combined with `noWrap` — does not work reliably on multi-line text.
39849
+ * The placeholder is a SECOND copy of the children: it is `aria-hidden`, so
39850
+ * the accessible name is right, but the text appears twice in `textContent`
39851
+ * — which is what a test asserting on the element's text reads. Where the
39852
+ * element's text must stay single (a tab, a label a test matches on), reserve
39853
+ * the space some other way; `Link`'s `currentEffectBold` paints the weight by
39854
+ * default and comes here only when asked to (`"hold-space"`).
39635
39855
  *
39636
39856
  * @param {boolean} [boldStable]
39637
39857
  * Alternative to `holdSpaceForStyle` for multi-line text. Keeps a consistent
@@ -40131,6 +40351,7 @@ installImportMetaCssBuild(import.meta);const css$X = /* css */`@layer navi {
40131
40351
  --link-text-decoration-hover: var(--link-text-decoration);
40132
40352
  --link-cursor: pointer;
40133
40353
  --link-outline-width: 2px;
40354
+ --link-current-effect-bold-thickness: .02em;
40134
40355
  --link-current-indicator-size: 2px;
40135
40356
  --link-current-indicator-spacing: 0;
40136
40357
  --link-current-indicator-color: var(--navi-link-current-indicator-color);
@@ -40266,7 +40487,12 @@ installImportMetaCssBuild(import.meta);const css$X = /* css */`@layer navi {
40266
40487
  --x-link-color: var(--link-color-current);
40267
40488
  }
40268
40489
 
40269
- &[data-current-effect-bold] {
40490
+ &[data-current-effect-bold="text-shadow"] {
40491
+ text-shadow: var(--link-current-effect-bold-thickness) 0 0 currentColor,
40492
+ calc(-1 * var(--link-current-effect-bold-thickness)) 0 0 currentColor;
40493
+ }
40494
+
40495
+ &[data-current-effect-bold="hold-space"] {
40270
40496
  font-weight: bold;
40271
40497
  }
40272
40498
 
@@ -40479,8 +40705,17 @@ Object.assign(PSEUDO_CLASSES, {
40479
40705
  * `"tab"` renders a tab-like affordance.
40480
40706
  * @param {boolean|"top"|"bottom"|"left"|"right"} [props.currentIndicator] - A
40481
40707
  * bar drawn on the given edge (or bottom when `true`) while current.
40482
- * @param {boolean} [props.currentEffectBold] - Bold the text while current
40483
- * (reserving the bold width so layout doesn't shift).
40708
+ * @param {boolean|"text-shadow"|"hold-space"} [props.currentEffectBold] - Bold
40709
+ * the label while current, without its width changing — a row of tabs must
40710
+ * not jump when one becomes current. Which is a choice of two:
40711
+ * - `"text-shadow"` (what `true` means): the weight is PAINTED, the glyphs
40712
+ * drawn a hair to each side of themselves
40713
+ * (`--link-current-effect-bold-thickness`). The label stays one piece of
40714
+ * text, so `textContent` and anything reading it still see it once.
40715
+ * - `"hold-space"`: the real bold face, its width held by an invisible copy
40716
+ * of the label rendered bold underneath. True bold — and the label then
40717
+ * appears TWICE in `textContent`, which a test asserting on the element's
40718
+ * text reads (see `holdSpaceForStyle` in `text.jsx`).
40484
40719
  * @param {boolean} [props.currentEffectShadow] - Inset-shadow effect while
40485
40720
  * current (used with `variant="tab"`).
40486
40721
  * @param {boolean|import("ignore:preact").ComponentChild} [props.startIcon] - Icon
@@ -40710,6 +40945,11 @@ const LinkPlain = props => {
40710
40945
  const startIconEl = startIcon;
40711
40946
  const endIconEl = innerEndIcon;
40712
40947
 
40948
+ // Bold by painting the glyphs unless asked for the bold face itself, because
40949
+ // that one costs a second copy of the label in the element's text (see the
40950
+ // css above).
40951
+ const currentEffectBoldTechnique = currentEffectBold === true ? "text-shadow" : currentEffectBold || undefined;
40952
+
40713
40953
  // Where the bar goes: said here, or once for the whole row by the <Nav>
40714
40954
  // around this link.
40715
40955
  const currentIndicatorAsked = currentIndicator ?? nav?.currentIndicator;
@@ -40799,7 +41039,7 @@ const LinkPlain = props => {
40799
41039
  onnavi_value: e => {
40800
41040
  e.detail.setValue(value);
40801
41041
  },
40802
- holdSpaceForStyle: currentEffectBold ? {
41042
+ holdSpaceForStyle: currentEffectBoldTechnique === "hold-space" ? {
40803
41043
  fontWeight: "bold"
40804
41044
  } : undefined,
40805
41045
  preventSpaceUnderlines: true
@@ -40813,7 +41053,7 @@ const LinkPlain = props => {
40813
41053
  ,
40814
41054
 
40815
41055
  "data-variant": variant,
40816
- "data-current-effect-bold": currentEffectBold ? "" : undefined,
41056
+ "data-current-effect-bold": currentEffectBoldTechnique,
40817
41057
  "data-current-effect-shadow": currentEffectShadow ? "" : undefined,
40818
41058
  "data-current-indicator-position": currentIndicatorPosition,
40819
41059
  "data-anchor": anchor ? "" : undefined,
@@ -41051,6 +41291,10 @@ const css$W = /* css */`@layer navi {
41051
41291
  --nav-current-indicator-size: 2px;
41052
41292
  --nav-current-indicator-color: var(--navi-link-current-indicator-color);
41053
41293
  }
41294
+
41295
+ .navi_nav .navi_link {
41296
+ --link-current-indicator-color: var(--nav-current-indicator-color);
41297
+ }
41054
41298
  }
41055
41299
 
41056
41300
  .navi_nav[data-nav-indicator] {
@@ -41061,8 +41305,14 @@ const css$W = /* css */`@layer navi {
41061
41305
  var(--nav-indicator-position-delta));
41062
41306
  --x-nav-indicator-length: calc(var(--nav-indicator-length) + var(--slide-travel-progress) *
41063
41307
  var(--nav-indicator-length-delta));
41308
+ --x-nav-indicator-color: var(--nav-indicator-color, var(--nav-current-indicator-color));
41309
+ --x-nav-indicator-color-toward: var(--nav-indicator-color-toward, var(--x-nav-indicator-color));
41064
41310
  z-index: 1;
41065
- background: var(--nav-current-indicator-color);
41311
+ background: color-mix(in srgb,
41312
+ var(--x-nav-indicator-color-toward)
41313
+ calc(var(--slide-travel-progress) * var(--nav-indicator-toward-sign, 0) *
41314
+ 100%),
41315
+ var(--x-nav-indicator-color));
41066
41316
  pointer-events: none;
41067
41317
  border-radius: .1px;
41068
41318
  position: absolute;
@@ -41127,6 +41377,11 @@ const css$W = /* css */`@layer navi {
41127
41377
 
41128
41378
  &[data-scrollable] {
41129
41379
  max-width: 100%;
41380
+
41381
+ & .navi_link {
41382
+ white-space: nowrap;
41383
+ flex: none;
41384
+ }
41130
41385
  }
41131
41386
 
41132
41387
  &[data-scrollable][data-vertical] {
@@ -41162,13 +41417,13 @@ const css$W = /* css */`@layer navi {
41162
41417
  &[data-expand-x] {
41163
41418
  flex-grow: 1;
41164
41419
 
41165
- &:not([data-vertical]) .navi_link {
41420
+ &:not([data-vertical]):not([data-scrollable]) .navi_link {
41166
41421
  flex: 1;
41167
41422
  justify-content: center;
41168
41423
  }
41169
41424
  }
41170
41425
 
41171
- &[data-expand-y][data-vertical] {
41426
+ &[data-expand-y][data-vertical]:not([data-scrollable]) {
41172
41427
  & .navi_link {
41173
41428
  flex: 1;
41174
41429
  }
@@ -41256,6 +41511,10 @@ const positionOfCurrentIndicator = (currentIndicator, vertical) => {
41256
41511
  * @param {boolean|"top"|"bottom"|"left"|"right"} [props.currentIndicator] - the
41257
41512
  * bar that says which tab one is on, said once here rather than on every
41258
41513
  * `<Link>`. A link may still say otherwise for itself.
41514
+ * Its colour belongs to the tab it is under: `currentIndicatorColor` here is
41515
+ * what every tab inherits, and a tab declaring
41516
+ * `--nav-current-indicator-color` for itself is arrived at in its own colour
41517
+ * — the bar changes colour as it travels rather than once it lands.
41259
41518
  * @param {boolean} [props.currentIndicatorSlides=true] - whether that bar
41260
41519
  * travels from the tab it was under to the tab it is under now, instead of
41261
41520
  * going out on one and coming back on the other. For a nav made of routes it
@@ -41334,11 +41593,11 @@ const Nav = ({
41334
41593
  return navElement.querySelector(`[data-slide-target="${CSS.escape(towardArea)}"]`);
41335
41594
  };
41336
41595
 
41337
- // Where the trait is and where it is headed, as four numbers of pixels the
41338
- // CSS above interpolates between (see the .navi_nav_indicator rules). Written
41339
- // by hand rather than rendered: it is read off the row as it stands, and the
41340
- // travel it must agree with starts in the same frame the container publishes
41341
- // it — a render would land after the movement had begun.
41596
+ // Where the trait is, where it is headed, and in which colour at each end —
41597
+ // the numbers the CSS above interpolates between (see the .navi_nav_indicator
41598
+ // rules). Written by hand rather than rendered: it is read off the row as it
41599
+ // stands, and the travel it must agree with starts in the same frame the
41600
+ // container publishes it — a render would land after the movement had begun.
41342
41601
  const paintIndicatorGeometry = () => {
41343
41602
  const navElement = navRef.current;
41344
41603
  const containerElement = slideContainerElementRef.current;
@@ -41360,13 +41619,29 @@ const Nav = ({
41360
41619
  position: tabElement.offsetLeft,
41361
41620
  length: tabElement.offsetWidth
41362
41621
  };
41622
+ // The colour a tab asks the bar to be under it. Read from the tab and not
41623
+ // from the row, so a tab that says nothing gives the row's value back —
41624
+ // it inherits it (see the css above) — and one that says something gives
41625
+ // its own. Empty when neither resolves to anything: the css then falls
41626
+ // back on its own, rather than being handed a colour that is not one.
41627
+ const colorOf = tabElement => getComputedStyle(tabElement).getPropertyValue("--link-current-indicator-color").trim();
41628
+ const writeColor = (property, color) => {
41629
+ if (color) {
41630
+ navElement.style.setProperty(property, color);
41631
+ } else {
41632
+ navElement.style.removeProperty(property);
41633
+ }
41634
+ };
41363
41635
  const currentMeasure = measure(tabElements[currentIndex]);
41364
41636
  const towardArea = containerElement.getAttribute("data-slide-travel-toward");
41365
41637
  const towardIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === towardArea);
41366
41638
  let positionDelta = 0;
41367
41639
  let lengthDelta = 0;
41640
+ let towardSign = 0;
41641
+ let towardColor = "";
41368
41642
  if (towardIndex !== -1 && towardIndex !== currentIndex) {
41369
- const towardMeasure = measure(tabElements[towardIndex]);
41643
+ const towardTabElement = tabElements[towardIndex];
41644
+ const towardMeasure = measure(towardTabElement);
41370
41645
  // What one box of travel is worth in pixels of this row, signed so that
41371
41646
  // the trait is exactly on the other tab when the progress is at its own
41372
41647
  // end: the container counts +1 when the picture leans on a slide sitting
@@ -41374,6 +41649,8 @@ const Nav = ({
41374
41649
  const sign = towardIndex > currentIndex ? -1 : 1;
41375
41650
  positionDelta = (towardMeasure.position - currentMeasure.position) * sign;
41376
41651
  lengthDelta = (towardMeasure.length - currentMeasure.length) * sign;
41652
+ towardSign = sign;
41653
+ towardColor = colorOf(towardTabElement);
41377
41654
  }
41378
41655
  const {
41379
41656
  style
@@ -41382,6 +41659,9 @@ const Nav = ({
41382
41659
  style.setProperty("--nav-indicator-length", currentMeasure.length);
41383
41660
  style.setProperty("--nav-indicator-position-delta", positionDelta);
41384
41661
  style.setProperty("--nav-indicator-length-delta", lengthDelta);
41662
+ style.setProperty("--nav-indicator-toward-sign", towardSign);
41663
+ writeColor("--nav-indicator-color", colorOf(tabElements[currentIndex]));
41664
+ writeColor("--nav-indicator-color-toward", towardColor);
41385
41665
  navElement.setAttribute("data-nav-indicator-measured", "");
41386
41666
  };
41387
41667
  // Reached through a ref by everything watching the DOM below: those watchers
@@ -42857,7 +43137,15 @@ installImportMetaCssBuild(import.meta);/**
42857
43137
  * keeps its bars against its own edges.
42858
43138
  * 2. **It gives its space back.** Being fixed it covers the content: without a
42859
43139
  * reserve the end of a long page stays under it, unreachable. It publishes
42860
- * what it takes on <html> — see fixed_bar_space.js.
43140
+ * what it takes on <html> — see fixed_bar_space.js. An edge holds ONE bar:
43141
+ * bars sharing an edge are pinned to it one over the other, so the room
43142
+ * given back is the largest of them and never their sum — which is what the
43143
+ * two bars of a page transition need, and what makes a deliberately stacked
43144
+ * second bar unexpressible. A second strip under the first belongs to the
43145
+ * flow: `position: sticky` at `top: var(--navi-fixed-bar-space-top)`, which
43146
+ * puts it under whatever the bar of the moment is worth, and
43147
+ * `useTransitionCover` (nav/transition_cover.js) so the pictures of a route
43148
+ * movement are cut at it.
42861
43149
  * 3. **It reaches under the notch.** `env(safe-area-inset-*)` is padding, so
42862
43150
  * the content is pushed in while the background keeps running to the edge of
42863
43151
  * the screen. Across the bar that inset is added to `width`/`height` too, so
@@ -42972,7 +43260,9 @@ const FixedBarStyleCSSVars = {
42972
43260
  * borderColor?: string,
42973
43261
  * }>}
42974
43262
  * @param {"top"|"bottom"|"left"|"right"} [props.area="top"] - Which edge of
42975
- * the window it is pinned to.
43263
+ * the window it is pinned to. One bar per edge: a second one does not stack
43264
+ * under the first, it sits on it (see this file's top comment for the strip
43265
+ * that does stack).
42976
43266
  * @param {string|number} [props.height] - For a bar on the top or bottom
42977
43267
  * @param {string|number} [props.width] - …and for one on a side. The safe-area
42978
43268
  * inset is NOT part of it: it is added on top, so the content keeps the size
@@ -43610,255 +43900,868 @@ const DetailsFieldContent = ({
43610
43900
  });
43611
43901
  };
43612
43902
 
43613
- /**
43614
- * Runs `fn` and commits whatever it re-renders before returning, instead of
43615
- * letting Preact batch it into the next microtask. Layout effects of what gets
43616
- * mounted run inside the call too, exactly as they would on any other commit.
43617
- *
43618
- * For the caller that has to read the DOM it just asked for — measuring an
43619
- * element whose content it mounts in the same breath — and cannot wait a tick
43620
- * to do it, because what comes after is a browser event still in flight
43621
- * (preventDefault, focus placement) that no longer accepts being answered late.
43622
- *
43623
- * `options.debounceRendering` is Preact's own hook for deciding *when* the
43624
- * render queue drains; swapping it for "right now" for the duration of the call
43625
- * is exactly how preact/compat implements React's flushSync. Reserve it for the
43626
- * case above: rendering synchronously in the middle of an event gives up the
43627
- * batching that makes several state changes one commit.
43628
- */
43629
- const flushSyncRendering = (fn) => {
43630
- const debounceRenderingPrevious = options.debounceRendering;
43631
- options.debounceRendering = (drainRenderQueue) => {
43632
- drainRenderQueue();
43633
- };
43634
- try {
43635
- fn();
43636
- } finally {
43637
- options.debounceRendering = debounceRenderingPrevious;
43638
- }
43639
- };
43640
-
43641
- /**
43642
- * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
43643
- * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
43644
- * of which of the two owns it.
43645
- */
43646
-
43903
+ // How long a popup waits before handing the focus to a field, when giving it
43904
+ // is what raises the on-screen keyboard.
43905
+ //
43906
+ // The focus is normally given as early as possible. But a popup places itself
43907
+ // against the viewport, and on a phone the keyboard takes a third of that
43908
+ // viewport away the moment a field receives focus — so the two landing in the
43909
+ // same tick means the popup is still arriving when the room under it changes,
43910
+ // and it re-places itself mid-entrance. Waiting lets it settle first, and the
43911
+ // keyboard then shrinks a box that has stopped moving.
43912
+ //
43913
+ // Long enough to outlast an entrance transition rather than merely reaching
43914
+ // the next frame: what has to be over is the popup MOVING, not one paint of
43915
+ // it.
43916
+ const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
43647
43917
 
43648
43918
  /**
43649
- * Whether a visibleRectEffect delivery is one that can have taken height away
43650
- * from a popup, and so pushed whatever holds focus out of sight:
43651
- * - "resize": the window/visual viewport settled — which is also how the
43652
- * on-screen keyboard arrives, overlay or not (window_size.js);
43653
- * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
43654
- * - "focusin": the focus-settled re-measure, for the room that changes with
43655
- * nothing announcing it (subscribeFocusSettled in window_size.js).
43919
+ * Owns open/close decision-making for a popup (Dialog or Popover): guards
43920
+ * against duplicate requests and notifies the popup owner's own reactions.
43656
43921
  *
43657
- * Everything else is a scroll of one kind or another, where nothing got
43658
- * smaller and scrolling the focused element back would fight the very gesture
43659
- * that fired it.
43660
- */
43661
- const mayHaveHiddenFocus = (event) => {
43662
- const type = event?.type;
43663
- return (
43664
- type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
43665
- );
43666
- };
43667
-
43668
- /**
43669
- * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
43670
- * getting shorter has pushed it out.
43922
+ * `controller.openEffect` is implemented by the controlled element (Dialog or
43923
+ * Popover), reassigned on every render so it always closes over the latest
43924
+ * props (scrollCapture, anchor, etc.). It performs whatever DOM side effects
43925
+ * are needed to make the element actually open (`showModal()`/`showPopover()`,
43926
+ * focus transfer, positioning, traps...) and returns its cleanup —
43927
+ * the matching side effects to sync back to closed (`close()`/
43928
+ * `hidePopover()`, releasing traps...). That cleanup is kept private to the
43929
+ * controller (not exposed as a property) and invoked when the popup actually
43930
+ * closes, however that happens.
43671
43931
  *
43672
- * The case this exists for: a field low in the scrolling body of a popup that
43673
- * also has a footer (box.jsx — with a body, the body is the only thing that
43674
- * scrolls and the footer is a sibling sitting right under it). Focusing the
43675
- * field makes the browser scroll it into view, which it does against the
43676
- * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
43677
- * that height away. The body shrinks, its scrollTop does not move, so the
43678
- * content slides down relative to the shorter scrollport and the field ends up
43679
- * past its bottom edge — visually, swallowed by the footer. The browser does
43680
- * not redo a scroll-into-view it already answered, so this does.
43932
+ * Dialog/Popover also call `openController.requestClose(e, { isCancel })` for
43933
+ * their own internal triggers (backdrop click, Escape).
43681
43934
  *
43682
- * Scoped to the field's own scroll container (never the page): a popup traps
43683
- * scrolling precisely so the document underneath cannot move, and a plain
43684
- * scrollIntoView walks past a container whose scrollbar isn't visible — see
43685
- * scrollIntoViewScoped's own doc.
43935
+ * `openHandler` is the popup owner's own business logic, passed once to
43936
+ * `createOpenController`. Its return value is `{ onRequestClose, onClose }`,
43937
+ * in the spirit of CloseWatcher
43938
+ * (https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher) but with
43939
+ * clearer naming than its cancel/close pair:
43940
+ * - `onRequestClose(e)`: about to close — call `e.preventDefault()` to stay
43941
+ * open. Validation lives here.
43942
+ * - `onClose(e)`: actually closing, not preventable — final reactions live here.
43686
43943
  *
43687
- * "nearest": the smallest scroll that makes it visible, and none at all when
43688
- * it already is — so this is free to call on every resize, and never fights
43689
- * where the user had scrolled to.
43944
+ * The controller exposes matching action methods:
43945
+ * - `open()`: requests opening — calls the caller's `onOpen` (see below), then
43946
+ * `mountContent`/`openEffect`, then `openHandler`.
43947
+ * - `requestClose()`: requests closing — calls `onRequestClose` then `onClose`,
43948
+ * stopping after the first if denied. The popup may choose to stay open,
43949
+ * which is what a `false` return says (`true`: closed, or closed already).
43950
+ * - `close()`: closes for real — calls only `onClose`, skipping
43951
+ * `onRequestClose` entirely. Used when there really is no choice (e.g. the
43952
+ * popup unmounting).
43690
43953
  */
43691
- const keepFocusedElementVisible = (popupEl) => {
43692
- const { activeElement } = document;
43693
- if (!activeElement || activeElement === popupEl) {
43694
- return;
43695
- }
43696
- if (!popupEl.contains(activeElement)) {
43697
- return;
43698
- }
43699
- scrollIntoViewScoped(activeElement, { block: "nearest" });
43700
- };
43954
+ const createOpenController = (
43955
+ openHandler,
43956
+ { debugInteraction } = {},
43957
+ ) => {
43958
+ let closeHandlers = null; // { onRequestClose, onClose } returned by openHandler
43959
+ let openEffectCleanup = null; // function returned by openEffect, undoes its DOM side effects
43960
+ let focusedAtClose = null; // what held the focus when the close was decided, see performClose
43701
43961
 
43702
- /**
43703
- * Calls `onSettled` once `el`'s current CSS transition is over — via
43704
- * `transitionend`, with a safety `setTimeout` fallback matching the longest
43705
- * `transition-duration`, in case nothing actually transitions or an event is
43706
- * missed.
43707
- *
43708
- * Returns a "cancel" function, so a caller whose instance has been superseded
43709
- * (a fresh open/close about to set its own state) can keep this stale one from
43710
- * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
43711
- * did up front is that fresh call's business, not this one's.
43712
- */
43713
- const whenTransitionSettles = (el, onSettled) => {
43714
- let settled = false;
43715
- const onTransitionEnd = (transitionEvent) => {
43716
- if (transitionEvent.target === el) {
43717
- finish();
43718
- }
43719
- };
43720
- const stopWatching = () => {
43721
- settled = true;
43722
- el.removeEventListener("transitionend", onTransitionEnd);
43723
- clearTimeout(safetyTimeoutId);
43724
- };
43725
- const finish = () => {
43726
- if (settled) {
43727
- return;
43728
- }
43729
- stopWatching();
43730
- onSettled();
43731
- };
43732
- el.addEventListener("transitionend", onTransitionEnd);
43733
- const durationsInSeconds = getComputedStyle(el)
43734
- .transitionDuration.split(",")
43735
- .map((value) => parseFloat(value) || 0);
43736
- const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
43737
- const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
43738
- return () => {
43739
- if (settled) {
43740
- return;
43741
- }
43742
- stopWatching();
43962
+ // Set true while we're waiting to see whether the click that follows a
43963
+ // mousedown-close will land back on whatever would reopen us — see
43964
+ // armSuppressNextOpenRequest below.
43965
+ let suppressNextOpenRequest = false;
43966
+ let disarmSuppressNextOpenRequest = null;
43967
+
43968
+ // When the popup closes because of a mousedown (e.g. clicking the
43969
+ // backdrop), the browser still dispatches the matching "click" afterward.
43970
+ // If that click lands back on the element that triggers open() (e.g. the
43971
+ // picker button), it would immediately reopen the popup. We cannot
43972
+ // preventDefault/stopPropagation the mousedown to stop that — the browser
43973
+ // dispatches the click regardless.
43974
+ //
43975
+ // Instead: arm a capture-phase "click" listener on document. Capture fires
43976
+ // before the click reaches its target, so by the time any bubble-phase
43977
+ // click handler (e.g. the trigger button's onClick, which calls
43978
+ // controller.open()) runs, `suppressNextOpenRequest` is already true and
43979
+ // open() ignores the request — no need to know *which* element triggers
43980
+ // it. A bubble-phase listener (runs after everything else, once the click
43981
+ // reaches document) clears the flag if nothing consumed it, meaning this
43982
+ // click never resulted in an open() call. A timeout is a last-resort safety
43983
+ // net in case the click never reaches document at all (e.g. some ancestor
43984
+ // called stopPropagation()) — a *task*, never a microtask: a microtask
43985
+ // checkpoint runs between two listeners of the same trusted event dispatch,
43986
+ // so it would clear the flag before the bubble-phase handler this is meant
43987
+ // to block ever runs, which is precisely the case it exists for.
43988
+ const armSuppressNextOpenRequest = () => {
43989
+ disarmSuppressNextOpenRequest?.();
43990
+ let safetyTimeout = null;
43991
+ const onCaptureClick = () => {
43992
+ document.removeEventListener("click", onCaptureClick, {
43993
+ capture: true,
43994
+ });
43995
+ suppressNextOpenRequest = true;
43996
+ document.addEventListener("click", onBubbleClick);
43997
+ safetyTimeout = setTimeout(() => {
43998
+ suppressNextOpenRequest = false;
43999
+ });
44000
+ };
44001
+ const onBubbleClick = () => {
44002
+ document.removeEventListener("click", onBubbleClick);
44003
+ clearTimeout(safetyTimeout);
44004
+ suppressNextOpenRequest = false;
44005
+ };
44006
+ disarmSuppressNextOpenRequest = () => {
44007
+ clearTimeout(safetyTimeout);
44008
+ document.removeEventListener("click", onCaptureClick, {
44009
+ capture: true,
44010
+ });
44011
+ document.removeEventListener("click", onBubbleClick);
44012
+ };
44013
+ document.addEventListener("click", onCaptureClick, { capture: true });
43743
44014
  };
43744
- };
43745
44015
 
43746
- /**
43747
- * Disables pointer-events on `el` until its current CSS transition settles —
43748
- * avoids the cursor changing/something becoming clickable while the popup is
43749
- * still visually moving into or out of place.
43750
- *
43751
- * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
43752
- * pointer-events, since a fresh call for the next open/close is about to set
43753
- * its own state.
43754
- */
43755
- const suppressPointerEventsDuringTransition = (el) => {
43756
- el.style.pointerEvents = "none";
43757
- return whenTransitionSettles(el, () => {
43758
- el.style.pointerEvents = "";
43759
- });
43760
- };
44016
+ const performClose = (closeEvent) => {
44017
+ controller.opened = false;
44018
+ // Read before any close effect touches the DOM: closing a native <dialog>
44019
+ // hands the focus back to whatever held it at showModal() time, so by the
44020
+ // time the close cleanup runs, the popup's content has already lost the
44021
+ // focus and could not be remembered for the next open.
44022
+ focusedAtClose = document.activeElement;
43761
44023
 
43762
- /**
43763
- * Hides the backdrop, deferring until the browser's matching "click" fires
43764
- * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
43765
- * comment for why) — same capture-phase-on-document pattern as
43766
- * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
43767
- * can't safely replace: mouseup (and the click that follows it) can land an
43768
- * arbitrarily long time after mousedown (the user is still holding the
43769
- * button down), so a short timeout can fire first and hide the backdrop
43770
- * before its own click ever arrives. A capture-phase listener on document
43771
- * fires for every click regardless of what any bubble-phase handler does
43772
- * downstream, so no fallback timer is needed.
43773
- *
43774
- * `hide` is the caller's own way to actually hide the backdrop
43775
- * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
43776
- * for a plain div) — this helper only owns the mousedown/click timing.
43777
- *
43778
- * Returns a disarm function (or undefined if hidden immediately), so a
43779
- * fresh open can cancel a pending hide it's about to make redundant.
43780
- */
43781
- const armPointerDownOutsideClose = (closeEvent, hide) => {
43782
- const mousedownEvent = findEvent(closeEvent, "mousedown");
43783
- if (!mousedownEvent) {
43784
- hide();
43785
- return undefined;
43786
- }
43787
- const onClick = () => {
43788
- document.removeEventListener("click", onClick, { capture: true });
43789
- hide();
43790
- };
43791
- document.addEventListener("click", onClick, { capture: true });
43792
- return () => {
43793
- document.removeEventListener("click", onClick, { capture: true });
43794
- };
43795
- };
44024
+ prevent_reopen: {
44025
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
44026
+ if (mousedownEvent) {
44027
+ debugInteraction(
44028
+ closeEvent,
44029
+ `closed by mousedown -> ignore next click`,
44030
+ );
44031
+ armSuppressNextOpenRequest();
44032
+ break prevent_reopen;
44033
+ }
43796
44034
 
43797
- /**
43798
- * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
43799
- * `prefix` plus a direction word), or `null` if both axes overlap the anchor
43800
- * (no direction at all — that's `resolvedAnimationKind === "scaling"`
43801
- * territory instead, see resolveAutoAnimationKind below).
43802
- *
43803
- * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
43804
- * when docked) keeps the word as the compass direction the popup comes
43805
- * from: placed "top" (a point/corner), it slides in from the top.
43806
- * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
43807
- * direction instead, the opposite compass point: placed "top" of the
43808
- * anchor, it moves/grows up, away from the anchor (which sits below it).
43809
- *
43810
- * "inset-*"/"center" contribute no direction on their axis either way.
43811
- */
43812
- const resolveDirectionValue = (y, x, { prefix }) => {
43813
- const yWord =
43814
- y === "top"
43815
- ? prefix === "expand"
43816
- ? "up"
43817
- : "top"
43818
- : y === "bottom"
43819
- ? prefix === "expand"
43820
- ? "down"
43821
- : "bottom"
43822
- : null;
43823
- const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
43824
- if (!yWord && !xWord) {
43825
- return null;
43826
- }
43827
- return yWord && xWord
43828
- ? `${prefix}-${yWord}-${xWord}`
43829
- : `${prefix}-${yWord || xWord}`;
43830
- };
44035
+ // The keyboard counterpart of the mousedown case above: a key press that
44036
+ // closes the popup and then goes on to activate the trigger, reopening it
44037
+ // on the spot. Space and Enter both get there, but not the same way and
44038
+ // not always — preventing the key unconditionally would eat presses that
44039
+ // were never going to activate anything (a space typed in a field, an
44040
+ // Enter the popup's own handler already consumed), so each is verified
44041
+ // before being prevented.
43831
44042
 
43832
- /**
43833
- * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
43834
- * — picked for any real anchor, or for a point/corner placed dead-center
43835
- * (both positionArea axes overlapping — there's no sensible direction to
43836
- * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
43837
- * for any no-anchor/docked case (Dialog always, Popover's own custom
43838
- * renderer when there's no real anchor), so this collapses to "scaling"
43839
- * there only for the dead-center case, "sliding" otherwise. The two
43840
- * "overlapping" booleans below describe the *positionArea* itself (a bare
43841
- * word vs. "inset-"/"center"), not anything about the anchor — they'd
43842
- * mean exactly the same thing even with no anchor at all, since it's the
43843
- * position strategy, not the anchor, that decides whether there's a
43844
- * direction to slide from.
43845
- */
43846
- const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
43847
- const yIsOverlapping =
43848
- parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
43849
- const xIsOverlapping =
43850
- parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
43851
- return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
43852
- };
44043
+ // Space: pressed on the trigger itself, which still has focus (closing
44044
+ // does not move it away from an element outside the popup). The browser
44045
+ // turns that press into a click on keyup, and that click lands back on
44046
+ // the trigger. Asked of the browser's own default action rather than
44047
+ // guessed from the tag name: a space that scrolls, or types into a field
44048
+ // inside the popup, has no activation to prevent and preventing it would
44049
+ // swallow the scroll / the character.
44050
+ const spaceKeyEvent = findEvent(
44051
+ closeEvent,
44052
+ (e) => e.type === "keydown" && e.key === " ",
44053
+ );
44054
+ if (
44055
+ spaceKeyEvent &&
44056
+ getKeyboardEventDefaultAction(spaceKeyEvent) === "activate"
44057
+ ) {
44058
+ debugInteraction(
44059
+ closeEvent,
44060
+ `closed by space on <${spaceKeyEvent.target.tagName.toLowerCase()}> -> prevent the click it would produce (space.preventDefault())`,
44061
+ );
44062
+ // The browser won't dispatch the click, and our "space_to_open" sees
44063
+ // defaultPrevented too so it won't try to open the picker either.
44064
+ spaceKeyEvent.preventDefault();
44065
+ break prevent_reopen;
44066
+ }
43853
44067
 
43854
- /**
43855
- * When a popup builds what it holds, and when it throws it away.
43856
- *
43857
- * A closed popup shows nothing, focuses nothing, and answers nothing: what it
43858
- * holds is out of reach until it opens. Building that content at mount time
43859
- * means a page carrying a handful of closed popups pays, on the very render
43860
- * that decides how fast it appears, for content nobody has asked for — and
43861
- * pays again on every subsequent measurement, since each of those nodes makes
44068
+ // Enter: pressed inside the popup (its own submit button, or implicit
44069
+ // submission from a field it contains). The popup closes synchronously
44070
+ // and focus is restored to the trigger, so the activation the browser
44071
+ // still owes this press is delivered to the trigger instead.
44072
+ //
44073
+ // Verified on two counts: the press still owes an activation (the
44074
+ // browser's default action for it is one — "activate" on a submit button,
44075
+ // "form_submit" on a field — and nothing has consumed it yet), and it came
44076
+ // from inside the popup. An Enter from outside is not this case at all.
44077
+ const enterKeyEvent = findEvent(
44078
+ closeEvent,
44079
+ (e) => e.type === "keydown" && e.key === "Enter",
44080
+ );
44081
+ if (
44082
+ enterKeyEvent &&
44083
+ !enterKeyEvent.defaultPrevented &&
44084
+ ENTER_ACTIVATING_DEFAULT_ACTION_SET.has(
44085
+ getKeyboardEventDefaultAction(enterKeyEvent),
44086
+ ) &&
44087
+ isInsideOpenPopup(enterKeyEvent.target)
44088
+ ) {
44089
+ debugInteraction(
44090
+ closeEvent,
44091
+ `closed by enter from inside the popup -> prevent the activation it would deliver to the trigger (enter.preventDefault())`,
44092
+ );
44093
+ enterKeyEvent.preventDefault();
44094
+ break prevent_reopen;
44095
+ }
44096
+ }
44097
+
44098
+ // Sync the DOM closed first (releasing the focus trap) — only then run
44099
+ // the owner's own reaction (onClose may restore focus to an element
44100
+ // outside the popup, which the focus trap would otherwise fight while
44101
+ // still active).
44102
+ openEffectCleanup?.(closeEvent);
44103
+ openEffectCleanup = null;
44104
+ closeHandlers?.onClose?.(closeEvent);
44105
+ closeHandlers = null;
44106
+ // Last: the close effects above are what starts the exit transition the
44107
+ // content must outlive (see popup_content_mount.js).
44108
+ controller.unmountContent?.();
44109
+ controller.onOpenedChange?.(false, closeEvent);
44110
+ };
44111
+ const controller = {
44112
+ opened: false,
44113
+ openEffect: null,
44114
+ // Set by the controlled element (see popup_content_mount.js) when its
44115
+ // content is still waiting for a first open to be built. Called below,
44116
+ // before openEffect, so the popup measures and positions the real thing.
44117
+ mountContent: null,
44118
+ // The caller's own `onOpen`, set by Dialog/Popover from their props on
44119
+ // every render (like openEffect). Called BEFORE mountContent, so whatever
44120
+ // it decides — which record this dialog is opening on — is already true by
44121
+ // the time the content is built, positioned and shown. That order is the
44122
+ // whole point: learning it afterwards means the content mounted on the
44123
+ // previous subject first.
44124
+ onOpen: null,
44125
+ // The counterpart, set only when the popup was told to throw its content
44126
+ // away on close (`mount="while-opened"`). Called from performClose above.
44127
+ unmountContent: null,
44128
+ // Told whenever `opened` actually changes, whatever asked for it — an
44129
+ // interaction, a command, a prop — with the event that asked. What lets a
44130
+ // `signal` prop reflect the popup's real state, and a `navState` prop write
44131
+ // it into the history entry (see useOpenPropsEffectOnOpenController);
44132
+ // called once the open/close has fully happened rather than mid-sequence.
44133
+ onOpenedChange: null,
44134
+ open: (e, detail) => {
44135
+ if (controller.opened || !controller.openEffect) {
44136
+ return;
44137
+ }
44138
+ if (suppressNextOpenRequest) {
44139
+ suppressNextOpenRequest = false;
44140
+ return;
44141
+ }
44142
+ const requestOpenEvent = new CustomEvent("navi_request_open", {
44143
+ detail: { event: e, ...detail },
44144
+ cancelable: true,
44145
+ });
44146
+ chainEvent(requestOpenEvent, e);
44147
+ // we prepare focus transfer before actually opening the popover/dialog
44148
+ // because opnening dialog makes browser try to transfer focus (which ends up in document.body for instance)
44149
+ const focusTransfer = prepareFocusTransfer(
44150
+ requestOpenEvent,
44151
+ debugInteraction,
44152
+ );
44153
+ controller.transferFocusOnOpen = (el) => {
44154
+ // requestOpenEvent, not the raw `e` — getFocusedBeforeTransfer needs
44155
+ // e.detail.eventChain (built by chainEvent above) to recover the
44156
+ // element a mousedown/click landed on. `e` itself is usually the raw
44157
+ // native event: its own `.detail` is a number (click count) on a
44158
+ // MouseEvent, so `e.detail.eventChain` is always undefined and the
44159
+ // mousedown/click branches below never matched — silently falling
44160
+ // back to `document.activeElement`, which is often `document.body`
44161
+ // once mousedown.preventDefault() has kept focus from landing
44162
+ // anywhere yet.
44163
+
44164
+ // Two conditions, and both are about THIS opening rather than about
44165
+ // the device:
44166
+ // - the interaction: only a finger raises a virtual keyboard, and a
44167
+ // hybrid tablet answers "coarse" to every device-level signal
44168
+ // whichever of its two inputs was just used — the open event still
44169
+ // remembers which one it was. An opening with no pointer in it at
44170
+ // all (a keyboard shortcut, defaultOpen, an app calling open()) is
44171
+ // not one either.
44172
+ // - the target: focusing a button raises nothing, so there is nothing
44173
+ // to wait for and the focus stays immediate. Only a field the
44174
+ // keyboard comes up for is worth delaying — which is why the
44175
+ // decision is taken on the resolved target, inside transferFocus.
44176
+ const openedByTouch = Boolean(
44177
+ findEvent(requestOpenEvent, isTouchDrivenEvent),
44178
+ );
44179
+ const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
44180
+ getDelay: (target) =>
44181
+ openedByTouch && isEditableTarget(target)
44182
+ ? FOCUS_DELAY_ON_KEYBOARD_MS
44183
+ : 0,
44184
+ });
44185
+ return (closeEvent) => {
44186
+ // Closed before the delay was up: the focus was never given, so it
44187
+ // must not be given now — to a field inside a popup on its way out,
44188
+ // raising the keyboard as it goes.
44189
+ cancelPendingFocus?.();
44190
+ markAutofocusRestoreOnClose(el, closeEvent, focusedAtClose);
44191
+ const focusoutEvent = findEvent(closeEvent, "focusout");
44192
+ if (focusoutEvent) {
44193
+ debugInteraction(
44194
+ closeEvent,
44195
+ `closed by focusout -> let focus go away`,
44196
+ );
44197
+ } else {
44198
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
44199
+ if (mousedownEvent) {
44200
+ debugInteraction(
44201
+ closeEvent,
44202
+ "closed by mousedown -> prevent browser focus (mousedown.preventDefault())",
44203
+ );
44204
+ mousedownEvent.preventDefault();
44205
+ }
44206
+ focusTransfer.restoreFocus();
44207
+ }
44208
+ };
44209
+ };
44210
+ // Before mountContent, which builds the content, and before openEffect,
44211
+ // which shows it: what the popup opens ON has to be known before either
44212
+ // (see `onOpen` above).
44213
+ controller.onOpen?.(requestOpenEvent);
44214
+ // After prepareFocusTransfer, which has to record what held the focus
44215
+ // before anything inside the popup can claim it, and before openEffect,
44216
+ // which measures the popup to place it.
44217
+ controller.mountContent?.();
44218
+ // Only now — after the content has been built, before openEffect shows
44219
+ // it. Dialog/Popover recompute aria-expanded and navi-hidden from this
44220
+ // flag on every render, and mountContent above renders synchronously:
44221
+ // flipping it any earlier commits an already-open DOM (aria-expanded
44222
+ // "true", navi-hidden gone) before openEffect has run a single
44223
+ // statement, so the "closed" frame it pins to transition from is in
44224
+ // fact the open one and the entrance animation has nothing to play.
44225
+ // It also gives the content it just built the opening it is documented
44226
+ // to observe — mounted while the popup reads as closed, told it opened
44227
+ // right after (see popup_content_mount.js and
44228
+ // use_displayed_layout_effect.js).
44229
+ controller.opened = true;
44230
+ const openEffectReturnValue =
44231
+ controller.openEffect(requestOpenEvent) || null;
44232
+ openEffectCleanup = (closeEvent) => {
44233
+ openEffectReturnValue?.(closeEvent);
44234
+ };
44235
+ closeHandlers = openHandler(requestOpenEvent) || null;
44236
+ controller.onOpenedChange?.(true, requestOpenEvent);
44237
+ },
44238
+ requestClose: (
44239
+ e = new CustomEvent("programmatic", { detail: {} }),
44240
+ detail,
44241
+ ) => {
44242
+ if (!controller.opened) {
44243
+ return true;
44244
+ }
44245
+ const requestCloseEvent = new CustomEvent("navi_request_close", {
44246
+ detail: { event: e, ...detail },
44247
+ cancelable: true,
44248
+ });
44249
+ chainEvent(requestCloseEvent, e);
44250
+ closeHandlers?.onRequestClose?.(requestCloseEvent);
44251
+ if (requestCloseEvent.defaultPrevented) {
44252
+ // The native <dialog> "cancel" event (Escape key) closes the dialog
44253
+ // by default; prevent that default so denial actually keeps it open.
44254
+ const nativeCancelEvent = findEvent(requestCloseEvent, "cancel");
44255
+ if (nativeCancelEvent) {
44256
+ nativeCancelEvent.preventDefault();
44257
+ }
44258
+ return false;
44259
+ }
44260
+ performClose(requestCloseEvent);
44261
+ return true;
44262
+ },
44263
+ close: (e = new CustomEvent("programmatic", { detail: {} }), detail) => {
44264
+ if (!controller.opened) {
44265
+ return;
44266
+ }
44267
+ const closeEvent = new CustomEvent("navi_close", {
44268
+ detail: { event: e, ...detail },
44269
+ });
44270
+ chainEvent(closeEvent, e);
44271
+ // Skips onRequestClose entirely — there is no choice here.
44272
+ performClose(closeEvent);
44273
+ },
44274
+ };
44275
+ return controller;
44276
+ };
44277
+
44278
+ // Inside a popup that is open right now — the popup being closed, in practice,
44279
+ // since that is the one the key press was delivered to.
44280
+ const isInsideOpenPopup = (element) => {
44281
+ if (!element || element.nodeType !== 1) {
44282
+ return false;
44283
+ }
44284
+ return Boolean(element.closest("dialog[open], [popover]:popover-open"));
44285
+ };
44286
+
44287
+ // What Enter is about to do when it is about to activate something: press the
44288
+ // focused control, or submit the form around it. Anything else it can do
44289
+ // (typing a newline, nothing at all) leaves no activation behind to land on the
44290
+ // trigger once focus is restored.
44291
+ const ENTER_ACTIVATING_DEFAULT_ACTION_SET = new Set([
44292
+ "activate",
44293
+ "form_submit",
44294
+ ]);
44295
+
44296
+ // Created once per popup instance: openHandler is wrapped in a stable callback
44297
+ // so the controller identity never changes across renders, even though
44298
+ // Dialog/Popover read fresh closures (scrollTrap, etc.) via
44299
+ // openController.openEffect on every render.
44300
+ const useOpenController = (openHandler) => {
44301
+ const debugInteraction = useDebugInteraction();
44302
+ const stableOpenHandler = useStableCallback(openHandler);
44303
+ const controllerRef = useRef(null);
44304
+ if (!controllerRef.current) {
44305
+ controllerRef.current = createOpenController(stableOpenHandler, {
44306
+ debugInteraction,
44307
+ });
44308
+ }
44309
+ // Unmount safety net: if Dialog/Popover unmounts while still open (parent
44310
+ // removes it from the tree without going through requestClose()), there is
44311
+ // no choice to leave open — close it for real.
44312
+ useLayoutEffect(() => {
44313
+ return () => {
44314
+ controllerRef.current.close();
44315
+ };
44316
+ }, []);
44317
+ return controllerRef.current;
44318
+ };
44319
+
44320
+ // Nested popups that both mount already-open (`open`/`defaultOpen`) would
44321
+ // otherwise stack in the wrong order: Preact fires layout effects
44322
+ // child-first on mount, so a nested popup's own mount-open would call
44323
+ // showPopover() before its ancestor's — and the top layer stacks *later*
44324
+ // showPopover() calls above *earlier* ones (see popover.jsx's own openEffect
44325
+ // comment) — leaving the ancestor on top instead of the nested popup, the
44326
+ // opposite of what opening them one at a time (ancestor first, by real user
44327
+ // interaction) would produce. Batching every mount-time silent open queued
44328
+ // during the same commit's layout-effect phase into one microtask flush,
44329
+ // then simply running them in *reverse* of their registration order fixes
44330
+ // this — no need to compare DOM positions: since effects already fire
44331
+ // child-first, tree-wide, for *any* ancestor/descendant pair the descendant
44332
+ // is always queued before the ancestor, regardless of what else is in the
44333
+ // tree, so reversing the whole batch always puts every ancestor before its
44334
+ // own descendants. Works for any nesting depth for the same reason. Two
44335
+ // unrelated (sibling) popups both mounting open also get reordered
44336
+ // relative to each other, but there's no meaningful "correct" order between
44337
+ // those anyway.
44338
+ let pendingMountOpens = [];
44339
+ let mountOpenFlushScheduled = false;
44340
+ const scheduleMountOpen = (run) => {
44341
+ pendingMountOpens.push(run);
44342
+ if (mountOpenFlushScheduled) {
44343
+ return;
44344
+ }
44345
+ mountOpenFlushScheduled = true;
44346
+ queueMicrotask(() => {
44347
+ const entries = pendingMountOpens;
44348
+ pendingMountOpens = [];
44349
+ mountOpenFlushScheduled = false;
44350
+ for (let i = entries.length - 1; i >= 0; i--) {
44351
+ entries[i]();
44352
+ }
44353
+ });
44354
+ };
44355
+
44356
+ // Where the popup's open state is kept, when it is kept anywhere: `navState`
44357
+ // resolved to the `{ id, type }` useNavState wants.
44358
+ //
44359
+ // `true` takes the popup's own id — a popup a `--navi-open` command can name is
44360
+ // a popup that already has a stable one, and that id is what identifies its
44361
+ // open state too.
44362
+ const NO_NAV_STATE = { id: undefined, type: "replace" };
44363
+ const resolveNavStateProp = (navState, popupId, name) => {
44364
+ if (!navState) {
44365
+ return NO_NAV_STATE;
44366
+ }
44367
+ if (navState === true) {
44368
+ return { id: popupId, type: "replace" };
44369
+ }
44370
+ if (typeof navState === "string") {
44371
+ return { id: navState, type: "replace" };
44372
+ }
44373
+ return { id: navState.id || popupId, type: navState.type || "replace" };
44374
+ };
44375
+
44376
+ /**
44377
+ * Keeps an open controller in sync with where the caller says the popup should
44378
+ * be: an `open`/`defaultOpen` pair, a `signal`, or a `navState` — the open
44379
+ * state written into the history entry, so a screen left and come back to finds
44380
+ * its popup as it was.
44381
+ *
44382
+ * Shared between `useOpenControllerByProps` below (Dialog/Popover driving their
44383
+ * own controller), `picker_custom.jsx` (which owns its controller but wants
44384
+ * the same skip-if-already-matching / open-or-requestClose control flow) and
44385
+ * `expandable.jsx` (open in flow rather than on a layer, same decision).
44386
+ *
44387
+ * @param {{ open: (e: Event, detail?: object) => void, requestClose: (e: Event, detail?: object) => void, opened: boolean }} openController
44388
+ * @param {{ id?: string, open?: boolean|"interaction", defaultOpen?: boolean|"interaction", signal?: import("@preact/signals").Signal<boolean>, navState?: boolean|string|{id?: string, type?: "push"|"replace"} }} props
44389
+ * @param {string} [name] What the dev warnings call the thing being opened.
44390
+ */
44391
+ const useOpenPropsEffectOnOpenController = (
44392
+ openController,
44393
+ props,
44394
+ name = "popup",
44395
+ ) => {
44396
+ const { signal, defaultOpen, navState } = props;
44397
+ const { id: navStateId, type: navStateType } = resolveNavStateProp(
44398
+ navState,
44399
+ props.id);
44400
+ // Called unconditionally (it answers with no-ops for an absent id), like
44401
+ // every other hook here.
44402
+ const [navStateValue, enterNavState, leaveNavState] = useNavState(
44403
+ navStateId,
44404
+ { type: navStateType },
44405
+ );
44406
+ // What the caller holds, however they hold it: the history entry when there
44407
+ // is a `navState`, an `open` they re-render themselves, or a `signal` this
44408
+ // hook also writes (see onOpenedChange below). Reading .value during render
44409
+ // is what subscribes the popup to a signal; reading the document state is
44410
+ // what subscribes it to the history entry, back button included.
44411
+ const open = navStateId
44412
+ ? Boolean(navStateValue)
44413
+ : signal
44414
+ ? signal.value
44415
+ : props.open;
44416
+ // Assigned on every render, like openEffect, so it always closes over the
44417
+ // latest prop: a popup that opens or closes on its own (Escape, backdrop, a
44418
+ // --navi-close command) writes what happened where the caller keeps it, so
44419
+ // whoever holds it always reads where the popup is.
44420
+ openController.onOpenedChange =
44421
+ navStateId || signal
44422
+ ? (opened, event) => {
44423
+ if (navStateId) {
44424
+ if (opened) {
44425
+ enterNavState();
44426
+ } else {
44427
+ // Under type "push" a cancel goes back rather than rewriting the
44428
+ // entry, so everything else written to the url while the popup
44429
+ // was open goes back with it (see useNavState's own leave()).
44430
+ leaveNavState({ isBack: Boolean(event?.detail?.isCancel) });
44431
+ }
44432
+ }
44433
+ if (signal) {
44434
+ signal.value = opened;
44435
+ }
44436
+ }
44437
+ : null;
44438
+ // Tracks whether the effect below has ever run before — only the very
44439
+ // first run gets the "mount already open" treatment (`open` truthy from
44440
+ // the start, or the uncontrolled, mount-only `defaultOpen`); every
44441
+ // subsequent `open` change is a real, later toggle and should animate
44442
+ // normally like any other interactive open/close.
44443
+ const isFirstRunRef = useRef(true);
44444
+
44445
+ useLayoutEffect(() => {
44446
+ const isFirstRun = isFirstRunRef.current;
44447
+ isFirstRunRef.current = false;
44448
+
44449
+ if (isFirstRun) {
44450
+ const mountOpenReason = open || defaultOpen;
44451
+ if (mountOpenReason) {
44452
+ // Whether this popup being open is something that just happened, or
44453
+ // something that was already true when the page appeared. "interaction"
44454
+ // says the mount IS the opening — the popup exists because the user
44455
+ // just asked for it — so the entrance plays like any other open. Any
44456
+ // other truthy value means it was simply already open: nothing was ever
44457
+ // shown as "closed" for the user to see it transition away from, so the
44458
+ // entrance is skipped (`silent`, see popover.jsx's own openEffect).
44459
+ //
44460
+ // Deferred + batched (see scheduleMountOpen above) rather than called
44461
+ // directly, so nested popups that both mount already-open end up
44462
+ // stacked ancestor-first instead of Preact's own child-first effect
44463
+ // order.
44464
+ scheduleMountOpen(() =>
44465
+ openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
44466
+ silent: mountOpenReason !== "interaction",
44467
+ }),
44468
+ );
44469
+ }
44470
+ return;
44471
+ }
44472
+
44473
+ if (open === undefined) {
44474
+ return;
44475
+ }
44476
+ // Skip when the controller is already in the desired state.
44477
+ // openController.opened tracks actual open/close (updated by onopen/onclose,
44478
+ // not by renders) so it is the authoritative check against feedback loops.
44479
+ if (open === openController.opened) {
44480
+ return;
44481
+ }
44482
+ if (open) {
44483
+ openController.open(new CustomEvent("open_by_prop", { detail: {} }));
44484
+ } else {
44485
+ openController.requestClose(
44486
+ new CustomEvent("close_by_prop", { detail: {} }),
44487
+ { isCancel: true },
44488
+ );
44489
+ }
44490
+ // The request can be refused (a busy form denying the close): the popup
44491
+ // then stays where it was, and whoever holds the open state is told so —
44492
+ // otherwise it would keep saying "closed" about a popup still open.
44493
+ if (signal) {
44494
+ signal.value = openController.opened;
44495
+ }
44496
+ if (navStateId && openController.opened) {
44497
+ enterNavState();
44498
+ }
44499
+ }, [open]);
44500
+ };
44501
+
44502
+ const useOpenControllerByProps = (props, name) => {
44503
+ const { onClose } = props;
44504
+ // Lets an uncontrolled consumer (no openController of its own) still react
44505
+ // to a self-initiated close (Escape, backdrop click, its own close button)
44506
+ // without having to own a controller just to observe it — onClose is
44507
+ // called on every real close, matching createOpenController's own
44508
+ // { onRequestClose, onClose } contract (never denies the close itself).
44509
+ const openController = useOpenController(() =>
44510
+ onClose ? { onClose } : undefined,
44511
+ );
44512
+ useOpenPropsEffectOnOpenController(openController, props, name);
44513
+ return openController;
44514
+ };
44515
+
44516
+ /**
44517
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
44518
+ * letting Preact batch it into the next microtask. Layout effects of what gets
44519
+ * mounted run inside the call too, exactly as they would on any other commit.
44520
+ *
44521
+ * For the caller that has to read the DOM it just asked for — measuring an
44522
+ * element whose content it mounts in the same breath — and cannot wait a tick
44523
+ * to do it, because what comes after is a browser event still in flight
44524
+ * (preventDefault, focus placement) that no longer accepts being answered late.
44525
+ *
44526
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
44527
+ * render queue drains; swapping it for "right now" for the duration of the call
44528
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
44529
+ * case above: rendering synchronously in the middle of an event gives up the
44530
+ * batching that makes several state changes one commit.
44531
+ */
44532
+ const flushSyncRendering = (fn) => {
44533
+ const debounceRenderingPrevious = options.debounceRendering;
44534
+ options.debounceRendering = (drainRenderQueue) => {
44535
+ drainRenderQueue();
44536
+ };
44537
+ try {
44538
+ fn();
44539
+ } finally {
44540
+ options.debounceRendering = debounceRenderingPrevious;
44541
+ }
44542
+ };
44543
+
44544
+ /**
44545
+ * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
44546
+ * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
44547
+ * of which of the two owns it.
44548
+ */
44549
+
44550
+
44551
+ /**
44552
+ * Whether a visibleRectEffect delivery is one that can have taken height away
44553
+ * from a popup, and so pushed whatever holds focus out of sight:
44554
+ * - "resize": the window/visual viewport settled — which is also how the
44555
+ * on-screen keyboard arrives, overlay or not (window_size.js);
44556
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
44557
+ * - "focusin": the focus-settled re-measure, for the room that changes with
44558
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
44559
+ *
44560
+ * Everything else is a scroll of one kind or another, where nothing got
44561
+ * smaller and scrolling the focused element back would fight the very gesture
44562
+ * that fired it.
44563
+ */
44564
+ const mayHaveHiddenFocus = (event) => {
44565
+ const type = event?.type;
44566
+ return (
44567
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
44568
+ );
44569
+ };
44570
+
44571
+ /**
44572
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
44573
+ * getting shorter has pushed it out.
44574
+ *
44575
+ * The case this exists for: a field low in the scrolling body of a popup that
44576
+ * also has a footer (box.jsx — with a body, the body is the only thing that
44577
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
44578
+ * field makes the browser scroll it into view, which it does against the
44579
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
44580
+ * that height away. The body shrinks, its scrollTop does not move, so the
44581
+ * content slides down relative to the shorter scrollport and the field ends up
44582
+ * past its bottom edge — visually, swallowed by the footer. The browser does
44583
+ * not redo a scroll-into-view it already answered, so this does.
44584
+ *
44585
+ * Scoped to the field's own scroll container (never the page): a popup traps
44586
+ * scrolling precisely so the document underneath cannot move, and a plain
44587
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
44588
+ * scrollIntoViewScoped's own doc.
44589
+ *
44590
+ * "nearest": the smallest scroll that makes it visible, and none at all when
44591
+ * it already is — so this is free to call on every resize, and never fights
44592
+ * where the user had scrolled to.
44593
+ */
44594
+ const keepFocusedElementVisible = (popupEl) => {
44595
+ const { activeElement } = document;
44596
+ if (!activeElement || activeElement === popupEl) {
44597
+ return;
44598
+ }
44599
+ if (!popupEl.contains(activeElement)) {
44600
+ return;
44601
+ }
44602
+ scrollIntoViewScoped(activeElement, { block: "nearest" });
44603
+ };
44604
+
44605
+ /**
44606
+ * Calls `onSettled` once `el`'s current CSS transition is over — via
44607
+ * `transitionend`, with a safety `setTimeout` fallback matching the longest
44608
+ * `transition-duration`, in case nothing actually transitions or an event is
44609
+ * missed.
44610
+ *
44611
+ * Returns a "cancel" function, so a caller whose instance has been superseded
44612
+ * (a fresh open/close about to set its own state) can keep this stale one from
44613
+ * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
44614
+ * did up front is that fresh call's business, not this one's.
44615
+ */
44616
+ const whenTransitionSettles = (el, onSettled) => {
44617
+ let settled = false;
44618
+ const onTransitionEnd = (transitionEvent) => {
44619
+ if (transitionEvent.target === el) {
44620
+ finish();
44621
+ }
44622
+ };
44623
+ const stopWatching = () => {
44624
+ settled = true;
44625
+ el.removeEventListener("transitionend", onTransitionEnd);
44626
+ clearTimeout(safetyTimeoutId);
44627
+ };
44628
+ const finish = () => {
44629
+ if (settled) {
44630
+ return;
44631
+ }
44632
+ stopWatching();
44633
+ onSettled();
44634
+ };
44635
+ el.addEventListener("transitionend", onTransitionEnd);
44636
+ const durationsInSeconds = getComputedStyle(el)
44637
+ .transitionDuration.split(",")
44638
+ .map((value) => parseFloat(value) || 0);
44639
+ const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
44640
+ const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
44641
+ return () => {
44642
+ if (settled) {
44643
+ return;
44644
+ }
44645
+ stopWatching();
44646
+ };
44647
+ };
44648
+
44649
+ /**
44650
+ * Disables pointer-events on `el` until its current CSS transition settles —
44651
+ * avoids the cursor changing/something becoming clickable while the popup is
44652
+ * still visually moving into or out of place.
44653
+ *
44654
+ * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
44655
+ * pointer-events, since a fresh call for the next open/close is about to set
44656
+ * its own state.
44657
+ */
44658
+ const suppressPointerEventsDuringTransition = (el) => {
44659
+ el.style.pointerEvents = "none";
44660
+ return whenTransitionSettles(el, () => {
44661
+ el.style.pointerEvents = "";
44662
+ });
44663
+ };
44664
+
44665
+ /**
44666
+ * Hides the backdrop, deferring until the browser's matching "click" fires
44667
+ * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
44668
+ * comment for why) — same capture-phase-on-document pattern as
44669
+ * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
44670
+ * can't safely replace: mouseup (and the click that follows it) can land an
44671
+ * arbitrarily long time after mousedown (the user is still holding the
44672
+ * button down), so a short timeout can fire first and hide the backdrop
44673
+ * before its own click ever arrives. A capture-phase listener on document
44674
+ * fires for every click regardless of what any bubble-phase handler does
44675
+ * downstream, so no fallback timer is needed.
44676
+ *
44677
+ * `hide` is the caller's own way to actually hide the backdrop
44678
+ * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
44679
+ * for a plain div) — this helper only owns the mousedown/click timing.
44680
+ *
44681
+ * Returns a disarm function (or undefined if hidden immediately), so a
44682
+ * fresh open can cancel a pending hide it's about to make redundant.
44683
+ */
44684
+ const armPointerDownOutsideClose = (closeEvent, hide) => {
44685
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
44686
+ if (!mousedownEvent) {
44687
+ hide();
44688
+ return undefined;
44689
+ }
44690
+ const onClick = () => {
44691
+ document.removeEventListener("click", onClick, { capture: true });
44692
+ hide();
44693
+ };
44694
+ document.addEventListener("click", onClick, { capture: true });
44695
+ return () => {
44696
+ document.removeEventListener("click", onClick, { capture: true });
44697
+ };
44698
+ };
44699
+
44700
+ /**
44701
+ * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
44702
+ * `prefix` plus a direction word), or `null` if both axes overlap the anchor
44703
+ * (no direction at all — that's `resolvedAnimationKind === "scaling"`
44704
+ * territory instead, see resolveAutoAnimationKind below).
44705
+ *
44706
+ * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
44707
+ * when docked) keeps the word as the compass direction the popup comes
44708
+ * from: placed "top" (a point/corner), it slides in from the top.
44709
+ * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
44710
+ * direction instead, the opposite compass point: placed "top" of the
44711
+ * anchor, it moves/grows up, away from the anchor (which sits below it).
44712
+ *
44713
+ * "inset-*"/"center" contribute no direction on their axis either way.
44714
+ */
44715
+ const resolveDirectionValue = (y, x, { prefix }) => {
44716
+ const yWord =
44717
+ y === "top"
44718
+ ? prefix === "expand"
44719
+ ? "up"
44720
+ : "top"
44721
+ : y === "bottom"
44722
+ ? prefix === "expand"
44723
+ ? "down"
44724
+ : "bottom"
44725
+ : null;
44726
+ const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
44727
+ if (!yWord && !xWord) {
44728
+ return null;
44729
+ }
44730
+ return yWord && xWord
44731
+ ? `${prefix}-${yWord}-${xWord}`
44732
+ : `${prefix}-${yWord || xWord}`;
44733
+ };
44734
+
44735
+ /**
44736
+ * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
44737
+ * — picked for any real anchor, or for a point/corner placed dead-center
44738
+ * (both positionArea axes overlapping — there's no sensible direction to
44739
+ * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
44740
+ * for any no-anchor/docked case (Dialog always, Popover's own custom
44741
+ * renderer when there's no real anchor), so this collapses to "scaling"
44742
+ * there only for the dead-center case, "sliding" otherwise. The two
44743
+ * "overlapping" booleans below describe the *positionArea* itself (a bare
44744
+ * word vs. "inset-"/"center"), not anything about the anchor — they'd
44745
+ * mean exactly the same thing even with no anchor at all, since it's the
44746
+ * position strategy, not the anchor, that decides whether there's a
44747
+ * direction to slide from.
44748
+ */
44749
+ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
44750
+ const yIsOverlapping =
44751
+ parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
44752
+ const xIsOverlapping =
44753
+ parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
44754
+ return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
44755
+ };
44756
+
44757
+ /**
44758
+ * When a popup builds what it holds, and when it throws it away.
44759
+ *
44760
+ * A closed popup shows nothing, focuses nothing, and answers nothing: what it
44761
+ * holds is out of reach until it opens. Building that content at mount time
44762
+ * means a page carrying a handful of closed popups pays, on the very render
44763
+ * that decides how fast it appears, for content nobody has asked for — and
44764
+ * pays again on every subsequent measurement, since each of those nodes makes
43862
44765
  * the document the rest of the page queries bigger.
43863
44766
  *
43864
44767
  * So the content is built when the popup first opens, and stays built from
@@ -43898,7 +44801,7 @@ const MOUNT_DEFAULT = "from-first-open";
43898
44801
  const usePopupContentMount = (
43899
44802
  openController,
43900
44803
  ref,
43901
- { children, mount = MOUNT_DEFAULT },
44804
+ { mount = MOUNT_DEFAULT },
43902
44805
  ) => {
43903
44806
  const mountedAlways = mount === "always";
43904
44807
  const [contentMounted, setContentMounted] = useState(
@@ -43938,30 +44841,48 @@ const usePopupContentMount = (
43938
44841
  }
43939
44842
  }, [mountedAlways]);
43940
44843
 
43941
- return contentMounted ? children : null;
44844
+ return contentMounted;
43942
44845
  };
43943
44846
 
43944
44847
  installImportMetaCssBuild(import.meta);/**
43945
44848
  * Expandable: an in-flow disclosure — a UI part that reveals a content part.
43946
44849
  * It covers the same ground as <Details> with structural differences:
43947
44850
  *
43948
- * - the two parts are explicit and free to order/orient:
44851
+ * - the two parts are explicit, and are Boxes:
43949
44852
  *
43950
44853
  * <Expandable>
43951
44854
  * <Expandable.UI>See more</Expandable.UI>
43952
44855
  * <Expandable.Content>…</Expandable.Content>
43953
44856
  * </Expandable>
43954
44857
  *
43955
- * Content after UI expands below (the <details> shape), Content before UI
43956
- * expands above; `layout="column"` puts the parts side by side (sharing
43957
- * their height), the content then expanding horizontally. The marker
43958
- * chevron follows: it points right while closed and toward where the
43959
- * content went while open (down, up, or left). The common shape has a
43960
- * shorthand: `ui` prop + children as content.
44858
+ * `openDirection` decides where the content is revealed — down (the
44859
+ * <details> shape) or up, left or right in `layout="column"`, where the
44860
+ * parts sit side by side sharing their height. It places the parts too
44861
+ * (CSS `order`), so an expandable revealing upward can still be written UI
44862
+ * first, and an expandable with no UI part at all — driven from
44863
+ * `open`/`signal` by a toggle of the app's own — can say which way it
44864
+ * opens. The marker chevron follows: it points right while closed and
44865
+ * toward where the content went while open (down, up, or left). The common
44866
+ * shape has a shorthand: `ui` prop + children as content.
44867
+ *
43961
44868
  * - the UI part is the focusable toggle itself (role button, Space/Enter,
43962
44869
  * arrow keys) and accepts any markup: controls inside it keep their own
43963
44870
  * behavior, the marker is purely decorative.
43964
44871
  *
44872
+ * The root never looks at its children: which parts are there, in which order,
44873
+ * is none of its business. It publishes a context and the parts take what they
44874
+ * need from it.
44875
+ *
44876
+ * Deciding to open or to close is the same decision a Dialog or a Popover
44877
+ * takes — the same props behind it (`open`/`defaultOpen`/`signal`/`navState`),
44878
+ * the same commands in front of it, the same refusable close — so it is taken
44879
+ * in the same place: an open controller (see open_controller.js). What belongs
44880
+ * to an expandable is only what opening LOOKS like, which is `openEffect` and
44881
+ * the cleanup it returns. Both render synchronously (flushSyncRendering)
44882
+ * before measuring, so the DOM is in the state they are about to measure —
44883
+ * that, and not any inspection of the children, is what makes a part free to
44884
+ * re-render later than the root (a memoized subtree does exactly that).
44885
+ *
43965
44886
  * Reach for it knowingly: expanding in-flow SHIFTS the layout — everything
43966
44887
  * below (or beside) moves when it opens. A Popover, Dialog, Picker or Callout
43967
44888
  * answers the same click on its own layer, moving nothing, which is usually
@@ -43980,17 +44901,18 @@ installImportMetaCssBuild(import.meta);/**
43980
44901
  * and answer the `navi_command`/`navi_request_open`/`navi_request_close`
43981
44902
  * events.
43982
44903
  *
43983
- * Content is not built until the first expansion and stays built afterwards —
43984
- * same policy, same `mount` prop as popups (see popup_content_mount.js):
43985
- * `"always"` builds it right away, `"while-opened"` throws it away once the
43986
- * collapse settles (so a closing animation still plays on real content).
44904
+ * Content is not built until the first expansion and stays built afterwards:
44905
+ * a popup's policy, and its code (`usePopupContentMount`, see
44906
+ * popup_content_mount.js) — `"always"` builds it right away, `"while-opened"`
44907
+ * throws it away once the collapse settles, so a closing animation still plays
44908
+ * on real content.
43987
44909
  *
43988
44910
  * The animation is a REVEAL, not a resize: the expandable's own footprint
43989
44911
  * grows/shrinks progressively (the content's grid track interpolates
43990
44912
  * 0fr <-> 1fr — rows for the stacked layout, columns for `layout="column"`),
43991
44913
  * but the content inside is laid out at its final size for the whole movement
43992
- * (its animated dimension is frozen to the measured final value, see the
43993
- * [opened] effect) and the container simply uncovers it. Text never rewraps
44914
+ * (its animated dimension is frozen to the measured final value, see
44915
+ * freezeContentSize) and the container simply uncovers it. Text never rewraps
43994
44916
  * mid-animation. The content is revealed from its UI side (pinned against the
43995
44917
  * UI when it comes first). Once settled open the clipping is released, so a
43996
44918
  * popover or focus ring inside is not cut at the edges.
@@ -44001,6 +44923,10 @@ const css$Q = /* css */`.navi_expandable {
44001
44923
  display: flex;
44002
44924
  position: relative;
44003
44925
 
44926
+ &[data-open-direction="down"] > .navi_expandable_content_container, &[data-open-direction="right"] > .navi_expandable_content_container, &[data-open-direction="up"] > .navi_expandable_ui, &[data-open-direction="left"] > .navi_expandable_ui {
44927
+ order: 1;
44928
+ }
44929
+
44004
44930
  & > .navi_expandable_ui {
44005
44931
  cursor: pointer;
44006
44932
  user-select: none;
@@ -44046,7 +44972,7 @@ const css$Q = /* css */`.navi_expandable {
44046
44972
  grid-template-rows: 1fr;
44047
44973
  }
44048
44974
 
44049
- &[data-content-first]:not([data-layout="column"]) > .navi_expandable_content_container {
44975
+ &[data-open-direction="up"] > .navi_expandable_content_container {
44050
44976
  clip-path: inset(0 -9999px -9999px);
44051
44977
  align-content: end;
44052
44978
 
@@ -44087,7 +45013,7 @@ const css$Q = /* css */`.navi_expandable {
44087
45013
  grid-template-rows: none;
44088
45014
  }
44089
45015
 
44090
- &[data-content-first] > .navi_expandable_content_container {
45016
+ &[data-open-direction="left"] > .navi_expandable_content_container {
44091
45017
  clip-path: inset(-9999px -9999px 0 0);
44092
45018
  justify-content: end;
44093
45019
 
@@ -44138,11 +45064,14 @@ const useExpandableContext = partName => {
44138
45064
  * open?: boolean,
44139
45065
  * defaultOpen?: boolean,
44140
45066
  * signal?: import("@preact/signals").Signal<boolean>,
45067
+ * navState?: boolean | string | { id?: string, type?: "push" | "replace" },
45068
+ * onClose?: (event: Event) => void,
44141
45069
  * onToggle?: (event: Event) => void,
44142
45070
  * action?: Function,
44143
45071
  * loading?: boolean,
44144
45072
  * animation?: boolean,
44145
45073
  * layout?: "row" | "column",
45074
+ * openDirection?: "down" | "up" | "right" | "left",
44146
45075
  * autoFocus?: boolean,
44147
45076
  * maxContentHeight?: string | number,
44148
45077
  * mount?: "always" | "from-first-open" | "while-opened",
@@ -44158,10 +45087,19 @@ const useExpandableContext = partName => {
44158
45087
  * `<Expandable.Content>` as children instead.
44159
45088
  * @param open - Drives the state from outside: the expandable opens/closes to
44160
45089
  * match every change of this prop, but user interaction can still toggle it
44161
- * in between (same semantics as Dialog/Popover's own `open`).
45090
+ * in between. Same open controller as Dialog/Popover, so the four props
45091
+ * below behave exactly as they do there (see open_controller.js).
44162
45092
  * @param defaultOpen - Uncontrolled, mount-only initial state.
44163
45093
  * @param signal - Two-way binding: the expandable follows the signal and
44164
45094
  * writes back into it whenever it toggles on its own. Excludes `open`.
45095
+ * @param navState - Keeps the open state in the history entry, so a screen
45096
+ * left and come back to finds its sections as they were: `true` uses the
45097
+ * expandable's own `id`, a string names the key, `{ id, type }` chooses
45098
+ * between rewriting the entry ("replace", the default) and pushing one
45099
+ * ("push"), where closing goes back. Source of truth — excludes
45100
+ * `open`/`signal`.
45101
+ * @param onClose - Called when the expandable actually closes, whatever asked
45102
+ * for it. Not preventable.
44165
45103
  * @param onToggle - Listens the "toggle" event dispatched on the root (a
44166
45104
  * ToggleEvent with newState/oldState where supported). Fires on every actual
44167
45105
  * state change, never on mount.
@@ -44175,15 +45113,23 @@ const useExpandableContext = partName => {
44175
45113
  * @param layout - `"row"` (default): the parts stack, the content expands
44176
45114
  * vertically. `"column"`: the parts sit side by side sharing their height,
44177
45115
  * the content expands horizontally next to the UI part.
44178
- * @param autoFocus - Off by default (the focus stays on the UI part when
44179
- * opening). `true` moves the focus into the content on open — the
44180
- * `[autofocus]` element if any, the first focusable otherwise. Whatever the
44181
- * setting, closing while the focus is inside the content hands it back to
44182
- * the UI part (it would otherwise be lost to the closed, inert content).
45116
+ * @param openDirection - Where the content is revealed: `"down"` (default) or
45117
+ * `"up"` in the stacked layout, `"right"` (default) or `"left"` in
45118
+ * `layout="column"`. It also places the parts, so the direction holds
45119
+ * whatever order they are written in — and an expandable with no
45120
+ * `<Expandable.UI>` at all, driven from `open`/`signal` by a toggle of the
45121
+ * app's own, can still say which way it opens.
45122
+ * @param autoFocus - Off by default (the keyboard stays where it is when the
45123
+ * expandable opens). `true` hands the focus to the content through the same
45124
+ * transfer a popup uses — the ladder picks the target (`navi-autofocus`
45125
+ * first, see focus_transfer.js) and closing gives the keyboard back where it
45126
+ * came from. An expandable that mounts already open never takes it. Whatever
45127
+ * the setting, closing while the focus sits inside the content hands it back
45128
+ * to the UI part (it would otherwise be lost to the closed, inert content).
44183
45129
  * @param maxContentHeight - Caps the content height; taller content scrolls
44184
45130
  * inside the expandable instead of growing it.
44185
- * @param mount - When the content is built and thrown away, same three values
44186
- * as a popup's (see popup_content_mount.js). `"from-first-open"` (the
45131
+ * @param mount - When the content is built and thrown away, a popup's own
45132
+ * three values and a popup's own code (see popup_content_mount.js). `"from-first-open"` (the
44187
45133
  * default) builds it on the first expansion and keeps it afterwards.
44188
45134
  * `"always"` builds it right away; in layout="column" it also gives the
44189
45135
  * closed expandable its content's height (the content is kept laid out at
@@ -44194,16 +45140,22 @@ const useExpandableContext = partName => {
44194
45140
  */
44195
45141
  const Expandable = props => {
44196
45142
  import.meta.css = [css$Q, "@jsenv/navi/src/control/expandable/expandable.jsx"];
45143
+ /* The open props are read from `props` by the open controller below; they
45144
+ are named here only to keep them out of `rest`, and so out of the DOM. */
45145
+ /* eslint-disable no-unused-vars */
44197
45146
  const {
44198
45147
  ref,
44199
45148
  ui,
44200
45149
  open,
44201
45150
  defaultOpen,
44202
45151
  signal,
45152
+ navState,
45153
+ onClose,
44203
45154
  action,
44204
45155
  loading,
44205
45156
  animation = false,
44206
45157
  layout,
45158
+ openDirection,
44207
45159
  autoFocus,
44208
45160
  maxContentHeight,
44209
45161
  mount = MOUNT_DEFAULT,
@@ -44213,35 +45165,43 @@ const Expandable = props => {
44213
45165
  children,
44214
45166
  ...rest
44215
45167
  } = props;
45168
+ /* eslint-enable no-unused-vars */
45169
+
44216
45170
  const defaultRef = useRef();
44217
45171
  const rootRef = ref || defaultRef;
44218
45172
  const uiRef = useRef();
44219
45173
  const contentContainerRef = useRef();
44220
45174
  const contentId = useId();
44221
45175
  const isColumn = layout === "column";
44222
- const mountedAlways = mount === "always";
44223
- const mountedWhileOpened = mount === "while-opened";
44224
- const closedContentSized = isColumn && mountedAlways;
44225
- // Reading .value during render is what subscribes the expandable to it.
44226
- const openRequested = signal ? signal.value : open;
44227
- const [opened, setOpened] = useState(() => Boolean(openRequested === undefined ? defaultOpen : openRequested));
44228
- const openedRef = useRef(opened);
44229
- openedRef.current = opened;
45176
+ const closedContentSized = isColumn && mount === "always";
44230
45177
  const hasAction = Boolean(action);
44231
45178
  const effectiveAction = useAction(action);
44232
45179
  const {
44233
45180
  loading: actionLoading
44234
45181
  } = useActionStatus(effectiveAction);
44235
- const [contentMounted, setContentMounted] = useState(() => mountedAlways || opened);
44236
45182
 
44237
- // Fully open and no longer moving — what allows overflow to become visible
44238
- // (see the CSS) and what mount="while-opened" waits for before emptying.
45183
+ // Registered before the open controller, so this cleanup runs before its own
45184
+ // unmount safety net (see useOpenController): a close fired on the way out
45185
+ // still does its bookkeeping, it just has no tree left to move.
45186
+ const unmountedRef = useRef(false);
45187
+ useLayoutEffect(() => {
45188
+ return () => {
45189
+ unmountedRef.current = true;
45190
+ };
45191
+ }, []);
45192
+ const openController = useOpenControllerByProps(props, "expandable");
45193
+ // What the controller decided, mirrored for the render (aria-expanded, the
45194
+ // content's inert, the marker). Written only from `openEffect` and the
45195
+ // cleanup it returns, and synchronously (see flushSyncRendering), so the DOM
45196
+ // is already in the new state when what follows measures it.
45197
+ const [opened, setOpened] = useState(false);
45198
+ // Fully open (or fully closed) and no longer moving — what releases the
45199
+ // clipping, see the CSS.
44239
45200
  const [settled, setSettled] = useState(true);
45201
+ const contentMounted = usePopupContentMount(openController, contentContainerRef, {
45202
+ mount
45203
+ });
44240
45204
 
44241
- // Read before the close touches the DOM: flipping the content to inert can
44242
- // blur what it held, so by effect time the focus to hand back to the UI part
44243
- // could already be gone.
44244
- const focusedBeforeCloseRef = useRef(null);
44245
45205
  // The pointer press that is about to toggle can blur the focused field
44246
45206
  // before the click ever fires — so what held the focus has to be remembered
44247
45207
  // at pointerdown time.
@@ -44255,10 +45215,7 @@ const Expandable = props => {
44255
45215
  // released once the movement settles.
44256
45216
  const freezeContentSize = () => {
44257
45217
  const contentContainer = contentContainerRef.current;
44258
- const contentElement = contentContainer ? contentContainer.firstElementChild.firstElementChild : null;
44259
- if (!contentElement) {
44260
- return;
44261
- }
45218
+ const contentElement = contentContainer.firstElementChild.firstElementChild;
44262
45219
  const rect = contentElement.getBoundingClientRect();
44263
45220
  if (isColumn) {
44264
45221
  // Both, not just the width: a max-height-capped content otherwise
@@ -44270,50 +45227,147 @@ const Expandable = props => {
44270
45227
  }
44271
45228
  };
44272
45229
 
44273
- // Where the last paint left the track, measured before the toggle commits:
44274
- // 0 when fully closed, partway when reopening during a collapse. Read here
44275
- // rather than in the effect — a layout read after the commit would also be
44276
- // the first style recalc of the open state, starting the track transition
44277
- // right there; once canceled (to measure the final size), a new transition
44278
- // to the same end value refuses to start and the reveal jumps.
44279
- const revealStartSizeRef = useRef(null);
44280
- const toggleTo = nextOpen => {
44281
- nextOpen = Boolean(nextOpen);
44282
- if (nextOpen === openedRef.current) {
44283
- return;
45230
+ // A movement still playing: what a new one cancels before starting, so a
45231
+ // reveal interrupted halfway does not have the previous settle release its
45232
+ // frozen size under it.
45233
+ const cancelSettleWatchRef = useRef(null);
45234
+ const cancelSettleWatch = () => {
45235
+ cancelSettleWatchRef.current?.();
45236
+ cancelSettleWatchRef.current = null;
45237
+ };
45238
+ const watchSettle = () => {
45239
+ const contentContainer = contentContainerRef.current;
45240
+ const contentElement = contentContainer.firstElementChild.firstElementChild;
45241
+ cancelSettleWatchRef.current = whenTransitionSettles(contentContainer, () => {
45242
+ cancelSettleWatchRef.current = null;
45243
+ contentElement.style.width = "";
45244
+ contentElement.style.height = "";
45245
+ setSettled(true);
45246
+ });
45247
+ };
45248
+
45249
+ // The reveal needs the content at its final size before the track starts
45250
+ // moving, and that size only exists in the open state — the reflow trick
45251
+ // (see instructions.md, CSS section), with transitions suppressed BEFORE the
45252
+ // first layout read: this runs pre-paint, so any earlier read would itself be
45253
+ // the first recalc of the open state and would start the track transition.
45254
+ const armReveal = startRect => {
45255
+ const contentContainer = contentContainerRef.current;
45256
+ const contentElement = contentContainer.firstElementChild.firstElementChild;
45257
+ contentContainer.style.transitionProperty = "none";
45258
+ const finalRect = contentElement.getBoundingClientRect();
45259
+ if (isColumn) {
45260
+ contentElement.style.width = `${finalRect.width}px`;
45261
+ contentElement.style.height = `${finalRect.height}px`;
45262
+ } else {
45263
+ contentElement.style.height = `${finalRect.height}px`;
44284
45264
  }
44285
- openedRef.current = nextOpen;
44286
- if (nextOpen) {
44287
- if (animation) {
44288
- const contentContainer = contentContainerRef.current;
44289
- revealStartSizeRef.current = contentContainer ? contentContainer.getBoundingClientRect() : null;
45265
+ // Put the tracks back where the last paint left them and let the
45266
+ // transition play from there. In fr — px does not interpolate with fr.
45267
+ const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
45268
+ if (isColumn) {
45269
+ contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
45270
+ if (!closedContentSized) {
45271
+ // The height opens alongside the width (a closed column expandable is
45272
+ // only as tall as its UI) — unless the closed content already sizes
45273
+ // it, where only the width has anywhere to go.
45274
+ contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
44290
45275
  }
44291
- setContentMounted(true);
44292
45276
  } else {
44293
- const activeElement = document.activeElement;
44294
- focusedBeforeCloseRef.current = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
44295
- if (animation) {
44296
- // Now, while the content is still fully laid out — by effect time the
44297
- // track is already heading to 0 (the opening case measures in the
44298
- // effect instead, where the just-mounted content exists).
44299
- freezeContentSize();
44300
- }
44301
- }
44302
- focusedAtPointerDownRef.current = null;
44303
- setOpened(nextOpen);
44304
- // Flipped here, before the closing/opening commit, so effects of that very
44305
- // commit already see the movement as started — mount="while-opened" must not
44306
- // read a stale "settled" and empty the content under a closing animation.
44307
- setSettled(!animation);
44308
- if (signal) {
44309
- signal.value = nextOpen;
45277
+ contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
44310
45278
  }
45279
+ // That starting frame must be genuinely rendered to transition from it,
45280
+ // and transitions re-enabled BEFORE the flip back to the open value —
45281
+ // same order as popover.jsx's own reflow trick.
45282
+ contentContainer.getBoundingClientRect();
45283
+ contentContainer.style.transitionProperty = "";
45284
+ contentContainer.style.gridTemplateColumns = "";
45285
+ contentContainer.style.gridTemplateRows = "";
45286
+ };
45287
+
45288
+ // What opening LOOKS like here, and how to undo it — the one thing an
45289
+ // expandable owns that a popup does not (see open_controller.js). Reassigned
45290
+ // on every render so it always closes over the latest props.
45291
+ openController.openEffect = openEvent => {
45292
+ const contentContainer = contentContainerRef.current;
45293
+ // `silent`: the expandable was already open when the page appeared
45294
+ // (`open`/`defaultOpen` at mount). Nothing changed for anyone to be told
45295
+ // about, nothing was ever shown closed to move away from, and a page must
45296
+ // not have its focus stolen by a section that was simply already open — so
45297
+ // an opening that was never an opening plays nothing, says nothing and
45298
+ // takes nothing. The action it carries still runs: its content is due.
45299
+ const silent = Boolean(openEvent.detail.silent);
45300
+ const revealing = animation && !silent && Boolean(contentContainer);
45301
+ // Where the last paint left the track — 0 when fully closed, partway when
45302
+ // reopening during a collapse — read while the DOM still says closed.
45303
+ const startRect = revealing ? contentContainer.getBoundingClientRect() : null;
45304
+ cancelSettleWatch();
45305
+ flushSyncRendering(() => {
45306
+ setOpened(true);
45307
+ setSettled(!revealing);
45308
+ });
44311
45309
  if (hasAction) {
44312
- if (nextOpen) {
44313
- runUnwatched(() => effectiveAction.run());
44314
- } else {
45310
+ runUnwatched(() => effectiveAction.run());
45311
+ }
45312
+ // autoFocus off (the default): the keyboard stays where it is, and with no
45313
+ // transfer there is nothing to restore on close either.
45314
+ const restoreFocus = autoFocus && !silent && contentContainer ? openController.transferFocusOnOpen(contentContainer) : null;
45315
+ if (revealing) {
45316
+ armReveal(startRect);
45317
+ watchSettle();
45318
+ }
45319
+ if (!silent) {
45320
+ rootRef.current.dispatchEvent(createToggleEvent(true));
45321
+ }
45322
+ return closeEvent => {
45323
+ if (unmountedRef.current) {
45324
+ return;
45325
+ }
45326
+ const contentContainerAtClose = contentContainerRef.current;
45327
+ // Read while the content still holds what it holds: the close is about
45328
+ // to make it inert, which blurs whatever is inside it.
45329
+ const activeElement = document.activeElement;
45330
+ const focusedBeforeClose = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
45331
+ focusedAtPointerDownRef.current = null;
45332
+ if (hasAction) {
44315
45333
  effectiveAction.abort();
44316
45334
  }
45335
+ cancelSettleWatch();
45336
+ const collapsing = animation && Boolean(contentContainerAtClose);
45337
+ if (collapsing) {
45338
+ // Now, while the content is still fully laid out — the collapsing
45339
+ // track uncovers a content frozen at that size.
45340
+ freezeContentSize();
45341
+ }
45342
+ flushSyncRendering(() => {
45343
+ setOpened(false);
45344
+ setSettled(!collapsing);
45345
+ });
45346
+ if (restoreFocus) {
45347
+ restoreFocus(closeEvent);
45348
+ } else if (focusedBeforeClose && contentContainerAtClose && contentContainerAtClose.contains(focusedBeforeClose)) {
45349
+ const uiElement = uiRef.current;
45350
+ if (uiElement) {
45351
+ moveFocusTo(uiElement);
45352
+ } else {
45353
+ // Nothing of the expandable's own can hold the keyboard (no UI
45354
+ // part): the focus only has to leave the content becoming inert.
45355
+ focusedBeforeClose.blur();
45356
+ }
45357
+ }
45358
+ if (collapsing) {
45359
+ watchSettle();
45360
+ }
45361
+ rootRef.current.dispatchEvent(createToggleEvent(false));
45362
+ };
45363
+ };
45364
+ const toggle = event => {
45365
+ if (openController.opened) {
45366
+ openController.requestClose(event, {
45367
+ isCancel: true
45368
+ });
45369
+ } else {
45370
+ openController.open(event);
44317
45371
  }
44318
45372
  };
44319
45373
  const findFirstFocusableInContent = () => {
@@ -44321,119 +45375,11 @@ const Expandable = props => {
44321
45375
  if (!contentContainer) {
44322
45376
  return null;
44323
45377
  }
44324
- const autofocusElement = contentContainer.querySelector("[autofocus]");
44325
- if (autofocusElement) {
44326
- return autofocusElement;
44327
- }
44328
45378
  return findAfter(contentContainer, elementIsFocusable, {
44329
45379
  root: contentContainer
44330
45380
  });
44331
45381
  };
44332
45382
 
44333
- // Follow `open`/`signal` changes after mount (the initial value is already
44334
- // in the state above). A self-initiated toggle that wrote the signal lands
44335
- // here too and no-ops, since the state already matches.
44336
- const isFirstOpenRequestedRunRef = useRef(true);
44337
- useLayoutEffect(() => {
44338
- if (isFirstOpenRequestedRunRef.current) {
44339
- isFirstOpenRequestedRunRef.current = false;
44340
- return;
44341
- }
44342
- if (openRequested === undefined) {
44343
- return;
44344
- }
44345
- toggleTo(openRequested);
44346
- }, [openRequested]);
44347
-
44348
- // A state change: tell the world (the "toggle" event), move the focus, and
44349
- // set up the reveal. Skipped on mount — nothing changed, so neither the
44350
- // event nor a transition exists (and a page must not have its focus stolen
44351
- // by an expandable that was simply already open).
44352
- const isFirstOpenedRunRef = useRef(true);
44353
- useLayoutEffect(() => {
44354
- if (isFirstOpenedRunRef.current) {
44355
- isFirstOpenedRunRef.current = false;
44356
- return undefined;
44357
- }
44358
- const root = rootRef.current;
44359
- root.dispatchEvent(createToggleEvent(opened));
44360
- if (opened) {
44361
- if (autoFocus) {
44362
- const firstFocusableElement = findFirstFocusableInContent();
44363
- if (firstFocusableElement) {
44364
- firstFocusableElement.focus();
44365
- }
44366
- }
44367
- } else {
44368
- const focusedBeforeClose = focusedBeforeCloseRef.current;
44369
- focusedBeforeCloseRef.current = null;
44370
- if (focusedBeforeClose && contentContainerRef.current && contentContainerRef.current.contains(focusedBeforeClose)) {
44371
- uiRef.current.focus();
44372
- }
44373
- }
44374
- if (!animation) {
44375
- return undefined;
44376
- }
44377
- const contentContainer = contentContainerRef.current;
44378
- const contentElement = contentContainer.firstElementChild.firstElementChild;
44379
- if (opened) {
44380
- // The reveal needs the content at its final size before the track
44381
- // starts moving, and the final size only exists in the open state —
44382
- // the reflow trick (see instructions.md, CSS section), with transitions
44383
- // suppressed BEFORE the first layout read: this effect runs pre-paint,
44384
- // so any earlier read would itself be the first recalc of the open
44385
- // state and would start the track transition (see revealStartSizeRef).
44386
- contentContainer.style.transitionProperty = "none";
44387
- const finalRect = contentElement.getBoundingClientRect();
44388
- if (isColumn) {
44389
- contentElement.style.width = `${finalRect.width}px`;
44390
- contentElement.style.height = `${finalRect.height}px`;
44391
- } else {
44392
- contentElement.style.height = `${finalRect.height}px`;
44393
- }
44394
- // Put the tracks back where the last paint left them and let the
44395
- // transition play from there. In fr — px does not interpolate with fr.
44396
- const startRect = revealStartSizeRef.current;
44397
- revealStartSizeRef.current = null;
44398
- const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
44399
- if (isColumn) {
44400
- contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
44401
- if (!closedContentSized) {
44402
- // The height opens alongside the width (a closed column expandable
44403
- // is only as tall as its UI) — unless the closed content already
44404
- // sizes it, where only the width has anywhere to go.
44405
- contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
44406
- }
44407
- } else {
44408
- contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
44409
- }
44410
- // That starting frame must be genuinely rendered to transition from it,
44411
- // and transitions re-enabled BEFORE the flip back to the open value —
44412
- // same order as popover.jsx's own reflow trick.
44413
- contentContainer.getBoundingClientRect();
44414
- contentContainer.style.transitionProperty = "";
44415
- contentContainer.style.gridTemplateColumns = "";
44416
- contentContainer.style.gridTemplateRows = "";
44417
- }
44418
- // (closing froze the content in toggleTo, while it was still laid out)
44419
- const cancel = whenTransitionSettles(contentContainer, () => {
44420
- contentElement.style.width = "";
44421
- contentElement.style.height = "";
44422
- setSettled(true);
44423
- });
44424
- return cancel;
44425
- }, [opened]);
44426
- useLayoutEffect(() => {
44427
- if (settled && !opened && mountedWhileOpened) {
44428
- setContentMounted(false);
44429
- }
44430
- }, [settled, opened, mountedWhileOpened]);
44431
- useLayoutEffect(() => {
44432
- if (mountedAlways) {
44433
- setContentMounted(true);
44434
- }
44435
- }, [mountedAlways]);
44436
-
44437
45383
  // closedContentSized (column + mount="always"): the closed content sizes
44438
45384
  // the height (see the CSS), which is only right if it lies at its OPEN
44439
45385
  // width — at its natural closed width (a 0-wide track) it would wrap
@@ -44457,17 +45403,16 @@ const Expandable = props => {
44457
45403
  contentContainer.getBoundingClientRect();
44458
45404
  contentContainer.style.transitionProperty = "";
44459
45405
  }, [closedContentSized, opened, settled, contentMounted]);
44460
-
44461
- // Mounted already open: the content is visible, its data is due.
44462
- useEffect(() => {
44463
- if (openedRef.current && hasAction) {
44464
- runUnwatched(() => effectiveAction.run());
44465
- }
44466
- }, []);
44467
45406
  const onRootKeyDown = keyboardEvent => {
44468
45407
  if (!arrowKeyShortcuts) {
44469
45408
  return;
44470
45409
  }
45410
+ // The shortcuts all speak about the UI part — opening from it, stepping
45411
+ // into the content, stepping back to it. Without one the expandable is
45412
+ // driven from outside and the keys belong to whatever drives it.
45413
+ if (!uiRef.current) {
45414
+ return;
45415
+ }
44471
45416
  // A nested expandable (deeper, so heard first) already answered this key.
44472
45417
  if (keyboardEvent.defaultPrevented) {
44473
45418
  return;
@@ -44486,9 +45431,9 @@ const Expandable = props => {
44486
45431
  if (document.activeElement !== uiRef.current) {
44487
45432
  return;
44488
45433
  }
44489
- if (!openedRef.current) {
45434
+ if (!openController.opened) {
44490
45435
  keyboardEvent.preventDefault();
44491
- toggleTo(true);
45436
+ openController.open(keyboardEvent);
44492
45437
  return;
44493
45438
  }
44494
45439
  const firstFocusableElementInContent = findFirstFocusableInContent();
@@ -44496,20 +45441,21 @@ const Expandable = props => {
44496
45441
  return;
44497
45442
  }
44498
45443
  keyboardEvent.preventDefault();
44499
- firstFocusableElementInContent.focus();
45444
+ moveFocusTo(firstFocusableElementInContent);
44500
45445
  return;
44501
45446
  }
44502
45447
  if (key === closeKeyShortcut) {
44503
- if (!openedRef.current) {
45448
+ if (!openController.opened) {
44504
45449
  return;
44505
45450
  }
44506
45451
  const uiElement = uiRef.current;
45452
+ keyboardEvent.preventDefault();
44507
45453
  if (document.activeElement === uiElement) {
44508
- keyboardEvent.preventDefault();
44509
- toggleTo(false);
45454
+ openController.requestClose(keyboardEvent, {
45455
+ isCancel: true
45456
+ });
44510
45457
  } else {
44511
- keyboardEvent.preventDefault();
44512
- uiElement.focus();
45458
+ moveFocusTo(uiElement);
44513
45459
  }
44514
45460
  }
44515
45461
  };
@@ -44528,7 +45474,7 @@ const Expandable = props => {
44528
45474
  return;
44529
45475
  }
44530
45476
  }
44531
- toggleTo(!openedRef.current);
45477
+ toggle(clickEvent);
44532
45478
  };
44533
45479
 
44534
45480
  // Space/Enter on the UI part itself (role button) — a key pressed on a
@@ -44545,27 +45491,25 @@ const Expandable = props => {
44545
45491
  } = keyboardEvent;
44546
45492
  if (key === " " || key === "Enter") {
44547
45493
  keyboardEvent.preventDefault();
44548
- toggleTo(!openedRef.current);
45494
+ toggle(keyboardEvent);
44549
45495
  }
44550
45496
  };
44551
45497
 
44552
- // Where the content went, so the marker can point at it while open (closed
44553
- // always points right): below by default, above when the content part comes
44554
- // first, beside for layout="column" (the chevron then points back toward
44555
- // the UI: left).
44556
- const childArray = toChildArray(children);
44557
- const firstPart = childArray.find(child => child && (child.type === ExpandableUI || child.type === ExpandableContent));
44558
- const hasParts = Boolean(firstPart);
44559
- const contentFirst = hasParts && firstPart.type === ExpandableContent;
44560
- const openDirection = isColumn ? "left" : contentFirst ? "up" : "down";
45498
+ // Only the two directions the layout has room for: a column layout reveals
45499
+ // sideways, a stacked one vertically.
45500
+ const revealDirection = isColumn ? openDirection === "left" ? "left" : "right" : openDirection === "up" ? "up" : "down";
45501
+ // While open the marker points at the content, while closed always right.
45502
+ // In a column layout it points back toward the UI part whichever side the
45503
+ // content took, "right" being the closed direction already.
45504
+ const markerDirection = isColumn ? "left" : revealDirection;
44561
45505
  const expandableContextValue = {
44562
45506
  opened,
44563
45507
  loading: loading || hasAction && actionLoading,
44564
45508
  contentMounted,
44565
45509
  hasAction,
44566
45510
  effectiveAction,
44567
- openDirection,
44568
- toggleTo,
45511
+ markerDirection,
45512
+ openController,
44569
45513
  onUIClick,
44570
45514
  onUIPointerDown,
44571
45515
  onUIKeyDown,
@@ -44574,12 +45518,9 @@ const Expandable = props => {
44574
45518
  contentId
44575
45519
  };
44576
45520
 
44577
- // Explicit parts win; the `ui` prop + children is the shorthand for the
44578
- // common shape (UI above, content below). Parts are cloned on every render:
44579
- // reference-stable children would be bailed out of the commit, leaving
44580
- // their context subscription to re-render them asynchronously — after the
44581
- // [opened] effect above, which measures the content they render.
44582
- const body = hasParts ? childArray.map(child => child && (child.type === ExpandableUI || child.type === ExpandableContent) ? cloneElement(child) : child) : jsxs(Fragment$1, {
45521
+ // The `ui` prop is the shorthand for the common shape: it names the UI part
45522
+ // and children are the content. Without it the children ARE the parts.
45523
+ const body = ui === undefined ? children : jsxs(Fragment$1, {
44583
45524
  children: [jsx(ExpandableUI, {
44584
45525
  children: ui
44585
45526
  }), jsx(ExpandableContent, {
@@ -44591,7 +45532,7 @@ const Expandable = props => {
44591
45532
  baseClassName: "navi_expandable",
44592
45533
  "aria-expanded": opened ? "true" : "false",
44593
45534
  "data-layout": isColumn ? "column" : undefined,
44594
- "data-content-first": contentFirst ? "" : undefined,
45535
+ "data-open-direction": revealDirection,
44595
45536
  "data-animation": animation ? "" : undefined,
44596
45537
  "data-settled": settled ? "" : undefined,
44597
45538
  "data-content-scrolls": maxContentHeight === undefined ? undefined : "",
@@ -44606,11 +45547,17 @@ const Expandable = props => {
44606
45547
  },
44607
45548
  onnavi_request_open: e => {
44608
45549
  rest.onnavi_request_open?.(e);
44609
- toggleTo(true);
45550
+ openController.open(e);
44610
45551
  },
44611
45552
  onnavi_request_close: e => {
44612
45553
  rest.onnavi_request_close?.(e);
44613
- toggleTo(false);
45554
+ const closing = openController.requestClose(e, {
45555
+ isCancel: e.detail?.isCancel
45556
+ });
45557
+ if (!closing) {
45558
+ // Said back to whoever asked: --navi-close:all stops climbing here.
45559
+ e.preventDefault();
45560
+ }
44614
45561
  },
44615
45562
  onKeyDown: e => {
44616
45563
  rest.onKeyDown?.(e);
@@ -44631,32 +45578,35 @@ const Expandable = props => {
44631
45578
  * The always-visible part that reveals the content: the focusable toggle
44632
45579
  * itself (role button — click, Space/Enter, arrow keys), holding the marker
44633
45580
  * plus whatever it is given — any markup, a function of `{ open }` included.
44634
- * Controls inside it keep their own behavior and do not toggle. Its position
44635
- * among the parts decides where the content goes (before the content: content
44636
- * below/right; after it: content above/left).
45581
+ * Controls inside it keep their own behavior and do not toggle. A Box, so it
45582
+ * takes the layout and style props every Box takes.
44637
45583
  *
44638
45584
  * @type {import("ignore:preact").FunctionComponent<{
45585
+ * marker?: false | import("ignore:preact").ComponentChildren,
44639
45586
  * children?: import("ignore:preact").ComponentChildren | ((state: { open: boolean }) => import("ignore:preact").ComponentChildren),
44640
45587
  * }>}
45588
+ * @param marker - The chevron drawn before the label. `false` removes it (a UI
45589
+ * part that is already an icon has no room for one); any node replaces it.
44641
45590
  */
44642
45591
  const ExpandableUI = ({
45592
+ marker,
44643
45593
  children,
44644
45594
  ...rest
44645
45595
  }) => {
44646
45596
  const {
44647
45597
  opened,
44648
45598
  loading,
44649
- openDirection,
44650
- toggleTo,
45599
+ markerDirection,
45600
+ openController,
44651
45601
  onUIClick,
44652
45602
  onUIPointerDown,
44653
45603
  onUIKeyDown,
44654
45604
  uiRef,
44655
45605
  contentId
44656
45606
  } = useExpandableContext("UI");
44657
- return jsxs("div", {
45607
+ return jsxs(Box, {
44658
45608
  ref: uiRef,
44659
- className: "navi_expandable_ui",
45609
+ baseClassName: "navi_expandable_ui",
44660
45610
  role: "button",
44661
45611
  tabIndex: 0,
44662
45612
  "aria-expanded": opened,
@@ -44674,21 +45624,23 @@ const ExpandableUI = ({
44674
45624
  onnavi_command: e => {
44675
45625
  onNaviCommand(e);
44676
45626
  },
44677
- onnavi_request_open: () => {
44678
- toggleTo(true);
45627
+ onnavi_request_open: e => {
45628
+ openController.open(e);
44679
45629
  },
44680
- onnavi_request_close: () => {
44681
- toggleTo(false);
45630
+ onnavi_request_close: e => {
45631
+ openController.requestClose(e, {
45632
+ isCancel: e.detail?.isCancel
45633
+ });
44682
45634
  },
44683
45635
  ...rest,
44684
- children: [jsx("span", {
45636
+ children: [marker === false ? null : jsx("span", {
44685
45637
  className: "navi_expandable_marker",
44686
45638
  "aria-hidden": "true",
44687
- children: jsx(SummaryMarker, {
45639
+ children: marker === undefined ? jsx(SummaryMarker, {
44688
45640
  open: opened,
44689
45641
  loading: loading,
44690
- openDirection: openDirection
44691
- })
45642
+ openDirection: markerDirection
45643
+ }) : marker
44692
45644
  }), jsx("div", {
44693
45645
  className: "navi_expandable_ui_label",
44694
45646
  children: typeof children === "function" ? children({
@@ -44699,8 +45651,9 @@ const ExpandableUI = ({
44699
45651
  };
44700
45652
 
44701
45653
  /**
44702
- * The revealed part. With an `action` on the Expandable, children may be a
44703
- * function `(data) => ui` or a branches object — see ActionRenderer.
45654
+ * The revealed part, a Box like the UI part. With an `action` on the
45655
+ * Expandable, children may be a function `(data) => ui` or a branches object —
45656
+ * see ActionRenderer.
44704
45657
  *
44705
45658
  * @type {import("ignore:preact").FunctionComponent<{}>}
44706
45659
  */
@@ -44723,10 +45676,10 @@ const ExpandableContent = ({
44723
45676
  children: children
44724
45677
  });
44725
45678
  }
44726
- return jsx("div", {
45679
+ return jsx(Box, {
44727
45680
  ref: contentContainerRef,
44728
45681
  id: contentId,
44729
- className: "navi_expandable_content_container",
45682
+ baseClassName: "navi_expandable_content_container",
44730
45683
  inert: opened ? undefined : true,
44731
45684
  ...rest,
44732
45685
  children: jsx("div", {
@@ -54346,806 +55299,212 @@ const useInputGroup = (ref) => {
54346
55299
  const allSelected =
54347
55300
  active.selectionStart === 0 &&
54348
55301
  active.selectionEnd === active.value.length;
54349
- const atEnd =
54350
- allSelected ||
54351
- (active.selectionStart === active.value.length &&
54352
- active.selectionEnd === active.value.length);
54353
- if (!atEnd) {
54354
- return;
54355
- }
54356
- const inputs = getInputs();
54357
- const idx = inputs.indexOf(active);
54358
- if (idx === -1) {
54359
- debugFocus(
54360
- e,
54361
- "InputGroup ArrowRight on non group input → do nothing",
54362
- );
54363
- return;
54364
- }
54365
- if (idx === inputs.length - 1) {
54366
- debugFocus(
54367
- e,
54368
- "InputGroup ArrowRight at end of last input → do nothing",
54369
- );
54370
- return;
54371
- }
54372
-
54373
- debugFocus(
54374
- e,
54375
- "InputGroup ArrowRight at end of input[%d] → focus input[%d]",
54376
- idx,
54377
- idx + 1,
54378
- );
54379
- e.preventDefault();
54380
- focusInput(inputs[idx + 1]);
54381
- return;
54382
- }
54383
- const allSelected =
54384
- active.selectionStart === 0 &&
54385
- active.selectionEnd === active.value.length;
54386
- const atStart =
54387
- allSelected ||
54388
- (active.selectionStart === 0 && active.selectionEnd === 0);
54389
- if (!atStart) {
54390
- return;
54391
- }
54392
- const inputs = getInputs();
54393
- const idx = inputs.indexOf(active);
54394
- if (idx === 0) {
54395
- return;
54396
- }
54397
- debugFocus(
54398
- e,
54399
- "InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
54400
- idx,
54401
- idx - 1,
54402
- );
54403
- e.preventDefault();
54404
- focusInput(inputs[idx - 1]);
54405
- };
54406
-
54407
- const handleNaviInputFull = (e) => {
54408
- if (!e.detail.event?.isTrusted) {
54409
- // Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
54410
- return;
54411
- }
54412
- const input = e.detail.event.currentTarget;
54413
- if (!el.contains(input)) {
54414
- return;
54415
- }
54416
- const inputs = getInputs();
54417
- const idx = inputs.indexOf(input);
54418
- if (idx === -1) {
54419
- return;
54420
- }
54421
- if (idx === inputs.length - 1) {
54422
- return;
54423
- }
54424
- const nextInput = inputs[idx + 1];
54425
- debugFocus(
54426
- e,
54427
- "InputGroup navi_input_full on input -> move to next input",
54428
- input,
54429
- nextInput,
54430
- );
54431
- e.preventDefault();
54432
- focusInput(nextInput);
54433
- };
54434
-
54435
- // const handlePaste = (e) => {
54436
- // const active = document.activeElement;
54437
- // if (!isTextInputElement(active) || !el.contains(active)) {
54438
- // return;
54439
- // }
54440
- // const inputs = getInputs();
54441
- // const startIdx = inputs.indexOf(active);
54442
- // if (startIdx === -1) {
54443
- // return;
54444
- // }
54445
- // const pastedText = e.clipboardData?.getData("text") ?? "";
54446
- // if (!pastedText) {
54447
- // return;
54448
- // }
54449
- // // Only intercept when the pasted text contains at least one separator
54450
- // // from the inputs starting at the focused position.
54451
- // const remainingInputs = inputs.slice(startIdx);
54452
- // const hasSeparatorMatch = remainingInputs.some(
54453
- // (input) =>
54454
- // input.dataset.separator &&
54455
- // pastedText.includes(input.dataset.separator),
54456
- // );
54457
- // if (!hasSeparatorMatch) {
54458
- // return;
54459
- // }
54460
- // e.preventDefault();
54461
- // let remaining = pastedText;
54462
- // let lastFilledIdx = startIdx;
54463
- // for (let i = 0; i < remainingInputs.length; i++) {
54464
- // const input = remainingInputs[i];
54465
- // const separator = input.dataset.separator;
54466
- // let part;
54467
- // if (separator && remaining.includes(separator)) {
54468
- // const sepIdx = remaining.indexOf(separator);
54469
- // part = remaining.slice(0, sepIdx);
54470
- // remaining = remaining.slice(sepIdx + separator.length);
54471
- // } else {
54472
- // part = remaining;
54473
- // remaining = "";
54474
- // }
54475
- // requestSubPaste(input, part, e);
54476
- // lastFilledIdx = startIdx + i;
54477
- // if (remaining === "") {
54478
- // break;
54479
- // }
54480
- // }
54481
- // focusInput(inputs[lastFilledIdx]);
54482
- // };
54483
-
54484
- el.addEventListener("keydown", handleKeyDown, { capture: true });
54485
- el.addEventListener("navi_input_full", handleNaviInputFull);
54486
- // el.addEventListener("paste", handlePaste, { capture: true });
54487
- return () => {
54488
- el.removeEventListener("keydown", handleKeyDown, { capture: true });
54489
- el.removeEventListener("navi_input_full", handleNaviInputFull);
54490
- // el.removeEventListener("paste", handlePaste, { capture: true });
54491
- };
54492
- }, [debugFocus]);
54493
- };
54494
-
54495
- // const requestSubPaste = (input, value, event) => {
54496
- // dispatchRequestInteraction(input, {
54497
- // event,
54498
- // name: "subpaste",
54499
- // allowed: () => {
54500
- // dispatchRequestSetUIState(input, value, { event });
54501
- // },
54502
- // });
54503
- // };
54504
-
54505
- const isTextInputElement = (el) => {
54506
- if (!el) {
54507
- return false;
54508
- }
54509
- if (el.tagName === "TEXTAREA") {
54510
- return true;
54511
- }
54512
- if (el.tagName !== "INPUT") {
54513
- return false;
54514
- }
54515
- const type = el.type || "text";
54516
- return (
54517
- type === "text" ||
54518
- type === "search" ||
54519
- type === "url" ||
54520
- type === "tel" ||
54521
- type === "email" ||
54522
- type === "password" ||
54523
- type === "number"
54524
- );
54525
- };
54526
-
54527
- // How many lines the thing around a component gives it, when that thing caps
54528
- // its own height and cannot cap what it holds.
54529
- //
54530
- // A <Picker> is the case it exists for: its value is clamped with maxLines,
54531
- // which is CSS line-clamp — it counts line boxes of inline text and has no idea
54532
- // what a wrapped flex row is. So a <BadgeList> rendered as a picker's ui reads
54533
- // the number from here and caps its own rows to it, and the picker turns its
54534
- // own clamp off (see .navi_picker_value:has(.navi_badge_list) in picker.jsx).
54535
- const MaxLinesContext = createContext(undefined);
54536
-
54537
- // When a component render a prop that can be anything (js value of preact element)
54538
- // make sure it cannot throw during render by converting it to a string if it's not a valid preact element or a primitive value
54539
- const renderSafe = (value) => {
54540
- if (
54541
- value === null ||
54542
- value === undefined ||
54543
- typeof value === "string" ||
54544
- typeof value === "number" ||
54545
- typeof value === "boolean"
54546
- ) {
54547
- return value;
54548
- }
54549
-
54550
- if (isValidElement(value)) {
54551
- return value;
54552
- }
54553
-
54554
- return String(value);
54555
- };
54556
-
54557
- // How long a popup waits before handing the focus to a field, when giving it
54558
- // is what raises the on-screen keyboard.
54559
- //
54560
- // The focus is normally given as early as possible. But a popup places itself
54561
- // against the viewport, and on a phone the keyboard takes a third of that
54562
- // viewport away the moment a field receives focus — so the two landing in the
54563
- // same tick means the popup is still arriving when the room under it changes,
54564
- // and it re-places itself mid-entrance. Waiting lets it settle first, and the
54565
- // keyboard then shrinks a box that has stopped moving.
54566
- //
54567
- // Long enough to outlast an entrance transition rather than merely reaching
54568
- // the next frame: what has to be over is the popup MOVING, not one paint of
54569
- // it.
54570
- const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
54571
-
54572
- /**
54573
- * Owns open/close decision-making for a popup (Dialog or Popover): guards
54574
- * against duplicate requests and notifies the popup owner's own reactions.
54575
- *
54576
- * `controller.openEffect` is implemented by the controlled element (Dialog or
54577
- * Popover), reassigned on every render so it always closes over the latest
54578
- * props (scrollCapture, anchor, etc.). It performs whatever DOM side effects
54579
- * are needed to make the element actually open (`showModal()`/`showPopover()`,
54580
- * focus transfer, positioning, traps...) and returns its cleanup —
54581
- * the matching side effects to sync back to closed (`close()`/
54582
- * `hidePopover()`, releasing traps...). That cleanup is kept private to the
54583
- * controller (not exposed as a property) and invoked when the popup actually
54584
- * closes, however that happens.
54585
- *
54586
- * Dialog/Popover also call `openController.requestClose(e, { isCancel })` for
54587
- * their own internal triggers (backdrop click, Escape).
54588
- *
54589
- * `openHandler` is the popup owner's own business logic, passed once to
54590
- * `createOpenController`. Its return value is `{ onRequestClose, onClose }`,
54591
- * in the spirit of CloseWatcher
54592
- * (https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher) but with
54593
- * clearer naming than its cancel/close pair:
54594
- * - `onRequestClose(e)`: about to close — call `e.preventDefault()` to stay
54595
- * open. Validation lives here.
54596
- * - `onClose(e)`: actually closing, not preventable — final reactions live here.
54597
- *
54598
- * The controller exposes matching action methods:
54599
- * - `open()`: requests opening — calls the caller's `onOpen` (see below), then
54600
- * `mountContent`/`openEffect`, then `openHandler`.
54601
- * - `requestClose()`: requests closing — calls `onRequestClose` then `onClose`,
54602
- * stopping after the first if denied. The popup may choose to stay open,
54603
- * which is what a `false` return says (`true`: closed, or closed already).
54604
- * - `close()`: closes for real — calls only `onClose`, skipping
54605
- * `onRequestClose` entirely. Used when there really is no choice (e.g. the
54606
- * popup unmounting).
54607
- */
54608
- const createOpenController = (
54609
- openHandler,
54610
- { debugInteraction } = {},
54611
- ) => {
54612
- let closeHandlers = null; // { onRequestClose, onClose } returned by openHandler
54613
- let openEffectCleanup = null; // function returned by openEffect, undoes its DOM side effects
54614
- let focusedAtClose = null; // what held the focus when the close was decided, see performClose
54615
-
54616
- // Set true while we're waiting to see whether the click that follows a
54617
- // mousedown-close will land back on whatever would reopen us — see
54618
- // armSuppressNextOpenRequest below.
54619
- let suppressNextOpenRequest = false;
54620
- let disarmSuppressNextOpenRequest = null;
54621
-
54622
- // When the popup closes because of a mousedown (e.g. clicking the
54623
- // backdrop), the browser still dispatches the matching "click" afterward.
54624
- // If that click lands back on the element that triggers open() (e.g. the
54625
- // picker button), it would immediately reopen the popup. We cannot
54626
- // preventDefault/stopPropagation the mousedown to stop that — the browser
54627
- // dispatches the click regardless.
54628
- //
54629
- // Instead: arm a capture-phase "click" listener on document. Capture fires
54630
- // before the click reaches its target, so by the time any bubble-phase
54631
- // click handler (e.g. the trigger button's onClick, which calls
54632
- // controller.open()) runs, `suppressNextOpenRequest` is already true and
54633
- // open() ignores the request — no need to know *which* element triggers
54634
- // it. A bubble-phase listener (runs after everything else, once the click
54635
- // reaches document) clears the flag if nothing consumed it, meaning this
54636
- // click never resulted in an open() call. A timeout is a last-resort safety
54637
- // net in case the click never reaches document at all (e.g. some ancestor
54638
- // called stopPropagation()) — a *task*, never a microtask: a microtask
54639
- // checkpoint runs between two listeners of the same trusted event dispatch,
54640
- // so it would clear the flag before the bubble-phase handler this is meant
54641
- // to block ever runs, which is precisely the case it exists for.
54642
- const armSuppressNextOpenRequest = () => {
54643
- disarmSuppressNextOpenRequest?.();
54644
- let safetyTimeout = null;
54645
- const onCaptureClick = () => {
54646
- document.removeEventListener("click", onCaptureClick, {
54647
- capture: true,
54648
- });
54649
- suppressNextOpenRequest = true;
54650
- document.addEventListener("click", onBubbleClick);
54651
- safetyTimeout = setTimeout(() => {
54652
- suppressNextOpenRequest = false;
54653
- });
54654
- };
54655
- const onBubbleClick = () => {
54656
- document.removeEventListener("click", onBubbleClick);
54657
- clearTimeout(safetyTimeout);
54658
- suppressNextOpenRequest = false;
54659
- };
54660
- disarmSuppressNextOpenRequest = () => {
54661
- clearTimeout(safetyTimeout);
54662
- document.removeEventListener("click", onCaptureClick, {
54663
- capture: true,
54664
- });
54665
- document.removeEventListener("click", onBubbleClick);
54666
- };
54667
- document.addEventListener("click", onCaptureClick, { capture: true });
54668
- };
54669
-
54670
- const performClose = (closeEvent) => {
54671
- controller.opened = false;
54672
- // Read before any close effect touches the DOM: closing a native <dialog>
54673
- // hands the focus back to whatever held it at showModal() time, so by the
54674
- // time the close cleanup runs, the popup's content has already lost the
54675
- // focus and could not be remembered for the next open.
54676
- focusedAtClose = document.activeElement;
54677
-
54678
- prevent_reopen: {
54679
- const mousedownEvent = findEvent(closeEvent, "mousedown");
54680
- if (mousedownEvent) {
54681
- debugInteraction(
54682
- closeEvent,
54683
- `closed by mousedown -> ignore next click`,
54684
- );
54685
- armSuppressNextOpenRequest();
54686
- break prevent_reopen;
54687
- }
54688
-
54689
- // The keyboard counterpart of the mousedown case above: a key press that
54690
- // closes the popup and then goes on to activate the trigger, reopening it
54691
- // on the spot. Space and Enter both get there, but not the same way and
54692
- // not always — preventing the key unconditionally would eat presses that
54693
- // were never going to activate anything (a space typed in a field, an
54694
- // Enter the popup's own handler already consumed), so each is verified
54695
- // before being prevented.
54696
-
54697
- // Space: pressed on the trigger itself, which still has focus (closing
54698
- // does not move it away from an element outside the popup). The browser
54699
- // turns that press into a click on keyup, and that click lands back on
54700
- // the trigger. Asked of the browser's own default action rather than
54701
- // guessed from the tag name: a space that scrolls, or types into a field
54702
- // inside the popup, has no activation to prevent and preventing it would
54703
- // swallow the scroll / the character.
54704
- const spaceKeyEvent = findEvent(
54705
- closeEvent,
54706
- (e) => e.type === "keydown" && e.key === " ",
54707
- );
54708
- if (
54709
- spaceKeyEvent &&
54710
- getKeyboardEventDefaultAction(spaceKeyEvent) === "activate"
54711
- ) {
54712
- debugInteraction(
54713
- closeEvent,
54714
- `closed by space on <${spaceKeyEvent.target.tagName.toLowerCase()}> -> prevent the click it would produce (space.preventDefault())`,
54715
- );
54716
- // The browser won't dispatch the click, and our "space_to_open" sees
54717
- // defaultPrevented too so it won't try to open the picker either.
54718
- spaceKeyEvent.preventDefault();
54719
- break prevent_reopen;
54720
- }
54721
-
54722
- // Enter: pressed inside the popup (its own submit button, or implicit
54723
- // submission from a field it contains). The popup closes synchronously
54724
- // and focus is restored to the trigger, so the activation the browser
54725
- // still owes this press is delivered to the trigger instead.
54726
- //
54727
- // Verified on two counts: the press still owes an activation (the
54728
- // browser's default action for it is one — "activate" on a submit button,
54729
- // "form_submit" on a field — and nothing has consumed it yet), and it came
54730
- // from inside the popup. An Enter from outside is not this case at all.
54731
- const enterKeyEvent = findEvent(
54732
- closeEvent,
54733
- (e) => e.type === "keydown" && e.key === "Enter",
54734
- );
54735
- if (
54736
- enterKeyEvent &&
54737
- !enterKeyEvent.defaultPrevented &&
54738
- ENTER_ACTIVATING_DEFAULT_ACTION_SET.has(
54739
- getKeyboardEventDefaultAction(enterKeyEvent),
54740
- ) &&
54741
- isInsideOpenPopup(enterKeyEvent.target)
54742
- ) {
54743
- debugInteraction(
54744
- closeEvent,
54745
- `closed by enter from inside the popup -> prevent the activation it would deliver to the trigger (enter.preventDefault())`,
54746
- );
54747
- enterKeyEvent.preventDefault();
54748
- break prevent_reopen;
54749
- }
54750
- }
54751
-
54752
- // Sync the DOM closed first (releasing the focus trap) — only then run
54753
- // the owner's own reaction (onClose may restore focus to an element
54754
- // outside the popup, which the focus trap would otherwise fight while
54755
- // still active).
54756
- openEffectCleanup?.(closeEvent);
54757
- openEffectCleanup = null;
54758
- closeHandlers?.onClose?.(closeEvent);
54759
- closeHandlers = null;
54760
- // Last: the close effects above are what starts the exit transition the
54761
- // content must outlive (see popup_content_mount.js).
54762
- controller.unmountContent?.();
54763
- controller.onOpenedChange?.(false, closeEvent);
54764
- };
54765
- const controller = {
54766
- opened: false,
54767
- openEffect: null,
54768
- // Set by the controlled element (see popup_content_mount.js) when its
54769
- // content is still waiting for a first open to be built. Called below,
54770
- // before openEffect, so the popup measures and positions the real thing.
54771
- mountContent: null,
54772
- // The caller's own `onOpen`, set by Dialog/Popover from their props on
54773
- // every render (like openEffect). Called BEFORE mountContent, so whatever
54774
- // it decides — which record this dialog is opening on — is already true by
54775
- // the time the content is built, positioned and shown. That order is the
54776
- // whole point: learning it afterwards means the content mounted on the
54777
- // previous subject first.
54778
- onOpen: null,
54779
- // The counterpart, set only when the popup was told to throw its content
54780
- // away on close (`mount="while-opened"`). Called from performClose above.
54781
- unmountContent: null,
54782
- // Told whenever `opened` actually changes, whatever asked for it — an
54783
- // interaction, a command, a prop — with the event that asked. What lets a
54784
- // `signal` prop reflect the popup's real state, and a `navState` prop write
54785
- // it into the history entry (see useOpenPropsEffectOnOpenController);
54786
- // called once the open/close has fully happened rather than mid-sequence.
54787
- onOpenedChange: null,
54788
- open: (e, detail) => {
54789
- if (controller.opened || !controller.openEffect) {
55302
+ const atEnd =
55303
+ allSelected ||
55304
+ (active.selectionStart === active.value.length &&
55305
+ active.selectionEnd === active.value.length);
55306
+ if (!atEnd) {
55307
+ return;
55308
+ }
55309
+ const inputs = getInputs();
55310
+ const idx = inputs.indexOf(active);
55311
+ if (idx === -1) {
55312
+ debugFocus(
55313
+ e,
55314
+ "InputGroup ArrowRight on non group input → do nothing",
55315
+ );
55316
+ return;
55317
+ }
55318
+ if (idx === inputs.length - 1) {
55319
+ debugFocus(
55320
+ e,
55321
+ "InputGroup ArrowRight at end of last input → do nothing",
55322
+ );
55323
+ return;
55324
+ }
55325
+
55326
+ debugFocus(
55327
+ e,
55328
+ "InputGroup ArrowRight at end of input[%d] → focus input[%d]",
55329
+ idx,
55330
+ idx + 1,
55331
+ );
55332
+ e.preventDefault();
55333
+ focusInput(inputs[idx + 1]);
54790
55334
  return;
54791
55335
  }
54792
- if (suppressNextOpenRequest) {
54793
- suppressNextOpenRequest = false;
55336
+ const allSelected =
55337
+ active.selectionStart === 0 &&
55338
+ active.selectionEnd === active.value.length;
55339
+ const atStart =
55340
+ allSelected ||
55341
+ (active.selectionStart === 0 && active.selectionEnd === 0);
55342
+ if (!atStart) {
54794
55343
  return;
54795
55344
  }
54796
- const requestOpenEvent = new CustomEvent("navi_request_open", {
54797
- detail: { event: e, ...detail },
54798
- cancelable: true,
54799
- });
54800
- chainEvent(requestOpenEvent, e);
54801
- // we prepare focus transfer before actually opening the popover/dialog
54802
- // because opnening dialog makes browser try to transfer focus (which ends up in document.body for instance)
54803
- const focusTransfer = prepareFocusTransfer(
54804
- requestOpenEvent,
54805
- debugInteraction,
55345
+ const inputs = getInputs();
55346
+ const idx = inputs.indexOf(active);
55347
+ if (idx === 0) {
55348
+ return;
55349
+ }
55350
+ debugFocus(
55351
+ e,
55352
+ "InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
55353
+ idx,
55354
+ idx - 1,
54806
55355
  );
54807
- controller.transferFocusOnOpen = (el) => {
54808
- // requestOpenEvent, not the raw `e` — getFocusedBeforeTransfer needs
54809
- // e.detail.eventChain (built by chainEvent above) to recover the
54810
- // element a mousedown/click landed on. `e` itself is usually the raw
54811
- // native event: its own `.detail` is a number (click count) on a
54812
- // MouseEvent, so `e.detail.eventChain` is always undefined and the
54813
- // mousedown/click branches below never matched — silently falling
54814
- // back to `document.activeElement`, which is often `document.body`
54815
- // once mousedown.preventDefault() has kept focus from landing
54816
- // anywhere yet.
55356
+ e.preventDefault();
55357
+ focusInput(inputs[idx - 1]);
55358
+ };
54817
55359
 
54818
- // Two conditions, and both are about THIS opening rather than about
54819
- // the device:
54820
- // - the interaction: only a finger raises a virtual keyboard, and a
54821
- // hybrid tablet answers "coarse" to every device-level signal
54822
- // whichever of its two inputs was just used — the open event still
54823
- // remembers which one it was. An opening with no pointer in it at
54824
- // all (a keyboard shortcut, defaultOpen, an app calling open()) is
54825
- // not one either.
54826
- // - the target: focusing a button raises nothing, so there is nothing
54827
- // to wait for and the focus stays immediate. Only a field the
54828
- // keyboard comes up for is worth delaying — which is why the
54829
- // decision is taken on the resolved target, inside transferFocus.
54830
- const openedByTouch = Boolean(
54831
- findEvent(requestOpenEvent, isTouchDrivenEvent),
54832
- );
54833
- const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
54834
- getDelay: (target) =>
54835
- openedByTouch && isEditableTarget(target)
54836
- ? FOCUS_DELAY_ON_KEYBOARD_MS
54837
- : 0,
54838
- });
54839
- return (closeEvent) => {
54840
- // Closed before the delay was up: the focus was never given, so it
54841
- // must not be given now — to a field inside a popup on its way out,
54842
- // raising the keyboard as it goes.
54843
- cancelPendingFocus?.();
54844
- markAutofocusRestoreOnClose(el, closeEvent, focusedAtClose);
54845
- const focusoutEvent = findEvent(closeEvent, "focusout");
54846
- if (focusoutEvent) {
54847
- debugInteraction(
54848
- closeEvent,
54849
- `closed by focusout -> let focus go away`,
54850
- );
54851
- } else {
54852
- const mousedownEvent = findEvent(closeEvent, "mousedown");
54853
- if (mousedownEvent) {
54854
- debugInteraction(
54855
- closeEvent,
54856
- "closed by mousedown -> prevent browser focus (mousedown.preventDefault())",
54857
- );
54858
- mousedownEvent.preventDefault();
54859
- }
54860
- focusTransfer.restoreFocus();
54861
- }
54862
- };
54863
- };
54864
- // Before mountContent, which builds the content, and before openEffect,
54865
- // which shows it: what the popup opens ON has to be known before either
54866
- // (see `onOpen` above).
54867
- controller.onOpen?.(requestOpenEvent);
54868
- // After prepareFocusTransfer, which has to record what held the focus
54869
- // before anything inside the popup can claim it, and before openEffect,
54870
- // which measures the popup to place it.
54871
- controller.mountContent?.();
54872
- // Only now — after the content has been built, before openEffect shows
54873
- // it. Dialog/Popover recompute aria-expanded and navi-hidden from this
54874
- // flag on every render, and mountContent above renders synchronously:
54875
- // flipping it any earlier commits an already-open DOM (aria-expanded
54876
- // "true", navi-hidden gone) before openEffect has run a single
54877
- // statement, so the "closed" frame it pins to transition from is in
54878
- // fact the open one and the entrance animation has nothing to play.
54879
- // It also gives the content it just built the opening it is documented
54880
- // to observe — mounted while the popup reads as closed, told it opened
54881
- // right after (see popup_content_mount.js and
54882
- // use_displayed_layout_effect.js).
54883
- controller.opened = true;
54884
- const openEffectReturnValue =
54885
- controller.openEffect(requestOpenEvent) || null;
54886
- openEffectCleanup = (closeEvent) => {
54887
- openEffectReturnValue?.(closeEvent);
54888
- };
54889
- closeHandlers = openHandler(requestOpenEvent) || null;
54890
- controller.onOpenedChange?.(true, requestOpenEvent);
54891
- },
54892
- requestClose: (
54893
- e = new CustomEvent("programmatic", { detail: {} }),
54894
- detail,
54895
- ) => {
54896
- if (!controller.opened) {
54897
- return true;
55360
+ const handleNaviInputFull = (e) => {
55361
+ if (!e.detail.event?.isTrusted) {
55362
+ // Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
55363
+ return;
54898
55364
  }
54899
- const requestCloseEvent = new CustomEvent("navi_request_close", {
54900
- detail: { event: e, ...detail },
54901
- cancelable: true,
54902
- });
54903
- chainEvent(requestCloseEvent, e);
54904
- closeHandlers?.onRequestClose?.(requestCloseEvent);
54905
- if (requestCloseEvent.defaultPrevented) {
54906
- // The native <dialog> "cancel" event (Escape key) closes the dialog
54907
- // by default; prevent that default so denial actually keeps it open.
54908
- const nativeCancelEvent = findEvent(requestCloseEvent, "cancel");
54909
- if (nativeCancelEvent) {
54910
- nativeCancelEvent.preventDefault();
54911
- }
54912
- return false;
55365
+ const input = e.detail.event.currentTarget;
55366
+ if (!el.contains(input)) {
55367
+ return;
54913
55368
  }
54914
- performClose(requestCloseEvent);
54915
- return true;
54916
- },
54917
- close: (e = new CustomEvent("programmatic", { detail: {} }), detail) => {
54918
- if (!controller.opened) {
55369
+ const inputs = getInputs();
55370
+ const idx = inputs.indexOf(input);
55371
+ if (idx === -1) {
54919
55372
  return;
54920
55373
  }
54921
- const closeEvent = new CustomEvent("navi_close", {
54922
- detail: { event: e, ...detail },
54923
- });
54924
- chainEvent(closeEvent, e);
54925
- // Skips onRequestClose entirely — there is no choice here.
54926
- performClose(closeEvent);
54927
- },
54928
- };
54929
- return controller;
54930
- };
54931
-
54932
- // Inside a popup that is open right now — the popup being closed, in practice,
54933
- // since that is the one the key press was delivered to.
54934
- const isInsideOpenPopup = (element) => {
54935
- if (!element || element.nodeType !== 1) {
54936
- return false;
54937
- }
54938
- return Boolean(element.closest("dialog[open], [popover]:popover-open"));
54939
- };
55374
+ if (idx === inputs.length - 1) {
55375
+ return;
55376
+ }
55377
+ const nextInput = inputs[idx + 1];
55378
+ debugFocus(
55379
+ e,
55380
+ "InputGroup navi_input_full on input -> move to next input",
55381
+ input,
55382
+ nextInput,
55383
+ );
55384
+ e.preventDefault();
55385
+ focusInput(nextInput);
55386
+ };
54940
55387
 
54941
- // What Enter is about to do when it is about to activate something: press the
54942
- // focused control, or submit the form around it. Anything else it can do
54943
- // (typing a newline, nothing at all) leaves no activation behind to land on the
54944
- // trigger once focus is restored.
54945
- const ENTER_ACTIVATING_DEFAULT_ACTION_SET = new Set([
54946
- "activate",
54947
- "form_submit",
54948
- ]);
55388
+ // const handlePaste = (e) => {
55389
+ // const active = document.activeElement;
55390
+ // if (!isTextInputElement(active) || !el.contains(active)) {
55391
+ // return;
55392
+ // }
55393
+ // const inputs = getInputs();
55394
+ // const startIdx = inputs.indexOf(active);
55395
+ // if (startIdx === -1) {
55396
+ // return;
55397
+ // }
55398
+ // const pastedText = e.clipboardData?.getData("text") ?? "";
55399
+ // if (!pastedText) {
55400
+ // return;
55401
+ // }
55402
+ // // Only intercept when the pasted text contains at least one separator
55403
+ // // from the inputs starting at the focused position.
55404
+ // const remainingInputs = inputs.slice(startIdx);
55405
+ // const hasSeparatorMatch = remainingInputs.some(
55406
+ // (input) =>
55407
+ // input.dataset.separator &&
55408
+ // pastedText.includes(input.dataset.separator),
55409
+ // );
55410
+ // if (!hasSeparatorMatch) {
55411
+ // return;
55412
+ // }
55413
+ // e.preventDefault();
55414
+ // let remaining = pastedText;
55415
+ // let lastFilledIdx = startIdx;
55416
+ // for (let i = 0; i < remainingInputs.length; i++) {
55417
+ // const input = remainingInputs[i];
55418
+ // const separator = input.dataset.separator;
55419
+ // let part;
55420
+ // if (separator && remaining.includes(separator)) {
55421
+ // const sepIdx = remaining.indexOf(separator);
55422
+ // part = remaining.slice(0, sepIdx);
55423
+ // remaining = remaining.slice(sepIdx + separator.length);
55424
+ // } else {
55425
+ // part = remaining;
55426
+ // remaining = "";
55427
+ // }
55428
+ // requestSubPaste(input, part, e);
55429
+ // lastFilledIdx = startIdx + i;
55430
+ // if (remaining === "") {
55431
+ // break;
55432
+ // }
55433
+ // }
55434
+ // focusInput(inputs[lastFilledIdx]);
55435
+ // };
54949
55436
 
54950
- // Created once per popup instance: openHandler is wrapped in a stable callback
54951
- // so the controller identity never changes across renders, even though
54952
- // Dialog/Popover read fresh closures (scrollTrap, etc.) via
54953
- // openController.openEffect on every render.
54954
- const useOpenController = (openHandler) => {
54955
- const debugInteraction = useDebugInteraction();
54956
- const stableOpenHandler = useStableCallback(openHandler);
54957
- const controllerRef = useRef(null);
54958
- if (!controllerRef.current) {
54959
- controllerRef.current = createOpenController(stableOpenHandler, {
54960
- debugInteraction,
54961
- });
54962
- }
54963
- // Unmount safety net: if Dialog/Popover unmounts while still open (parent
54964
- // removes it from the tree without going through requestClose()), there is
54965
- // no choice to leave open — close it for real.
54966
- useLayoutEffect(() => {
55437
+ el.addEventListener("keydown", handleKeyDown, { capture: true });
55438
+ el.addEventListener("navi_input_full", handleNaviInputFull);
55439
+ // el.addEventListener("paste", handlePaste, { capture: true });
54967
55440
  return () => {
54968
- controllerRef.current.close();
55441
+ el.removeEventListener("keydown", handleKeyDown, { capture: true });
55442
+ el.removeEventListener("navi_input_full", handleNaviInputFull);
55443
+ // el.removeEventListener("paste", handlePaste, { capture: true });
54969
55444
  };
54970
- }, []);
54971
- return controllerRef.current;
55445
+ }, [debugFocus]);
54972
55446
  };
54973
55447
 
54974
- // Nested popups that both mount already-open (`open`/`defaultOpen`) would
54975
- // otherwise stack in the wrong order: Preact fires layout effects
54976
- // child-first on mount, so a nested popup's own mount-open would call
54977
- // showPopover() before its ancestor's — and the top layer stacks *later*
54978
- // showPopover() calls above *earlier* ones (see popover.jsx's own openEffect
54979
- // comment) — leaving the ancestor on top instead of the nested popup, the
54980
- // opposite of what opening them one at a time (ancestor first, by real user
54981
- // interaction) would produce. Batching every mount-time silent open queued
54982
- // during the same commit's layout-effect phase into one microtask flush,
54983
- // then simply running them in *reverse* of their registration order fixes
54984
- // this — no need to compare DOM positions: since effects already fire
54985
- // child-first, tree-wide, for *any* ancestor/descendant pair the descendant
54986
- // is always queued before the ancestor, regardless of what else is in the
54987
- // tree, so reversing the whole batch always puts every ancestor before its
54988
- // own descendants. Works for any nesting depth for the same reason. Two
54989
- // unrelated (sibling) popups both mounting open also get reordered
54990
- // relative to each other, but there's no meaningful "correct" order between
54991
- // those anyway.
54992
- let pendingMountOpens = [];
54993
- let mountOpenFlushScheduled = false;
54994
- const scheduleMountOpen = (run) => {
54995
- pendingMountOpens.push(run);
54996
- if (mountOpenFlushScheduled) {
54997
- return;
54998
- }
54999
- mountOpenFlushScheduled = true;
55000
- queueMicrotask(() => {
55001
- const entries = pendingMountOpens;
55002
- pendingMountOpens = [];
55003
- mountOpenFlushScheduled = false;
55004
- for (let i = entries.length - 1; i >= 0; i--) {
55005
- entries[i]();
55006
- }
55007
- });
55008
- };
55448
+ // const requestSubPaste = (input, value, event) => {
55449
+ // dispatchRequestInteraction(input, {
55450
+ // event,
55451
+ // name: "subpaste",
55452
+ // allowed: () => {
55453
+ // dispatchRequestSetUIState(input, value, { event });
55454
+ // },
55455
+ // });
55456
+ // };
55009
55457
 
55010
- // Where the popup's open state is kept, when it is kept anywhere: `navState`
55011
- // resolved to the `{ id, type }` useNavState wants.
55012
- //
55013
- // `true` takes the popup's own id — a popup a `--navi-open` command can name is
55014
- // a popup that already has a stable one, and that id is what identifies its
55015
- // open state too.
55016
- const NO_NAV_STATE = { id: undefined, type: "replace" };
55017
- const resolveNavStateProp = (navState, popupId) => {
55018
- if (!navState) {
55019
- return NO_NAV_STATE;
55458
+ const isTextInputElement = (el) => {
55459
+ if (!el) {
55460
+ return false;
55020
55461
  }
55021
- if (navState === true) {
55022
- return { id: popupId, type: "replace" };
55462
+ if (el.tagName === "TEXTAREA") {
55463
+ return true;
55023
55464
  }
55024
- if (typeof navState === "string") {
55025
- return { id: navState, type: "replace" };
55465
+ if (el.tagName !== "INPUT") {
55466
+ return false;
55026
55467
  }
55027
- return { id: navState.id || popupId, type: navState.type || "replace" };
55468
+ const type = el.type || "text";
55469
+ return (
55470
+ type === "text" ||
55471
+ type === "search" ||
55472
+ type === "url" ||
55473
+ type === "tel" ||
55474
+ type === "email" ||
55475
+ type === "password" ||
55476
+ type === "number"
55477
+ );
55028
55478
  };
55029
55479
 
55030
- /**
55031
- * Keeps an open controller in sync with where the caller says the popup should
55032
- * be: an `open`/`defaultOpen` pair, a `signal`, or a `navState` — the open
55033
- * state written into the history entry, so a screen left and come back to finds
55034
- * its popup as it was.
55035
- *
55036
- * Shared between `useOpenControllerByProps` below (Dialog/Popover driving their
55037
- * own controller) and `picker_custom.jsx` (which owns its controller but wants
55038
- * the same skip-if-already-matching / open-or-requestClose control flow).
55039
- *
55040
- * @param {{ open: (e: Event, detail?: object) => void, requestClose: (e: Event, detail?: object) => void, opened: boolean }} openController
55041
- * @param {{ id?: string, open?: boolean|"interaction", defaultOpen?: boolean|"interaction", signal?: import("@preact/signals").Signal<boolean>, navState?: boolean|string|{id?: string, type?: "push"|"replace"} }} props
55042
- */
55043
- const useOpenPropsEffectOnOpenController = (openController, props) => {
55044
- const { signal, defaultOpen, navState } = props;
55045
- const { id: navStateId, type: navStateType } = resolveNavStateProp(
55046
- navState,
55047
- props.id,
55048
- );
55049
- // Called unconditionally (it answers with no-ops for an absent id), like
55050
- // every other hook here.
55051
- const [navStateValue, enterNavState, leaveNavState] = useNavState(
55052
- navStateId,
55053
- { type: navStateType },
55054
- );
55055
- // What the caller holds, however they hold it: the history entry when there
55056
- // is a `navState`, an `open` they re-render themselves, or a `signal` this
55057
- // hook also writes (see onOpenedChange below). Reading .value during render
55058
- // is what subscribes the popup to a signal; reading the document state is
55059
- // what subscribes it to the history entry, back button included.
55060
- const open = navStateId
55061
- ? Boolean(navStateValue)
55062
- : signal
55063
- ? signal.value
55064
- : props.open;
55065
- // Assigned on every render, like openEffect, so it always closes over the
55066
- // latest prop: a popup that opens or closes on its own (Escape, backdrop, a
55067
- // --navi-close command) writes what happened where the caller keeps it, so
55068
- // whoever holds it always reads where the popup is.
55069
- openController.onOpenedChange =
55070
- navStateId || signal
55071
- ? (opened, event) => {
55072
- if (navStateId) {
55073
- if (opened) {
55074
- enterNavState();
55075
- } else {
55076
- // Under type "push" a cancel goes back rather than rewriting the
55077
- // entry, so everything else written to the url while the popup
55078
- // was open goes back with it (see useNavState's own leave()).
55079
- leaveNavState({ isBack: Boolean(event?.detail?.isCancel) });
55080
- }
55081
- }
55082
- if (signal) {
55083
- signal.value = opened;
55084
- }
55085
- }
55086
- : null;
55087
- // Tracks whether the effect below has ever run before — only the very
55088
- // first run gets the "mount already open" treatment (`open` truthy from
55089
- // the start, or the uncontrolled, mount-only `defaultOpen`); every
55090
- // subsequent `open` change is a real, later toggle and should animate
55091
- // normally like any other interactive open/close.
55092
- const isFirstRunRef = useRef(true);
55480
+ // How many lines the thing around a component gives it, when that thing caps
55481
+ // its own height and cannot cap what it holds.
55482
+ //
55483
+ // A <Picker> is the case it exists for: its value is clamped with maxLines,
55484
+ // which is CSS line-clamp — it counts line boxes of inline text and has no idea
55485
+ // what a wrapped flex row is. So a <BadgeList> rendered as a picker's ui reads
55486
+ // the number from here and caps its own rows to it, and the picker turns its
55487
+ // own clamp off (see .navi_picker_value:has(.navi_badge_list) in picker.jsx).
55488
+ const MaxLinesContext = createContext(undefined);
55093
55489
 
55094
- useLayoutEffect(() => {
55095
- const isFirstRun = isFirstRunRef.current;
55096
- isFirstRunRef.current = false;
55490
+ // When a component render a prop that can be anything (js value of preact element)
55491
+ // make sure it cannot throw during render by converting it to a string if it's not a valid preact element or a primitive value
55492
+ const renderSafe = (value) => {
55493
+ if (
55494
+ value === null ||
55495
+ value === undefined ||
55496
+ typeof value === "string" ||
55497
+ typeof value === "number" ||
55498
+ typeof value === "boolean"
55499
+ ) {
55500
+ return value;
55501
+ }
55097
55502
 
55098
- if (isFirstRun) {
55099
- const mountOpenReason = open || defaultOpen;
55100
- if (mountOpenReason) {
55101
- // Whether this popup being open is something that just happened, or
55102
- // something that was already true when the page appeared. "interaction"
55103
- // says the mount IS the opening — the popup exists because the user
55104
- // just asked for it — so the entrance plays like any other open. Any
55105
- // other truthy value means it was simply already open: nothing was ever
55106
- // shown as "closed" for the user to see it transition away from, so the
55107
- // entrance is skipped (`silent`, see popover.jsx's own openEffect).
55108
- //
55109
- // Deferred + batched (see scheduleMountOpen above) rather than called
55110
- // directly, so nested popups that both mount already-open end up
55111
- // stacked ancestor-first instead of Preact's own child-first effect
55112
- // order.
55113
- scheduleMountOpen(() =>
55114
- openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
55115
- silent: mountOpenReason !== "interaction",
55116
- }),
55117
- );
55118
- }
55119
- return;
55120
- }
55503
+ if (isValidElement(value)) {
55504
+ return value;
55505
+ }
55121
55506
 
55122
- if (open === undefined) {
55123
- return;
55124
- }
55125
- // Skip when the controller is already in the desired state.
55126
- // openController.opened tracks actual open/close (updated by onopen/onclose,
55127
- // not by renders) so it is the authoritative check against feedback loops.
55128
- if (open === openController.opened) {
55129
- return;
55130
- }
55131
- if (open) {
55132
- openController.open(new CustomEvent("open_by_prop", { detail: {} }));
55133
- } else {
55134
- openController.requestClose(
55135
- new CustomEvent("close_by_prop", { detail: {} }),
55136
- { isCancel: true },
55137
- );
55138
- }
55139
- // The request can be refused (a busy form denying the close): the popup
55140
- // then stays where it was, and whoever holds the open state is told so —
55141
- // otherwise it would keep saying "closed" about a popup still open.
55142
- if (signal) {
55143
- signal.value = openController.opened;
55144
- }
55145
- if (navStateId && openController.opened) {
55146
- enterNavState();
55147
- }
55148
- }, [open]);
55507
+ return String(value);
55149
55508
  };
55150
55509
 
55151
55510
  /**
@@ -56691,10 +57050,10 @@ const useDialogProps = props => {
56691
57050
  // the latest prop. Called by openController.open() before the content is
56692
57051
  // built: what this popup opens ON is known before anything reads it.
56693
57052
  openController.onOpen = onOpen || null;
56694
- const children = usePopupContentMount(openController, props.ref, {
56695
- children: childrenProp,
57053
+ const contentMounted = usePopupContentMount(openController, props.ref, {
56696
57054
  mount
56697
57055
  });
57056
+ const children = contentMounted ? childrenProp : null;
56698
57057
  const isModal = layer === "top";
56699
57058
  const ref = props.ref;
56700
57059
  const expandY = Boolean(expand) || Boolean(expandYProp);
@@ -58136,10 +58495,10 @@ const usePopoverProps = props => {
58136
58495
  // the latest prop. Called by openController.open() before the content is
58137
58496
  // built: what this popup opens ON is known before anything reads it.
58138
58497
  openController.onOpen = onOpen || null;
58139
- const children = usePopupContentMount(openController, props.ref, {
58140
- children: childrenProp,
58498
+ const contentMounted = usePopupContentMount(openController, props.ref, {
58141
58499
  mount
58142
58500
  });
58501
+ const children = contentMounted ? childrenProp : null;
58143
58502
  const isTopLayer = layer === "top";
58144
58503
  const ref = props.ref;
58145
58504
  const backdropRef = useRef();
@@ -80746,5 +81105,5 @@ const UserSvg = () => jsx("svg", {
80746
81105
  })
80747
81106
  });
80748
81107
 
80749
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, OfflineError, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isOfflineError, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
81108
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, OfflineError, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isOfflineError, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
80750
81109
  //# sourceMappingURL=jsenv_navi.js.map