@jsenv/navi 0.29.14 → 0.29.16

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.
@@ -6490,6 +6490,10 @@ const POSITION_PROPS = {
6490
6490
  fixed: applyToCssPropWhenTruthy("position", "fixed", "static"),
6491
6491
  sticky: applyToCssPropWhenTruthy("position", "sticky", "static"),
6492
6492
  zIndex: PASS_THROUGH,
6493
+ // Keeps the zIndex values used inside this box local to it — see
6494
+ // docs/z_index.md: a z-index that opens no stacking context competes with
6495
+ // the whole page, fixed bars included.
6496
+ isolation: PASS_THROUGH,
6493
6497
  order: PASS_THROUGH,
6494
6498
  left: (value) => {
6495
6499
  return { left: value === true ? 0 : value };
@@ -8905,6 +8909,20 @@ import.meta.css = [/* css */`
8905
8909
  between them — and a control flush against the edge of a scrolling area
8906
8910
  overflows it (a focus outline is drawn outside the control it belongs to)
8907
8911
  and raises a scrollbar of its own. */
8912
+ /* A control sitting right against the edge of what scrolls must keep its
8913
+ loading outline within its own box: the outline is drawn a couple pixels
8914
+ outside the control (see loading_outline.jsx), and that bleed alone is
8915
+ enough to make the area scrollable — a scrollbar appearing and disappearing
8916
+ as things load. Only what the scroller directly contains is against that
8917
+ edge; anything nested deeper has room around it and keeps the outline it
8918
+ asked for, hence the child combinators. Written on the outline itself
8919
+ rather than on the control, because the var inherits: setting it on a
8920
+ container would reach every control below it, edge or not. */
8921
+ [data-scrollable] > .navi_loading_outline_wrapper,
8922
+ [data-scrollable] > * > .navi_loading_outline_wrapper {
8923
+ --loading-outline-min-inset: 0px;
8924
+ }
8925
+
8908
8926
  [data-scrollable] {
8909
8927
  overflow: var(--x-scrollable-overflow, auto);
8910
8928
 
@@ -10057,6 +10075,15 @@ const LoadingOutlineUI = props => {
10057
10075
  } = props;
10058
10076
  const shouldShowSpinner = useDebounceTrue(loading, debounce);
10059
10077
  const rectangleRef = useRef(null);
10078
+
10079
+ // Nothing in the DOM until something actually loads: the box below is
10080
+ // absolutely positioned slightly outside the control, which is enough to
10081
+ // make an ancestor scrollable (a 1px scrollbar on a control sitting against
10082
+ // the edge of a scrolling area). A control that never loads must not pay for
10083
+ // a decoration it will never draw.
10084
+ if (!loading) {
10085
+ return children;
10086
+ }
10060
10087
  let insetTop = inset + spacingTop + marginTop;
10061
10088
  let insetRight = inset + spacingRight + marginRight;
10062
10089
  let insetBottom = inset + spacingBottom + marginBottom;
@@ -10087,7 +10114,7 @@ const LoadingOutlineUI = props => {
10087
10114
  "--loading-rectangle-bottom": `${insetBottom}px`,
10088
10115
  "--loading-rectangle-left": `${insetLeft}px`
10089
10116
  },
10090
- children: loading && jsx(LoadingIndicatorFluid, {
10117
+ children: jsx(LoadingIndicatorFluid, {
10091
10118
  visuallyHidden: !shouldShowSpinner,
10092
10119
  radius: radius,
10093
10120
  color: color,
@@ -13764,6 +13791,25 @@ const css$V = /* css */`
13764
13791
  * @param {Function} [options.onClose] - Callback when callout is closed
13765
13792
  * @param {boolean} [options.closeOnClickOutside] - Whether to close on outside clicks (defaults to true for "info" status)
13766
13793
  * @param {boolean} [options.debug=false] - Enable debug logging
13794
+ *
13795
+ * Positioning is also driven by attributes read on the anchor element itself
13796
+ * (so markup can tune a callout without going through this function):
13797
+ * - `data-callout-arrow-x="start" | "center" | "end"`: where the arrow points
13798
+ * horizontally along the anchor — `start`/`end` target the left/right edge of
13799
+ * the anchor's text content (inside its borders and padding), `center` targets
13800
+ * the middle of the anchor. Without it the arrow follows the anchor's computed
13801
+ * `text-align` (`center` → center, `right`/`end` → end, anything else → start),
13802
+ * so it lands where the text visually begins. The arrow is always clamped to
13803
+ * stay within the callout's own rounded corners, so a value pointing outside
13804
+ * the callout's width sticks to the nearest side.
13805
+ * - `data-callout-position="top" | "bottom" | …`: preferred side of the anchor
13806
+ * (defaults to `"bottom"`, flipped when there isn't enough space).
13807
+ * - `data-callout-position-fixed`: opt out of that flipping.
13808
+ * - `data-callout-anchor="<selector>"`: point at an inner element instead of the
13809
+ * anchor itself.
13810
+ * - `data-callout-point-to-border-box` / `data-callout-point-to-content-box`:
13811
+ * which box the callout aligns to.
13812
+ * - `data-callout-viewport-spacing="<number>"`: minimum gap with the viewport edges.
13767
13813
  * @returns {Object} - Callout object with properties:
13768
13814
  * - {Function} close - Function to close the callout
13769
13815
  * - {Function} update - Function to update message and options
@@ -17775,6 +17821,104 @@ const useExecuteAction = (
17775
17821
  return executeAction;
17776
17822
  };
17777
17823
 
17824
+ /**
17825
+ * A control placed inside a region that expands on click — a `<summary>`, an
17826
+ * accordion header carrying `aria-expanded` — has its click read twice: once by
17827
+ * the control it was aimed at, once by the region around it. The second reading
17828
+ * is never wanted; a menu opened from a collapsed row should not also unfold the
17829
+ * row.
17830
+ *
17831
+ * Cancelling the click is the only way to stop the region: a `<summary>` runs
17832
+ * its default action after the propagation, so `stopPropagation` does not reach
17833
+ * it. And it can only be done once the control has taken the click for itself —
17834
+ * navi refuses an interaction on an already-cancelled event (see
17835
+ * `onRequestInteraction`), so cancelling any earlier silences the control
17836
+ * instead of the region.
17837
+ *
17838
+ * That moment — right after an interaction was allowed — only exists inside
17839
+ * navi, which is why the cancellation lives here rather than in application code.
17840
+ */
17841
+
17842
+ const CLICK_TO_EXPAND_SELECTOR = "summary, [aria-expanded]";
17843
+
17844
+ /**
17845
+ * Cancels `event` when the control consumed a click that a surrounding
17846
+ * click-to-expand region would otherwise read as "unfold me".
17847
+ *
17848
+ * Does nothing when cancelling the click would also cancel what the control
17849
+ * itself does with it (a link navigating, a checkbox toggling): there, the two
17850
+ * behaviours cannot be separated and the control's own comes first.
17851
+ */
17852
+ const preventClickToExpand = (element, event) => {
17853
+ if (!event || event.type !== "click") {
17854
+ return;
17855
+ }
17856
+ if (event.defaultPrevented) {
17857
+ return;
17858
+ }
17859
+ if (!clickDefaultActionIsInert(element, event)) {
17860
+ return;
17861
+ }
17862
+ const parentElement = element.parentElement;
17863
+ if (!parentElement) {
17864
+ return;
17865
+ }
17866
+ // From the parent: a control that opens something carries its own
17867
+ // `aria-expanded` and would find itself.
17868
+ const clickToExpandRegion = parentElement.closest(CLICK_TO_EXPAND_SELECTOR);
17869
+ if (!clickToExpandRegion) {
17870
+ return;
17871
+ }
17872
+ event.preventDefault();
17873
+ };
17874
+
17875
+ const clickDefaultActionIsInert = (element, event) => {
17876
+ if (!isInertOnClick(element)) {
17877
+ return false;
17878
+ }
17879
+ // The activation belongs to what was clicked, which can be deeper than the
17880
+ // control host (a button inside it) or above it (a label wrapping it).
17881
+ const { target } = event;
17882
+ if (target && target !== element && target.nodeType === 1) {
17883
+ let ancestor = target;
17884
+ while (ancestor) {
17885
+ if (!isInertOnClick(ancestor)) {
17886
+ return false;
17887
+ }
17888
+ ancestor = ancestor.parentElement;
17889
+ }
17890
+ }
17891
+ return true;
17892
+ };
17893
+
17894
+ const NON_INERT_INPUT_TYPE_SET = new Set([
17895
+ "checkbox",
17896
+ "radio",
17897
+ "submit",
17898
+ "reset",
17899
+ "image",
17900
+ "file",
17901
+ ]);
17902
+
17903
+ const isInertOnClick = (element) => {
17904
+ const { tagName } = element;
17905
+ if (tagName === "A" || tagName === "AREA") {
17906
+ return !element.hasAttribute("href");
17907
+ }
17908
+ if (tagName === "LABEL") {
17909
+ // A label forwards the click to its control, whose activation would be
17910
+ // cancelled along with the click.
17911
+ return false;
17912
+ }
17913
+ if (tagName === "INPUT") {
17914
+ return !NON_INERT_INPUT_TYPE_SET.has(element.type);
17915
+ }
17916
+ if (tagName === "BUTTON") {
17917
+ return element.type === "button";
17918
+ }
17919
+ return true;
17920
+ };
17921
+
17778
17922
  const BUSY_CONSTRAINT = {
17779
17923
  name: "busy",
17780
17924
  messageAttribute: "data-busy-message",
@@ -18143,6 +18287,9 @@ const onRequestInteraction = (
18143
18287
  debugInteraction(event, `"${name}" allowed`);
18144
18288
  allowed?.();
18145
18289
  always?.();
18290
+ // The click served this control; it must not serve a second time whatever
18291
+ // unfolds around it (see click_to_expand.js).
18292
+ preventClickToExpand(controlHost, event);
18146
18293
  return true;
18147
18294
  };
18148
18295
 
@@ -25713,8 +25860,8 @@ const css$T = /* css */`
25713
25860
  * the screen and a centered box ends up both cramped and out of thumb
25714
25861
  * reach, while under a mouse the centered box is already the right shape —
25715
25862
  * hence a prop that only ever does something on touch. It supplies defaults
25716
- * for `positionArea`, `marginWithContainer` and `expandX`, so any of the
25717
- * three can still be pinned explicitly. Keyed off `(pointer: coarse)` (the
25863
+ * for `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`,
25864
+ * so any of them can still be pinned explicitly. Keyed off `(pointer: coarse)` (the
25718
25865
  * input device, not a width breakpoint — a narrow desktop window is still a
25719
25866
  * mouse) via `coarsePointerSignal`, so it re-resolves live.
25720
25867
  * @param {string} [props.positionArea="center"] - Where to dock the dialog
@@ -25751,7 +25898,7 @@ const css$T = /* css */`
25751
25898
  * A `layer="local"` dialog always locks its own positioned ancestor's
25752
25899
  * scroll while open (its backdrop only covers the scrollport, so scrolling
25753
25900
  * there would reveal uncovered content); this prop extends the lock to the
25754
- * whole page.
25901
+ * whole page. Defaults to `true` for a dialog docked by `dockedOnTouch`.
25755
25902
  * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`} [props.animation]
25756
25903
  * - `true`/`"auto"` resolves to `"scaling"` for a centered `positionArea`,
25757
25904
  * or a concrete `"slide-from-*"` direction otherwise. Any other explicit
@@ -25920,7 +26067,11 @@ const DialogLocal = props => {
25920
26067
  const DOCKED = {
25921
26068
  positionArea: "bottom",
25922
26069
  marginWithContainer: 0,
25923
- expandX: true
26070
+ expandX: true,
26071
+ // A sheet resting on the bottom edge is dragged with a thumb, and a drag that
26072
+ // runs past its own edge must not land on the page behind it: the same
26073
+ // reasoning as "bottom" above, applied to the gesture instead of the shape.
26074
+ scrollCapture: true
25924
26075
  };
25925
26076
 
25926
26077
  // The first control inside `dialogEl` that is mid-action, if any. Walks the
@@ -25966,7 +26117,7 @@ const useDialogProps = props => {
25966
26117
  // there's no native inert-ing, so the real backdrop below is what
25967
26118
  // actually makes "capture"/"none" behave the same way here too.
25968
26119
  pointerInteractionOutsideEffect = "close",
25969
- scrollCapture,
26120
+ scrollCapture: scrollCaptureProp,
25970
26121
  animation,
25971
26122
  // Only ever affects --anchor-width/--anchor-height (see this file's top
25972
26123
  // comment) — Dialog's own positioning is never relative to it.
@@ -26001,6 +26152,7 @@ const useDialogProps = props => {
26001
26152
  const expandXUnset = expand === undefined && expandXProp === undefined;
26002
26153
  const expandX = expandXUnset ? isDocked && DOCKED.expandX : Boolean(expand) || Boolean(expandXProp);
26003
26154
  const expandY = Boolean(expand) || Boolean(expandYProp);
26155
+ const scrollCapture = scrollCaptureProp ?? (isDocked ? DOCKED.scrollCapture : false);
26004
26156
  const backdropRef = useRef();
26005
26157
  // Disarms a still-pending backdrop hide from a previous close (see
26006
26158
  // armPointerDownOutsideClose below) — same pattern as popover.jsx's own.
@@ -26560,8 +26712,13 @@ const DIALOG_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible",
26560
26712
 
26561
26713
  // Lets consumers pass animationDuration="0.5s" as a regular prop; Box maps
26562
26714
  // it to the CSS var for us (see box.jsx's styleCSSVars handling).
26715
+ // borderRadius goes through --dialog-border-radius rather than the
26716
+ // border-radius property itself so the flush-corner rules above (a plain
26717
+ // stylesheet) can still square the corners that land on the container's own —
26718
+ // an inline border-radius would outrank them.
26563
26719
  const DIALOG_STYLE_CSS_VARS = {
26564
26720
  animationDuration: "--popup-animation-duration",
26721
+ borderRadius: "--dialog-border-radius",
26565
26722
  minWidth: "--dialog-min-width",
26566
26723
  maxWidth: "--dialog-max-width",
26567
26724
  minHeight: "--dialog-min-height",
@@ -31521,12 +31678,30 @@ const useActionAsyncData = (action, {
31521
31678
  const runningState = action.runningStateSignal.peek();
31522
31679
  const [, setTick] = useState(0);
31523
31680
  useEffect(() => {
31524
- return action.runningStateSignal.subscribe(state => {
31681
+ const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
31525
31682
  if (state === RUNNING) {
31526
31683
  dismissedActionWeakSet.delete(action);
31527
31684
  }
31528
31685
  setTick(n => n + 1);
31529
31686
  });
31687
+ // The data does not come from this action's runs alone: dataSignal is a
31688
+ // computed over the resource store, so an other action writing that store
31689
+ // (a PUT upserting an item that a GET_MANY list already holds) changes the
31690
+ // data while this action stays COMPLETED. Subscribing here re-renders
31691
+ // through the same controlled path as the run state, instead of `.value`.
31692
+ let dataNotificationIsInitial = true;
31693
+ const unsubscribeFromData = action.dataSignal.subscribe(() => {
31694
+ if (dataNotificationIsInitial) {
31695
+ // subscribe() calls back synchronously with the current value
31696
+ dataNotificationIsInitial = false;
31697
+ return;
31698
+ }
31699
+ setTick(n => n + 1);
31700
+ });
31701
+ return () => {
31702
+ unsubscribeFromRunningState();
31703
+ unsubscribeFromData();
31704
+ };
31530
31705
  }, []);
31531
31706
  if (runningState === COMPLETED) {
31532
31707
  return [action.dataSignal.peek(), false, undefined];
@@ -35842,10 +36017,13 @@ installImportMetaCssBuild(import.meta);const css$N = /* css */`
35842
36017
  --link-background: unset;
35843
36018
  --link-background-current: unset;
35844
36019
  --link-background-selected: light-dark(#bbdefb, #2563eb);
35845
- --link-color: rgb(0, 0, 238);
35846
- --link-color-visited: color-mix(in srgb, var(--link-color), black 40%);
36020
+ --link-color: var(--navi-link-color);
36021
+ --link-color-visited: var(
36022
+ --navi-link-color-visited,
36023
+ color-mix(in srgb, var(--link-color), black 40%)
36024
+ );
35847
36025
 
35848
- --link-color-pressed: red;
36026
+ --link-color-pressed: var(--navi-link-color-pressed);
35849
36027
  --link-text-decoration: underline;
35850
36028
  --link-text-decoration-hover: var(--link-text-decoration);
35851
36029
  --link-cursor: pointer;
@@ -35854,7 +36032,7 @@ installImportMetaCssBuild(import.meta);const css$N = /* css */`
35854
36032
 
35855
36033
  --link-current-indicator-size: 2px;
35856
36034
  --link-current-indicator-spacing: 0;
35857
- --link-current-indicator-color: rgb(205, 52, 37);
36035
+ --link-current-indicator-color: var(--navi-link-current-indicator-color);
35858
36036
  }
35859
36037
  }
35860
36038
 
@@ -38898,7 +39076,7 @@ installImportMetaCssBuild(import.meta);const css$F = /* css */`
38898
39076
  --loader-color: var(--navi-loader-color);
38899
39077
  --border-color: var(--navi-control-border-color);
38900
39078
  --background-color: white;
38901
- --accent-color: light-dark(#4476ff, #3b82f6);
39079
+ --accent-color: var(--navi-control-accent-color);
38902
39080
  --background-color-checked: var(--accent-color);
38903
39081
  --border-color-checked: var(--accent-color);
38904
39082
  --checkmark-color: white;
@@ -39638,7 +39816,7 @@ installImportMetaCssBuild(import.meta);const css$D = /* css */`
39638
39816
  --border-color: var(--navi-control-border-color);
39639
39817
  --background-color: white;
39640
39818
  --background-color-checked: var(--background-color);
39641
- --accent-color: light-dark(#4476ff, #3b82f6);
39819
+ --accent-color: var(--navi-control-accent-color);
39642
39820
  --radiomark-color: var(--accent-color);
39643
39821
  --border-color-checked: var(--accent-color);
39644
39822
  --cursor: pointer;
@@ -40150,7 +40328,7 @@ installImportMetaCssBuild(import.meta);const css$C = /* css */`
40150
40328
  --font-family: var(--navi-control-font-family);
40151
40329
 
40152
40330
  --loader-color: var(--navi-loader-color);
40153
- --accent-color: rgb(24, 117, 255);
40331
+ --accent-color: var(--navi-control-accent-color);
40154
40332
  --color-mix-light: black;
40155
40333
  --color-mix-dark: white;
40156
40334
  --color-mix: var(--color-mix-dark);
@@ -42983,6 +43161,13 @@ installImportMetaCssBuild(import.meta);/**
42983
43161
  * the map one would have drawn for a line, so it is drawn here and everything
42984
43162
  * below only ever knows about maps.
42985
43163
  *
43164
+ * A travel is ONE BOX long, whatever the distance between the two slides on the
43165
+ * map: the slide arriving is placed next to the one being left for the duration
43166
+ * and put back where the map says afterwards, so a tab bar jumping from the
43167
+ * first tab to the last shows those two and nothing else. Nobody wants to watch
43168
+ * the slides in between fly past — least of all in a tab bar, where they are
43169
+ * not a road one travels but places one goes straight to.
43170
+ *
42986
43171
  * The slides live INSIDE the box, which is what makes this work for a popup: a
42987
43172
  * dialog and a popover are both promoted to the browser's top layer, so no
42988
43173
  * container of ours could ever hold two of them side by side and translate the
@@ -43086,6 +43271,18 @@ const css$z = /* css */`
43086
43271
  slides. */
43087
43272
  translate: var(--slide-offset, 0);
43088
43273
  }
43274
+ /* Off stage: everything but the slide one is looking at and, while the
43275
+ track moves, the slide one is leaving. A travel is one box long
43276
+ whatever the distance on the map (the two are placed a box apart for
43277
+ the occasion, see the layout effect), so the slides in between are
43278
+ never crossed — but on a map wider than one box they would still sit
43279
+ in the frame, and a tab bar jumping from the first tab to the last must
43280
+ show those two and nothing else.
43281
+ visibility, not display: this box is measured on its LARGEST slide, and
43282
+ a slide taken out of the layout would take its size out with it. */
43283
+ > [data-slide][data-slide-offstage] {
43284
+ visibility: hidden;
43285
+ }
43089
43286
  /* Nothing here for a slide not on screen: [inert] (set from JS) already
43090
43287
  takes it out of reach of the pointer, of Tab and of a screen reader —
43091
43288
  one attribute instead of pointer-events plus aria-hidden, and the only
@@ -43317,6 +43514,25 @@ const SlideContainer = ({
43317
43514
  // mid-travel would read a moving value.
43318
43515
  const offsetRef = useRef();
43319
43516
  const trackAnimationRef = useRef(null);
43517
+ // Where the slides are while the track moves, which is not where the map says
43518
+ // they are: the one being left stays put and the one arriving is placed ONE
43519
+ // BOX away from it, whichever way the travel goes and however far apart the
43520
+ // two are on the map. So a jump from the first tab to the last is one box of
43521
+ // travel and the tabs in between are never seen flying past — and everything
43522
+ // not in this map is off stage for the duration. Null at rest, when the map
43523
+ // is the whole truth again (see settleTravel).
43524
+ const stageRef = useRef(null);
43525
+ // Which slide the last drawing put on screen: the current one at rest, the
43526
+ // one being travelled TO while the track moves. What the next travel departs
43527
+ // from, because it is what one is looking at.
43528
+ const drawnAreaRef = useRef(undefined);
43529
+ // Which way the travel about to be drawn goes, when whatever asked for it
43530
+ // knows: a window stepping off its last slide comes back on its first, and
43531
+ // only the press says that is a step forward — the map, read between those
43532
+ // two places, says the opposite. Undefined for a travel asked for by name
43533
+ // (--navi-go-to-slide, --navi-back), where the map is the only thing that
43534
+ // knows and is right.
43535
+ const travelStepRef = useRef(null);
43320
43536
  // What to do once the travel now starting is over, handed to the animation as
43321
43537
  // soon as there is one.
43322
43538
  const rollBackRef = useRef(null);
@@ -43418,6 +43634,52 @@ const SlideContainer = ({
43418
43634
  }
43419
43635
  };
43420
43636
 
43637
+ // The travel is over: the stage is struck and every slide goes back where the
43638
+ // map says it is. Nothing is seen moving for it — the slide on screen sits at
43639
+ // the same place whatever the arrangement (its own offset and the track's are
43640
+ // opposites and cancel out), and the others are off stage — as long as the
43641
+ // two are written in one go, which is why this is done here rather than left
43642
+ // to a render.
43643
+ const settleTravel = () => {
43644
+ const track = trackRef.current;
43645
+ if (!track) {
43646
+ return;
43647
+ }
43648
+ const {
43649
+ slideElements,
43650
+ placeOf
43651
+ } = readMap();
43652
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current"));
43653
+ if (!currentElement) {
43654
+ return;
43655
+ }
43656
+ stageRef.current = null;
43657
+ trackAnimationRef.current = null;
43658
+ for (const slideElement of slideElements) {
43659
+ const {
43660
+ x,
43661
+ y
43662
+ } = placeOf.get(readArea(slideElement)) || {
43663
+ x: 0,
43664
+ y: 0
43665
+ };
43666
+ slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
43667
+ slideElement.toggleAttribute("data-slide-offstage", slideElement !== currentElement);
43668
+ }
43669
+ const currentArea = readArea(currentElement);
43670
+ drawnAreaRef.current = currentArea;
43671
+ const {
43672
+ x,
43673
+ y
43674
+ } = placeOf.get(currentArea) || {
43675
+ x: 0,
43676
+ y: 0
43677
+ };
43678
+ const offset = `${-x * 100}% ${-y * 100}%`;
43679
+ offsetRef.current = offset;
43680
+ track.style.setProperty("--slide-container-offset", offset);
43681
+ };
43682
+
43421
43683
  // Everything positional is decided here, from the DOM, once per render: where
43422
43684
  // each slide stands on the map, which one is current, and how far the track
43423
43685
  // must be for that one to be the one on screen. Reading the DOM is what makes
@@ -43435,10 +43697,59 @@ const SlideContainer = ({
43435
43697
  // Nothing named, or a name nothing answers to: the first slide is the one
43436
43698
  // shown, the way a stack of pages opens on its first page.
43437
43699
  slideElements[0];
43438
- const currentPlace = placeOf.get(readArea(currentElement)) || {
43700
+ const currentArea = readArea(currentElement);
43701
+ const realPlaceOf = area => placeOf.get(area) || {
43439
43702
  x: 0,
43440
43703
  y: 0
43441
43704
  };
43705
+ const durationMs = durationToMs(duration);
43706
+ // Nothing is travelling, so nothing is staged: the picture to paint is the
43707
+ // map itself, and a stage left over from a travel that has just been given
43708
+ // up on would be painted instead of it.
43709
+ if (noTravel) {
43710
+ stageRef.current = null;
43711
+ }
43712
+ let stage = stageRef.current;
43713
+ const drawnArea = stage ? stage.area : drawnAreaRef.current;
43714
+ const travelStarts = !noTravel && durationMs > 0 && drawnArea !== undefined && drawnArea !== currentArea && slideElements.some(slideElement => readArea(slideElement) === drawnArea);
43715
+ if (travelStarts) {
43716
+ // Where the slide being left IS — the place the stage gave it if a travel
43717
+ // was already playing (a press landing mid-travel departs from where the
43718
+ // eye is, not from the map), its own place otherwise.
43719
+ const departurePlace = stage ? stage.placeByArea.get(drawnArea) ?? realPlaceOf(drawnArea) : realPlaceOf(drawnArea);
43720
+ const step = travelStepRef.current || {
43721
+ x: Math.sign(realPlaceOf(currentArea).x - realPlaceOf(drawnArea).x),
43722
+ y: Math.sign(realPlaceOf(currentArea).y - realPlaceOf(drawnArea).y)
43723
+ };
43724
+ // Kept, not replaced: the slides a chain of quick presses has already
43725
+ // left behind are still trailing off screen, and taking them off stage
43726
+ // now would blink them out mid-travel.
43727
+ const placeByArea = new Map(stage?.placeByArea);
43728
+ placeByArea.set(drawnArea, departurePlace);
43729
+ const arrivalPlace = {
43730
+ x: departurePlace.x + step.x,
43731
+ y: departurePlace.y + step.y
43732
+ };
43733
+ // The cell the arriving slide takes, taken back from whoever was left
43734
+ // standing on it: a travel turned around mid-flight comes back over
43735
+ // ground it has just covered, and the slide it left there would be
43736
+ // underneath the one arriving — two pictures in one box.
43737
+ for (const [stagedArea, stagedPlace] of placeByArea) {
43738
+ if (stagedArea !== drawnArea && stagedArea !== currentArea && stagedPlace.x === arrivalPlace.x && stagedPlace.y === arrivalPlace.y) {
43739
+ placeByArea.delete(stagedArea);
43740
+ }
43741
+ }
43742
+ placeByArea.set(currentArea, arrivalPlace);
43743
+ stage = stageRef.current = {
43744
+ placeByArea,
43745
+ area: currentArea
43746
+ };
43747
+ }
43748
+ // Said about the travel now being drawn and about no other: a re-render in
43749
+ // the middle of one reads the stage back, which already knows.
43750
+ travelStepRef.current = null;
43751
+ const placeOfArea = area => stage && stage.placeByArea.get(area) || realPlaceOf(area);
43752
+ const currentPlace = placeOfArea(currentArea);
43442
43753
  // A transfer waiting for a travel that never happened — a controlled
43443
43754
  // `current` the caller chose not to move: dropped, the focus has no
43444
43755
  // business going anywhere. The one for a travel that DID happen stays,
@@ -43454,17 +43765,18 @@ const SlideContainer = ({
43454
43765
  // and that one knows what was pressed.
43455
43766
  const focusIsLeaving = !focusHandOverRef.current && slideElements.some(slideElement => slideElement !== currentElement && slideElement.contains(document.activeElement));
43456
43767
  for (const slideElement of slideElements) {
43768
+ const area = readArea(slideElement);
43457
43769
  const {
43458
43770
  x,
43459
43771
  y
43460
- } = placeOf.get(readArea(slideElement)) || {
43461
- x: 0,
43462
- y: 0
43463
- };
43772
+ } = placeOfArea(area);
43464
43773
  slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
43465
43774
  const isCurrent = slideElement === currentElement;
43466
43775
  slideElement.toggleAttribute("data-current", isCurrent);
43467
43776
  slideElement.toggleAttribute("data-slide-displaced", !isCurrent);
43777
+ // On stage: the two ends of the travel while there is one, and the slide
43778
+ // being shown when there is not.
43779
+ slideElement.toggleAttribute("data-slide-offstage", stage ? !stage.placeByArea.has(area) : !isCurrent);
43468
43780
  if (isCurrent) {
43469
43781
  // Reachable again first, so the focus below has somewhere to land: an
43470
43782
  // inert element cannot take it.
@@ -43491,7 +43803,6 @@ const SlideContainer = ({
43491
43803
  // Where the track ends up, always — the animation below only covers the way
43492
43804
  // there, and when it is over this is what holds.
43493
43805
  track.style.setProperty("--slide-container-offset", offset);
43494
- const durationMs = durationToMs(duration);
43495
43806
  const travels = !noTravel && durationMs > 0 && offsetBefore !== undefined && offsetBefore !== offset;
43496
43807
  if (travels) {
43497
43808
  // The time it takes is the distance it has left to cover: a travel picked
@@ -43522,6 +43833,18 @@ const SlideContainer = ({
43522
43833
  if (pendingRollsRef.current.length) {
43523
43834
  hurryTravel(trackAnimationRef.current);
43524
43835
  }
43836
+ // Arrived: the map is the truth again (see settleTravel). Attached before
43837
+ // the window's own roll back just below, so the stage is struck first and
43838
+ // whatever that one renders is drawn from the map.
43839
+ trackAnimationRef.current.finished.then(settleTravel, () => {
43840
+ // cancelled by the next travel — that one carries the stage on
43841
+ });
43842
+ } else if (stage && trackAnimationRef.current?.playState !== "running") {
43843
+ // Staged with nothing left to play: a travel that was drawn and then had
43844
+ // its animation taken away (a duration set to 0, a re-render landing
43845
+ // between the two). Struck at once rather than left standing, since the
43846
+ // thing it was standing for is over.
43847
+ settleTravel();
43525
43848
  }
43526
43849
  // A window waiting for its travel to be over (see goToArea's own loop
43527
43850
  // branch): the animation says when, and says it about the move that just
@@ -43558,6 +43881,13 @@ const SlideContainer = ({
43558
43881
  for (const slideElement of slideElements) {
43559
43882
  slideElement.toggleAttribute("inert", slideElement !== currentElement);
43560
43883
  }
43884
+ // What is on screen now, for the travel after this one to depart from.
43885
+ // While a travel is playing the stage is the one that knows (it holds the
43886
+ // slide being travelled TO, which is what one is looking at), so this is
43887
+ // only ever written at rest.
43888
+ if (!stageRef.current) {
43889
+ drawnAreaRef.current = currentArea;
43890
+ }
43561
43891
  });
43562
43892
 
43563
43893
  /**
@@ -43652,6 +43982,15 @@ const SlideContainer = ({
43652
43982
  ...cameFromRef.current,
43653
43983
  [area]: readArea(currentElement)
43654
43984
  };
43985
+ // Which way this travel is drawn, said by what asked for it rather than
43986
+ // read off the map: a window stepping off its last slide comes back on its
43987
+ // first, and between those two places the map says "all the way back" when
43988
+ // the press said "one forward". Nothing to say when the travel was asked
43989
+ // for by name — there the map is the only one who knows.
43990
+ travelStepRef.current = dx || dy ? {
43991
+ x: Math.sign(dx),
43992
+ y: Math.sign(dy)
43993
+ } : null;
43655
43994
  if (loop) {
43656
43995
  // A window does not change slide, it rolls: the travel plays, and once it
43657
43996
  // is over the window is put back where it rests while whoever owns the
@@ -47826,13 +48165,10 @@ const css$u = /* css */`
47826
48165
  font-size: 1em;
47827
48166
  line-height: 1.4;
47828
48167
  }
47829
- /* A control that IS the row — a direct child of the item, so it spans it
47830
- must keep its loading outline within its own box: the scroll container is
47831
- overflow:auto, and the couple pixels the outline normally draws outside
47832
- the control are enough to make it scrollable, so a scrollbar would appear
47833
- and disappear as things load. Targeted on the outline itself rather than
47834
- inherited from the item, so a control nested deeper (which has room around
47835
- it, and does not reach the edges) keeps the outline it asked for. */
48168
+ /* Same rule as [data-scrollable] in box.jsx, said again for this scroller:
48169
+ what an item holds IS against the edge of the scroll container — the list
48170
+ element between the two is markup, not spacing so its loading outline
48171
+ stays inside its own box rather than raising a scrollbar. */
47836
48172
  .navi_list_item > .navi_loading_outline_wrapper,
47837
48173
  .navi_list_item > * > .navi_loading_outline_wrapper,
47838
48174
  .navi_list_item_header > * > .navi_loading_outline_wrapper,
@@ -53586,6 +53922,18 @@ const css$n = /* css */`
53586
53922
  /* The control grows itself; resizable below hands the handle back. */
53587
53923
  resize: none;
53588
53924
  overflow: auto;
53925
+ /* A placeholder must be readable in full before anything is typed: a
53926
+ field that opens already scrolled reads as a field that already has
53927
+ text in it. Its wrapped height is measured (see usePlaceholderHeight)
53928
+ because it only exists once laid out, and it only raises the floor
53929
+ while the placeholder is what is being shown — what is typed sizes the
53930
+ box on its own. */
53931
+ &:placeholder-shown {
53932
+ min-height: max(
53933
+ calc(var(--textarea-min-rows, 1.5) * 1lh),
53934
+ var(--x-textarea-placeholder-height, 0px)
53935
+ );
53936
+ }
53589
53937
  }
53590
53938
  &[data-resizable] .navi_control_input {
53591
53939
  height: calc(var(--textarea-min-rows, 1.5) * 1lh);
@@ -53623,7 +53971,9 @@ const css$n = /* css */`
53623
53971
  * @param {number} [maxRows] Lines after which the control stops growing and
53624
53972
  * scrolls instead. Without it the control grows with its content.
53625
53973
  * @param {boolean} [resizable] Give the browser's vertical resize handle back.
53626
- * A manual resize takes over from the automatic growth.
53974
+ * An exchange, not an addition: the hand takes over from the automatic
53975
+ * growth, so the control stops following what is typed and stays at the
53976
+ * height it was last dragged to (starting at `minRows`).
53627
53977
  * @param {number} [maxLength] The character limit, validated at submit. Pair
53628
53978
  * with `maxLengthGuard` to block typing past it, and render a
53629
53979
  * TextareaCharCount to show it.
@@ -53645,6 +53995,7 @@ const Textarea = ({
53645
53995
  import.meta.css = [inputCss + css$n, "@jsenv/navi/src/control/input/textarea.jsx"];
53646
53996
  const defaultRef = useRef(null);
53647
53997
  props.ref = props.ref || defaultRef;
53998
+ usePlaceholderHeight(props.ref, props.placeholder);
53648
53999
  const [rootProps, hostProps, childrenWrapperProps] = useControlProps(props, {
53649
54000
  controlType: "input"
53650
54001
  });
@@ -53725,6 +54076,61 @@ const TextareaCharCount = ({
53725
54076
  children: maxLength === undefined ? length : `${length}/${maxLength}`
53726
54077
  });
53727
54078
  };
54079
+
54080
+ // `field-sizing: content` sizes the box from the value, and an empty field has
54081
+ // none — the placeholder is text the browser refuses to make room for. So the
54082
+ // height it wraps to is measured and published as --x-textarea-placeholder-height
54083
+ // for the CSS above to use as a floor.
54084
+ const usePlaceholderHeight = (ref, placeholder) => {
54085
+ useLayoutEffect(() => {
54086
+ const textareaEl = ref.current;
54087
+ if (!placeholder) {
54088
+ textareaEl.style.removeProperty("--x-textarea-placeholder-height");
54089
+ return null;
54090
+ }
54091
+ let widthMeasured;
54092
+ const measure = () => {
54093
+ // What is typed sizes the box itself; the placeholder is not displayed
54094
+ // then, and scrollHeight would report the value's height instead.
54095
+ if (textareaEl.value !== "") {
54096
+ return;
54097
+ }
54098
+ const {
54099
+ paddingTop,
54100
+ paddingBottom
54101
+ } = getComputedStyle(textareaEl);
54102
+ // Cleared before reading: scrollHeight can never report less than the
54103
+ // height already applied, so measuring on top of a previous measure could
54104
+ // only ever grow the box, never let it shrink back on a wider viewport.
54105
+ textareaEl.style.setProperty("--x-textarea-placeholder-height", "0px");
54106
+ const contentHeight = textareaEl.scrollHeight - parseFloat(paddingTop) - parseFloat(paddingBottom);
54107
+ widthMeasured = textareaEl.clientWidth;
54108
+ textareaEl.style.setProperty("--x-textarea-placeholder-height", `${contentHeight}px`);
54109
+ };
54110
+ measure();
54111
+ // The placeholder wraps against the available width, so a new width is a
54112
+ // new number of lines. Height changes are ignored: this measure is what
54113
+ // causes them, and reacting to them would be reacting to ourselves.
54114
+ const resizeObserver = new ResizeObserver(() => {
54115
+ if (textareaEl.clientWidth !== widthMeasured) {
54116
+ measure();
54117
+ }
54118
+ });
54119
+ resizeObserver.observe(textareaEl);
54120
+ // The width may have changed while the field held a value, when measuring
54121
+ // was impossible — emptying it is when the placeholder comes back.
54122
+ const onInput = () => {
54123
+ if (textareaEl.value === "") {
54124
+ measure();
54125
+ }
54126
+ };
54127
+ textareaEl.addEventListener("input", onInput);
54128
+ return () => {
54129
+ resizeObserver.disconnect();
54130
+ textareaEl.removeEventListener("input", onInput);
54131
+ };
54132
+ }, [placeholder]);
54133
+ };
53728
54134
  const RealTextarea = ({
53729
54135
  maxLength,
53730
54136
  ...domProps