@jsenv/navi 0.29.425 → 0.29.427

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.
@@ -7,7 +7,7 @@ import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchIntern
7
7
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
9
9
  import { options, isValidElement, createContext, render, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
10
- import { useErrorBoundary, useLayoutEffect, useContext, useMemo, useRef, useEffect, useCallback, useState, useReducer, useId } from "preact/hooks";
10
+ import { useErrorBoundary, useLayoutEffect, useContext, useState, useMemo, useRef, useEffect, useCallback, useReducer, useId } from "preact/hooks";
11
11
  import { humanizeI18n, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
12
12
  export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
13
13
  import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
@@ -10812,6 +10812,179 @@ const dropGeneratedIdKeys = (state) => {
10812
10812
  return stateWithoutGeneratedIdKeys;
10813
10813
  };
10814
10814
 
10815
+ const FRAME_TIMEOUT_MS = 100;
10816
+
10817
+ /**
10818
+ * Calls back once the browser has painted what is committed now.
10819
+ *
10820
+ * The callbacks of the next frame run before that frame paints, so the call is
10821
+ * made from a task queued from inside one — the same way preact schedules
10822
+ * useEffect. Unlike an effect, nothing runs this early: preact flushes a
10823
+ * component's pending effects as soon as that component renders again, and a
10824
+ * re-render before the paint is exactly what a caller waiting for the paint
10825
+ * has to survive. A frame not coming at all (a background tab) still answers,
10826
+ * late, through the timeout.
10827
+ *
10828
+ * @param {() => void} callback
10829
+ * @returns {() => void} cancel
10830
+ */
10831
+ const afterPaint = (callback) => {
10832
+ let called = false;
10833
+ let timeoutId;
10834
+ let frameId;
10835
+ const onFrame = () => {
10836
+ if (called) {
10837
+ return;
10838
+ }
10839
+ called = true;
10840
+ clearTimeout(timeoutId);
10841
+ cancelAnimationFrame(frameId);
10842
+ timeoutId = setTimeout(callback, 0);
10843
+ };
10844
+ timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
10845
+ frameId = requestAnimationFrame(onFrame);
10846
+ return () => {
10847
+ called = true;
10848
+ clearTimeout(timeoutId);
10849
+ cancelAnimationFrame(frameId);
10850
+ };
10851
+ };
10852
+
10853
+ /**
10854
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
10855
+ * letting Preact batch it into the next microtask. Layout effects of what gets
10856
+ * mounted run inside the call too, exactly as they would on any other commit.
10857
+ *
10858
+ * For the caller that has to read the DOM it just asked for — measuring an
10859
+ * element whose content it mounts in the same breath — and cannot wait a tick
10860
+ * to do it, because what comes after is a browser event still in flight
10861
+ * (preventDefault, focus placement) that no longer accepts being answered late.
10862
+ *
10863
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
10864
+ * render queue drains; swapping it for "right now" for the duration of the call
10865
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
10866
+ * case above: rendering synchronously in the middle of an event gives up the
10867
+ * batching that makes several state changes one commit.
10868
+ */
10869
+ const flushSyncRendering = (fn) => {
10870
+ const debounceRenderingPrevious = options.debounceRendering;
10871
+ options.debounceRendering = (drainRenderQueue) => {
10872
+ drainRenderQueue();
10873
+ };
10874
+ try {
10875
+ fn();
10876
+ } finally {
10877
+ options.debounceRendering = debounceRenderingPrevious;
10878
+ }
10879
+ };
10880
+
10881
+ /**
10882
+ * The content of a `<Box mount="after-paint">`: built right after the first
10883
+ * frame that shows the box, unless that frame would show it.
10884
+ *
10885
+ * The browser paints nothing until the render that builds a page has ended, and
10886
+ * what lies below the first screen weighs in that render as much as what is on
10887
+ * it. A box marked this way is left empty in that render, then built once the
10888
+ * frame is painted — the page-level twin of a list's render window (see
10889
+ * `renderBudget` in list.jsx), for a page made of sections.
10890
+ *
10891
+ * Which boxes lie below the screen is measured, not taken from the caller. The
10892
+ * caller cannot know: a taller phone, a profile with nothing in its first
10893
+ * section, and the box it marked is on screen. So each box looks where it
10894
+ * stands in the animation callbacks of its first frame — laid out, not yet
10895
+ * painted — and one the frame would show, or one above it, is built there:
10896
+ * content appearing on screen is a pop-in, and content growing ABOVE what is
10897
+ * read pushes it down. Marking too much costs a second render in that frame,
10898
+ * never a wrong picture.
10899
+ *
10900
+ * The document put back where it was read is the other frame that shows it.
10901
+ * That offset is written by navi (see scroll_restoration.js), and written into
10902
+ * a document still missing its bottom it is clamped: the page opens higher than
10903
+ * it was left, and the bottom arrives under a page that has already jumped. So
10904
+ * whoever writes an arrival's offset builds first the boxes that offset shows
10905
+ * (buildDeferredBoxesAbove), in the same task — synchronously, since what comes
10906
+ * right after it (the offset, a route travel measuring its window) reads the
10907
+ * layout.
10908
+ */
10909
+
10910
+ const deferredBoxes = new Set();
10911
+ const MountAfterPaint = ({
10912
+ boxRef,
10913
+ children
10914
+ }) => {
10915
+ const [built, setBuilt] = useState(false);
10916
+ useLayoutEffect(() => {
10917
+ let frame;
10918
+ let cancelAfterPaint;
10919
+ const stopWaiting = () => {
10920
+ deferredBoxes.delete(deferredBox);
10921
+ cancelAnimationFrame(frame);
10922
+ cancelAfterPaint();
10923
+ };
10924
+ // The first of the three that come — the offset of an arrival, the frame
10925
+ // finding the box on screen, the paint — builds it and cancels the others.
10926
+ const deferredBox = {
10927
+ getElement: () => boxRef.current,
10928
+ build: () => {
10929
+ stopWaiting();
10930
+ setBuilt(true);
10931
+ }
10932
+ };
10933
+ deferredBoxes.add(deferredBox);
10934
+ // Requested before afterPaint's own frame callback, so the box is looked at
10935
+ // before that one turns the paint into a build.
10936
+ frame = requestAnimationFrame(() => {
10937
+ const top = readTopInWindow(boxRef.current);
10938
+ if (top !== null && top < window.innerHeight) {
10939
+ deferredBox.build();
10940
+ }
10941
+ });
10942
+ cancelAfterPaint = afterPaint(deferredBox.build);
10943
+ return stopWaiting;
10944
+ }, []);
10945
+ return built ? children : null;
10946
+ };
10947
+
10948
+ /**
10949
+ * Builds, now, every box still waiting whose top is above `bottom` (in the
10950
+ * document's coordinates): what the document will show once scrolled so that
10951
+ * `bottom` is the bottom of the screen. Boxes built push those below them
10952
+ * further down, so the ones measured above `bottom` are all the ones that can
10953
+ * be shown there, and a few more at worst.
10954
+ *
10955
+ * Renders synchronously, so it must be called outside a Preact commit: a render
10956
+ * flushed from a layout effect runs inside the commit still walking the tree.
10957
+ */
10958
+ const buildDeferredBoxesAbove = bottom => {
10959
+ if (deferredBoxes.size === 0) {
10960
+ return;
10961
+ }
10962
+ const toBuild = [];
10963
+ for (const deferredBox of deferredBoxes) {
10964
+ const top = readTopInWindow(deferredBox.getElement());
10965
+ if (top !== null && top + window.scrollY < bottom) {
10966
+ toBuild.push(deferredBox);
10967
+ }
10968
+ }
10969
+ if (toBuild.length === 0) {
10970
+ return;
10971
+ }
10972
+ flushSyncRendering(() => {
10973
+ for (const deferredBox of toBuild) {
10974
+ deferredBox.build();
10975
+ }
10976
+ });
10977
+ };
10978
+
10979
+ // null for a box nothing draws (a closed popup, a hidden tab): no frame shows
10980
+ // it, wherever the document is.
10981
+ const readTopInWindow = element => {
10982
+ if (!element || element.getClientRects().length === 0) {
10983
+ return null;
10984
+ }
10985
+ return element.getBoundingClientRect().top;
10986
+ };
10987
+
10815
10988
  /**
10816
10989
  * A container has put its page on screen — or as much of it as it can.
10817
10990
  *
@@ -10867,7 +11040,9 @@ const [publishRouteRender, observeRouteRender] = createPubSub();
10867
11040
  * What is NOT covered, and cannot be from here: a page whose height depends on
10868
11041
  * something still loading. Its content is not there at the moment it is put
10869
11042
  * back, so a position beyond what has arrived is clamped as before. Only the
10870
- * page knows when it is whole.
11043
+ * page knows when it is whole. What navi itself leaves out of a first render
11044
+ * is the exception — a `<Box mount="after-paint">` — and is built before the
11045
+ * offset that shows it is written (see mount_after_paint.jsx).
10871
11046
  *
10872
11047
  * WHEN a page is arrived at is not decided here either. A document navigation
10873
11048
  * lands where its kind says (see via_history.js), and one scrollport can be
@@ -11016,14 +11191,18 @@ const installScrollRestoration = () => {
11016
11191
  // What a reload asks for, now that the browser has been told not to do it.
11017
11192
  // Once, and at the first render of a route: the position is only meaningful
11018
11193
  // once there is a page under it.
11194
+ // Written once the commit that rendered it has ended: said from inside it,
11195
+ // and what the offset shows may still have to be built (see scrollTo).
11019
11196
  const positionOnLoad = positionByUrl.get(window.location.href);
11020
11197
  if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11021
11198
  const stopListening = observeRouteRender(() => {
11022
11199
  stopListening();
11023
- if (documentHoldCount) {
11024
- return;
11025
- }
11026
- scrollTo(positionOnLoad);
11200
+ queueMicrotask(() => {
11201
+ if (documentHoldCount) {
11202
+ return;
11203
+ }
11204
+ scrollTo(positionOnLoad);
11205
+ });
11027
11206
  });
11028
11207
  }
11029
11208
  };
@@ -11130,14 +11309,25 @@ const forgetScrollersOnArrival = (url, { from } = {}) => {
11130
11309
  // last read. Where this one was read, and its top when it never was — leaving
11131
11310
  // the offset alone would seat the reader wherever the neighbour happened to
11132
11311
  // be, so here "nothing recorded" and "stay" are not the same thing.
11312
+ //
11313
+ // Called outside a Preact commit only, like scrollTo: a row of tabs measures
11314
+ // its window right after, so what the top shows is built first here rather
11315
+ // than in the frame (see mount_after_paint.jsx).
11133
11316
  const arriveAtScrollPosition = (url) => {
11134
11317
  if (restoreScrollPosition(url)) {
11135
11318
  return;
11136
11319
  }
11320
+ buildDeferredBoxesAbove(window.innerHeight);
11137
11321
  startAtTop(url);
11138
11322
  };
11139
11323
 
11324
+ // What the offset shows is built first: written into a document still missing
11325
+ // a deferred box, the offset is clamped (see mount_after_paint.jsx). That build
11326
+ // is a render of its own, so this is never called from inside a commit — a
11327
+ // caller that learns the page is there from a layout effect (observeRouteRender)
11328
+ // writes once that commit has ended.
11140
11329
  const scrollTo = ({ x, y }) => {
11330
+ buildDeferredBoxesAbove(y + window.innerHeight);
11141
11331
  window.scrollTo({ top: y, left: x, behavior: "instant" });
11142
11332
  };
11143
11333
 
@@ -16048,9 +16238,9 @@ const armOutsidePressClose = (
16048
16238
  * `font-family` is deliberately absent: a surface keeps the face of what
16049
16239
  * opened it, so a section written in a display font gets its tooltips in that
16050
16240
  * font. `color`, `font-size` and `font-weight` are absent too — each surface
16051
- * answers those its own way (a popup writes in --navi-popup-color and follows
16052
- * the size of what opened it; a callout reverts to the document's ink and
16053
- * size).
16241
+ * answers those its own way (a popup and a callout write in
16242
+ * --navi-popup-color; a popup follows the size of what opened it, a callout
16243
+ * resets to the document's size).
16054
16244
  *
16055
16245
  * In `@layer navi` so an app that wants one of these back says so on the
16056
16246
  * surface and wins, without having to out-specify anything.
@@ -16280,8 +16470,7 @@ const css$14 = /* css */ `
16280
16470
  --callout-error-color: var(--navi-callout-error-color);
16281
16471
  --callout-neutral-color: var(--navi-callout-neutral-color);
16282
16472
 
16283
- --callout-background-color: white;
16284
- --callout-icon-color: black;
16473
+ --callout-background-color: var(--navi-popup-background-color);
16285
16474
  --callout-padding: 8px;
16286
16475
  --callout-z-index: var(--navi-z-index-callout);
16287
16476
  /* The page's line, said here rather than inherited: the icon and the
@@ -16335,11 +16524,11 @@ const css$14 = /* css */ `
16335
16524
  height: auto; /* User agent reset */
16336
16525
  margin: 0;
16337
16526
  padding: 0; /* User agent reset */
16338
- /* The UA's ink for a [popover] element (CanvasText), not the element's:
16339
- a callout sits inside the element but writes on its own paper. The
16340
- color keywords are re-declared against that same ink in
16341
- navi_css_vars.js (.navi_callout). */
16342
- color: revert;
16527
+ /* A callout sits inside the element but writes on its own paper: the
16528
+ popup's ink, paired with the popup's paper it is drawn on, so the two
16529
+ follow color-scheme and the theme together. The color keywords are
16530
+ re-declared against that same ink in navi_css_vars.js. */
16531
+ color: var(--navi-popup-color);
16343
16532
  font-weight: initial; /* Callout fells disconnected from the element, font weight should be predictible and stable */
16344
16533
  font-size: initial; /* Callout fells disconnected from the element, font size should be predictible and stable */
16345
16534
  line-height: var(
@@ -18562,16 +18751,18 @@ const findBusySource = (field) => {
18562
18751
  // a new interaction is queued behind the run (see the action queue in
18563
18752
  // control_hooks.jsx) rather than refused.
18564
18753
  if (!field.optimistic && boundAction) {
18565
- // The INSTANCE the proxy resolves to right now, not the proxy's own
18566
- // signal: that one is a MIRROR, synced by an effect the settling batch
18567
- // defers — read mid-batch (a state echo carrying the user's event back
18568
- // down, an automatic follow-up), it still says RUNNING for an action
18569
- // that is already over, and the gate would refuse — callout included —
18570
- // for nothing. The resolved instance is the live truth: at that echo it
18571
- // is the instance that just settled, already COMPLETED. And it IS the
18572
- // running one whenever one runs — a non-optimistic control's state
18573
- // cannot move mid-run, this very gate blocks it.
18574
- const liveAction = boundAction.getCurrentAction?.() ?? boundAction;
18754
+ // The instance the run in flight uses, as the render reads it (see
18755
+ // runningActionSignal in control_hooks.jsx): the state a proxy follows can
18756
+ // move mid-run — a popup content unmounting takes the group's value — and
18757
+ // the proxy would resolve to an idle instance. With no run out, the
18758
+ // instance the proxy resolves to right now, never the proxy's own signal:
18759
+ // a MIRROR synced by an effect the settling batch defers, which still says
18760
+ // RUNNING mid-batch (a state echo, an automatic follow-up) for an action
18761
+ // already over. An instance is COMPLETED the moment it settles.
18762
+ const liveAction =
18763
+ field.runningActionSignal.value ??
18764
+ boundAction.getCurrentAction?.() ??
18765
+ boundAction;
18575
18766
  if (liveAction.runningStateSignal.value === RUNNING) {
18576
18767
  return { field, action: liveAction };
18577
18768
  }
@@ -19609,11 +19800,10 @@ const READONLY_CONSTRAINT = {
19609
19800
  name: "readonly",
19610
19801
  messageAttribute: "data-readonly-message",
19611
19802
  check: (field, { intent } = {}) => {
19612
- const readOnly = Boolean(
19613
- field.controlHostProps.readOnly ||
19614
- field.controlHostProps["aria-readonly"] === "true",
19615
- );
19616
- if (!readOnly) {
19803
+ // Not off the rendered `readOnly`/`aria-readonly`: they also carry the run
19804
+ // of the control's own action, a frame behind (see readOnlyWithoutRun in
19805
+ // control_hooks.jsx). That run is BUSY_CONSTRAINT's, which reads it live.
19806
+ if (!field.readOnlyWithoutRun) {
19617
19807
  return null;
19618
19808
  }
19619
19809
 
@@ -30032,6 +30222,7 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30032
30222
  * footer?: boolean,
30033
30223
  * body?: boolean,
30034
30224
  * sticky?: boolean,
30225
+ * mount?: "always" | "after-paint",
30035
30226
  * children?: import("ignore:preact").ComponentChildren,
30036
30227
  * [key: string]: any,
30037
30228
  * }>}
@@ -30080,6 +30271,16 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30080
30271
  * (see docs/interactions.md). A plain box has no wiring of its own: it does
30081
30272
  * nothing with `action` (that is a control's prop), so a click on it is
30082
30273
  * declared here like any other interaction: `interactions={{ click: fn }}`.
30274
+ * @param {"always"|"after-paint"} [mount="always"] When `children` are built.
30275
+ * `"after-paint"` leaves the box empty in the render that first mounts it and
30276
+ * builds them right after the first frame that shows the page, then keeps
30277
+ * them: for the sections of a page that lie below its first screen. navi
30278
+ * measures where the box stands before that frame, and one the frame would
30279
+ * show (or where the document is put back, on a return) is built before the
30280
+ * paint all the same. Until built, what is inside is not in the document:
30281
+ * no id there is found, no named control there is there to hear its own
30282
+ * `signal` (see docs/control_object.md). Only for content nothing reaches
30283
+ * before the first frame (see docs/scroll.md).
30083
30284
  */
30084
30285
  const Box = props => {
30085
30286
  const {
@@ -30087,6 +30288,7 @@ const Box = props => {
30087
30288
  children,
30088
30289
  separator,
30089
30290
  interactions,
30291
+ mount,
30090
30292
  ...computeProps
30091
30293
  } = props;
30092
30294
  const parentBoxFlow = useContext(BoxFlowContext);
@@ -30164,6 +30366,12 @@ const Box = props => {
30164
30366
  children: innerChildren
30165
30367
  });
30166
30368
  }
30369
+ if (mount === "after-paint") {
30370
+ innerChildren = jsx(MountAfterPaint, {
30371
+ boxRef: finalRef,
30372
+ children: innerChildren
30373
+ });
30374
+ }
30167
30375
  return jsx(TagName, {
30168
30376
  ref: finalRef,
30169
30377
  className: innerClassName,
@@ -33642,6 +33850,13 @@ const OLD_BAND_TOP_PROPERTY = "--navi-transition-old-band-top";
33642
33850
  const OLD_BAND_RIGHT_PROPERTY = "--navi-transition-old-band-right";
33643
33851
  const OLD_BAND_BOTTOM_PROPERTY = "--navi-transition-old-band-bottom";
33644
33852
  const OLD_BAND_LEFT_PROPERTY = "--navi-transition-old-band-left";
33853
+ const VIEWPORT_TOP_PROPERTY = "--navi-transition-viewport-top";
33854
+ const VIEWPORT_LEFT_PROPERTY = "--navi-transition-viewport-left";
33855
+ // Worn by the root once the group is held at the window by our numbers alone
33856
+ // (see placeTransitionWindow): the group rule of each movement is written on
33857
+ // it. Read by rules on pseudo-elements only, so wearing it restyles nothing
33858
+ // the document inherits.
33859
+ const WINDOW_PLACED_ATTRIBUTE = "data-navi-transition-window-placed";
33645
33860
  const WINDOW_PROPERTIES = [
33646
33861
  WINDOW_TOP_PROPERTY,
33647
33862
  WINDOW_LEFT_PROPERTY,
@@ -33655,6 +33870,8 @@ const WINDOW_PROPERTIES = [
33655
33870
  OLD_BAND_RIGHT_PROPERTY,
33656
33871
  OLD_BAND_BOTTOM_PROPERTY,
33657
33872
  OLD_BAND_LEFT_PROPERTY,
33873
+ VIEWPORT_TOP_PROPERTY,
33874
+ VIEWPORT_LEFT_PROPERTY,
33658
33875
  ];
33659
33876
 
33660
33877
  // Whose numbers are currently published. The window belongs to the movement
@@ -33662,6 +33879,8 @@ const WINDOW_PROPERTIES = [
33662
33879
  // after another has replaced it must not wipe numbers the new one is standing
33663
33880
  // on.
33664
33881
  let windowOwner = null;
33882
+ // The element the box is captured as, for as long as the window is held.
33883
+ let windowElement = null;
33665
33884
  // Stops re-reading the arriving state's corner, when a movement is on.
33666
33885
  let unfollowArrivingState = null;
33667
33886
 
@@ -33717,6 +33936,10 @@ const holdTransitionWindow = (
33717
33936
  const right =
33718
33937
  rectBefore.right > rectAfter.right ? rectBefore.right : rectAfter.right;
33719
33938
  windowOwner = owner;
33939
+ windowElement = element;
33940
+ // Pictures of a movement this one took over from are placed by the browser
33941
+ // until this one's own pictures exist.
33942
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
33720
33943
  setTransitionValue(WINDOW_TOP_PROPERTY, `${top}px`);
33721
33944
  setTransitionValue(WINDOW_LEFT_PROPERTY, `${left}px`);
33722
33945
  setTransitionValue(WINDOW_WIDTH_PROPERTY, `${right - left}px`);
@@ -33741,6 +33964,33 @@ const holdTransitionWindow = (
33741
33964
  });
33742
33965
  };
33743
33966
 
33967
+ /**
33968
+ * Called once the pictures exist — the view transition's `ready` — with the
33969
+ * name the box was captured under. Where the viewport's corner stands in the
33970
+ * space the pictures are drawn in is the browser's placement of the group
33971
+ * minus our reading of the same element, both taken in this one instant; the
33972
+ * group is held at the window with it from then on (see the top of the file).
33973
+ * A browser that does not hand the group's placement back leaves the group
33974
+ * where it places it, moved to the window by the arriving corner.
33975
+ */
33976
+ const placeTransitionWindow = (owner, groupName) => {
33977
+ if (owner !== windowOwner) {
33978
+ return;
33979
+ }
33980
+ const { transform } = getComputedStyle(
33981
+ document.documentElement,
33982
+ `::view-transition-group(${groupName})`,
33983
+ );
33984
+ if (!transform || transform === "none") {
33985
+ return;
33986
+ }
33987
+ const placement = new DOMMatrixReadOnly(transform);
33988
+ const rect = windowElement.getBoundingClientRect();
33989
+ setTransitionValue(VIEWPORT_TOP_PROPERTY, `${placement.m42 - rect.top}px`);
33990
+ setTransitionValue(VIEWPORT_LEFT_PROPERTY, `${placement.m41 - rect.left}px`);
33991
+ document.documentElement.setAttribute(WINDOW_PLACED_ATTRIBUTE, "");
33992
+ };
33993
+
33744
33994
  // The live layout takes the box back. A discontinuity by construction — the
33745
33995
  // window stands at the held height, the box is at its own — and an invisible
33746
33996
  // one: the page arriving is fully in place, and the strip below it that the
@@ -33750,20 +34000,22 @@ const releaseTransitionWindow = (owner) => {
33750
34000
  return;
33751
34001
  }
33752
34002
  windowOwner = null;
34003
+ windowElement = null;
33753
34004
  if (unfollowArrivingState) {
33754
34005
  unfollowArrivingState();
33755
34006
  }
34007
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
33756
34008
  for (const property of WINDOW_PROPERTIES) {
33757
34009
  removeTransitionValue(property);
33758
34010
  }
33759
34011
  };
33760
34012
 
33761
- // The arriving state's corner, re-read every frame — as often as the browser
33762
- // refreshes the group's placement from the same element (see the top of the
33763
- // file) — and with it the offset outside the box, which carries the window
33764
- // and the corner of the state being left along. Requested, never awaited: the
33765
- // first call runs inside the update callback, where a frame cannot come.
33766
- // Written only when it moved: every write restyles the pictures.
34013
+ // The arriving state's corner, re-read every frame — as often as anything can
34014
+ // move the page arriving (see the top of the file) — and with it the offset
34015
+ // outside the box, which carries the window and the corner of the state being
34016
+ // left along. Requested, never awaited: the first call runs inside the update
34017
+ // callback, where a frame cannot come. Written only when it moved: every write
34018
+ // restyles the pictures.
33767
34019
  const followArrivingState = (element, rectAtHold, heldAt) => {
33768
34020
  if (unfollowArrivingState) {
33769
34021
  unfollowArrivingState();
@@ -34268,6 +34520,13 @@ const css$12 = /* css */`@property --navi-route-transition-leave {
34268
34520
  animation-name: none;
34269
34521
  }
34270
34522
 
34523
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-transition) {
34524
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
34525
+ left: calc(var(--navi-transition-window-left) +
34526
+ var(--navi-transition-viewport-left));
34527
+ transform: none;
34528
+ }
34529
+
34271
34530
  &::view-transition-old(navi-route-transition) {
34272
34531
  top: calc(var(--navi-transition-window-old-top) - var(--navi-transition-window-top));
34273
34532
  left: calc(var(--navi-transition-window-old-left) - var(--navi-transition-window-left));
@@ -35315,6 +35574,8 @@ const beginTransition = ({
35315
35574
  // the only place the silent misconfigurations show. They are all about the
35316
35575
  // same thing — a movement playing on pictures that are not the pages.
35317
35576
  const viewTransitionReady = () => {
35577
+ // Read before anything below writes to the document.
35578
+ placeTransitionWindow(transition, AREA_NAME);
35318
35579
  startTransitionFurniture(transition, {
35319
35580
  duration: transition.duration
35320
35581
  });
@@ -35826,6 +36087,13 @@ const css$11 = /* css */`.navi_route_travel {
35826
36087
  animation-duration: var(--navi-route-travel-duration, .3s);
35827
36088
  animation-name: none;
35828
36089
  }
36090
+
36091
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-travel) {
36092
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
36093
+ left: calc(var(--navi-transition-window-left) +
36094
+ var(--navi-transition-viewport-left));
36095
+ transform: none;
36096
+ }
35829
36097
  }
35830
36098
 
35831
36099
  :root[data-navi-route-travel-held] {
@@ -36204,6 +36472,10 @@ const RouteTravel = ({
36204
36472
  });
36205
36473
  });
36206
36474
  travel.viewTransition = viewTransition;
36475
+ // Subscribed first, so it reads before the scrub below writes anything.
36476
+ viewTransition.ready.then(() => {
36477
+ placeTransitionWindow(travel, TRAVEL_NAME);
36478
+ }, ignoreSkipped);
36207
36479
  if (scrub) {
36208
36480
  // Said only now: the release has to have something to let go of, and the
36209
36481
  // transition did not exist a line above.
@@ -36403,16 +36675,20 @@ const RouteTravel = ({
36403
36675
  // what makes the document tall enough to hold that offset again —
36404
36676
  // and the recording stays deaf until then, over the clamp the swap
36405
36677
  // back makes on the way. Its own deafness rather than the travel's:
36406
- // the travel ends here, and this outlives it by a render.
36678
+ // the travel ends here, and this outlives it by a render. Written
36679
+ // once the commit that says so has ended, as arriveAtScrollPosition
36680
+ // asks.
36407
36681
  const resumeScrollRecording = suspendScrollRecording();
36408
36682
  const stopWatchingRender = observeRouteRender(() => {
36409
36683
  stopWatchingRender();
36410
- const {
36411
- route,
36412
- params
36413
- } = travel.fromPage;
36414
- arriveAtScrollPosition(route.buildUrl(params));
36415
- resumeScrollRecording();
36684
+ queueMicrotask(() => {
36685
+ const {
36686
+ route,
36687
+ params
36688
+ } = travel.fromPage;
36689
+ arriveAtScrollPosition(route.buildUrl(params));
36690
+ resumeScrollRecording();
36691
+ });
36416
36692
  });
36417
36693
  }
36418
36694
  releaseRendering();
@@ -39145,34 +39421,6 @@ registerNaviCommand("--navi-unselect", (source, event) => {
39145
39421
  };
39146
39422
  });
39147
39423
 
39148
- /**
39149
- * Runs `fn` and commits whatever it re-renders before returning, instead of
39150
- * letting Preact batch it into the next microtask. Layout effects of what gets
39151
- * mounted run inside the call too, exactly as they would on any other commit.
39152
- *
39153
- * For the caller that has to read the DOM it just asked for — measuring an
39154
- * element whose content it mounts in the same breath — and cannot wait a tick
39155
- * to do it, because what comes after is a browser event still in flight
39156
- * (preventDefault, focus placement) that no longer accepts being answered late.
39157
- *
39158
- * `options.debounceRendering` is Preact's own hook for deciding *when* the
39159
- * render queue drains; swapping it for "right now" for the duration of the call
39160
- * is exactly how preact/compat implements React's flushSync. Reserve it for the
39161
- * case above: rendering synchronously in the middle of an event gives up the
39162
- * batching that makes several state changes one commit.
39163
- */
39164
- const flushSyncRendering = (fn) => {
39165
- const debounceRenderingPrevious = options.debounceRendering;
39166
- options.debounceRendering = (drainRenderQueue) => {
39167
- drainRenderQueue();
39168
- };
39169
- try {
39170
- fn();
39171
- } finally {
39172
- options.debounceRendering = debounceRenderingPrevious;
39173
- }
39174
- };
39175
-
39176
39424
  /**
39177
39425
  * When a popup builds what it holds, and when it throws it away.
39178
39426
  *
@@ -41471,6 +41719,65 @@ const keptOrFirstDefinedChildUIState = (children, fallbackState, kept) => {
41471
41719
  }
41472
41720
  return kept;
41473
41721
  };
41722
+ // The same claim, for an object: a key whose control is not there cannot be
41723
+ // contradicted by it — a slide not built yet (SlideContainer mount="near"), a
41724
+ // row scrolled out, a page parked. A key of `kept` stays until a control of
41725
+ // that name, there, says otherwise; a value put ON the group replaces the
41726
+ // whole object, which is how a key is dropped on purpose.
41727
+ const keptAndNamedChildUIStates = (children, fallbackState, kept) => {
41728
+ const groupValues =
41729
+ kept !== null && typeof kept === "object" && !Array.isArray(kept)
41730
+ ? { ...kept }
41731
+ : {};
41732
+ return Object.assign(
41733
+ groupValues,
41734
+ readNamedChildUIStates(children, { warnNameless: true }),
41735
+ );
41736
+ };
41737
+ // What the named controls among `children` say, one key each. A nameless
41738
+ // GROUP is a grouping, not a value: it exists to hold its children together
41739
+ // (a WheelGroup sharing navigation, a fieldset-ish cluster) without claiming a
41740
+ // key of its own, so what its controls say is written as if they were here.
41741
+ // Naming it is what turns the same group into one key — see ControlGroup's
41742
+ // `name`. Only what its CONTROLS say: such a grouping is handed the whole
41743
+ // object around it (see distributeChildUIState below) and keeps what they do
41744
+ // not answer for, so merged whole it would carry its siblings' keys back up,
41745
+ // stale.
41746
+ const readNamedChildUIStates = (children, { warnNameless }) => {
41747
+ const values = {};
41748
+ for (const child of children) {
41749
+ const { name, emptyUIState } = child;
41750
+ // A control holding nothing writes its own empty, not a hole: the key is in
41751
+ // the object either way, and what is read from it keeps the shape the
41752
+ // reader was promised (see resolveEmptyUIState).
41753
+ const uiState =
41754
+ child.uiState === undefined && emptyUIState !== undefined
41755
+ ? emptyUIState
41756
+ : child.uiState;
41757
+ if (!name) {
41758
+ if (isNamelessGrouping(child, uiState)) {
41759
+ Object.assign(
41760
+ values,
41761
+ child.keepsUnansweredKeys
41762
+ ? readNamedChildUIStates(child.getChildControllers(), {
41763
+ warnNameless: false,
41764
+ })
41765
+ : uiState,
41766
+ );
41767
+ continue;
41768
+ }
41769
+ if (warnNameless) {
41770
+ console.warn(
41771
+ "A group child is missing a name property, its state won't be included in the group state",
41772
+ child,
41773
+ );
41774
+ }
41775
+ continue;
41776
+ }
41777
+ values[name] = uiState;
41778
+ }
41779
+ return values;
41780
+ };
41474
41781
 
41475
41782
  // Default aggregate/distribute implementations keyed by controlType or stateType.
41476
41783
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
@@ -41540,37 +41847,7 @@ const GROUP_DEFAULTS = {
41540
41847
  childControlFilter: (child) => {
41541
41848
  return child.controlType !== "button" && child.controlType !== "link";
41542
41849
  },
41543
- aggregateChildStates: (children) => {
41544
- const groupValues = {};
41545
- for (const child of children) {
41546
- const { name, emptyUIState } = child;
41547
- // A control holding nothing writes its own empty, not a hole: the key
41548
- // is in the object either way, and what is read from it keeps the shape
41549
- // the reader was promised (see resolveEmptyUIState).
41550
- const uiState =
41551
- child.uiState === undefined && emptyUIState !== undefined
41552
- ? emptyUIState
41553
- : child.uiState;
41554
- if (!name) {
41555
- // A nameless GROUP is a grouping, not a value: it exists to hold its
41556
- // children together (a WheelGroup sharing navigation, a fieldset-ish
41557
- // cluster) without claiming a key of its own, so what it holds is
41558
- // merged in as if its children had been written here. Naming it is
41559
- // what turns the same group into one key — see ControlGroup's `name`.
41560
- if (isNamelessGrouping(child, uiState)) {
41561
- Object.assign(groupValues, uiState);
41562
- continue;
41563
- }
41564
- console.warn(
41565
- "A group child is missing a name property, its state won't be included in the group state",
41566
- child,
41567
- );
41568
- continue;
41569
- }
41570
- groupValues[name] = uiState;
41571
- }
41572
- return groupValues;
41573
- },
41850
+ aggregateChildStates: keptAndNamedChildUIStates,
41574
41851
  distributeChildUIState: (newUIState, child) => {
41575
41852
  const childName = child.name;
41576
41853
  if (
@@ -41594,7 +41871,8 @@ const GROUP_DEFAULTS = {
41594
41871
  return childUIState;
41595
41872
  }
41596
41873
  // Merged in on the way up (see above), so on the way down it takes the
41597
- // whole object and picks out its own keys — the same value it produced.
41874
+ // whole object: its controls pick out their own keys, and it keeps the
41875
+ // rest for those of its controls that are not there yet.
41598
41876
  // Only for a child with no name of its own: the same condition the
41599
41877
  // aggregate applies before merging. A NAMED child holding an object (a
41600
41878
  // `type="object"` picker, say) answers for one key, and the object it is
@@ -42072,6 +42350,11 @@ const useUIGroupStateController = (
42072
42350
  // a picker filling its popup, a value prop) rather than worked out from
42073
42351
  // its children. What it protects is read in onChange.
42074
42352
  stateGivenFromAbove: hasValueProp || hasDefaultValueProp,
42353
+ // Its value carries keys none of its controls answers for (see
42354
+ // keptAndNamedChildUIStates): a group merging it as a nameless
42355
+ // grouping reads its controls instead of its value.
42356
+ keepsUnansweredKeys:
42357
+ resolvedAggregateChildStates === keptAndNamedChildUIStates,
42075
42358
  uiStateSignal,
42076
42359
  wantRequesterButtonState,
42077
42360
  ref,
@@ -42705,6 +42988,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
42705
42988
  ref: realUIStateController.ref,
42706
42989
  uiStateSignal: realUIStateController.uiStateSignal,
42707
42990
  controlHostProps: realUIStateController.controlHostProps,
42991
+ readOnlyWithoutRun: realUIStateController.readOnlyWithoutRun,
42708
42992
  registerChild: (child) => {
42709
42993
  if (!canRegisterAsFacadeChild(child)) {
42710
42994
  return;
@@ -42833,6 +43117,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
42833
43117
  s.controller.ref = realUIStateController.ref;
42834
43118
  s.controller.uiStateSignal = realUIStateController.uiStateSignal;
42835
43119
  s.controller.controlHostProps = realUIStateController.controlHostProps;
43120
+ s.controller.readOnlyWithoutRun =
43121
+ realUIStateController.readOnlyWithoutRun;
42836
43122
  realUIStateController.pushStateDownToFacadeChild =
42837
43123
  s.controller.pushStateDownToChild;
42838
43124
 
@@ -44639,6 +44925,11 @@ const useInteractiveProps = (props, {
44639
44925
  const actionLoading = optimistic ? false : actionRunning;
44640
44926
  const loadingResolved = loadingBase || actionLoading;
44641
44927
  const readOnlyResolved = readOnlyBase || actionLoading;
44928
+ // What READONLY_CONSTRAINT refuses for. The run of the control's own action
44929
+ // is BUSY_CONSTRAINT's, read live: readOnlyResolved is a render snapshot,
44930
+ // still read-only in the tick the run settles — the tick a form's
44931
+ // after-send `--navi-reset` asks the form to empty itself.
44932
+ uiStateController.readOnlyWithoutRun = Boolean(readOnlyBase);
44642
44933
  // Read-only, and what this control opens still opens: reading what is in
44643
44934
  // there changes nothing. Read by READONLY_CONSTRAINT, which lets an
44644
44935
  // interaction that only reads through on it.
@@ -45091,10 +45382,10 @@ const useInteractiveProps = (props, {
45091
45382
  });
45092
45383
  }
45093
45384
  // controlHostProps is a curated subset of props with resolved values applied
45094
- // (e.g. readOnly resolved from context + action loading). The interaction system
45095
- // reads off uiStateController.controlHostProps at runtime (e.g. READONLY_CONSTRAINT
45096
- // checks controlHostProps.readOnly), so pointing the controller at controlHostProps
45097
- // keeps those reads current without any extra bookkeeping.
45385
+ // (e.g. readOnly resolved from context + action loading). The constraints
45386
+ // read off uiStateController.controlHostProps at runtime (e.g. DISABLED_CONSTRAINT,
45387
+ // the readonly reason), so pointing the controller at controlHostProps keeps
45388
+ // those reads current without any extra bookkeeping.
45098
45389
  const firstRender = uiStateController.controlHostProps === undefined;
45099
45390
  uiStateController.controlHostProps = controlHostProps;
45100
45391
  // The action itself, not just what the last render made of it: its running
@@ -52551,7 +52842,8 @@ const useExpandableContext = partName => {
52551
52842
  * other three, intent on the UI part (pointer entering it, focus landing in
52552
52843
  * it) builds the content ahead of the click; `"while-opened"` content is
52553
52844
  * built by a press on it only, and thrown away if that press does not
52554
- * expand it.
52845
+ * expand it. Named controls in content not built are kept by the group
52846
+ * around them (docs/control_object.md, "A control that is not there").
52555
52847
  */
52556
52848
  const Expandable = props => {
52557
52849
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -54137,14 +54429,34 @@ const ButtonConfirm = ({
54137
54429
  }
54138
54430
  return jsxs("span", {
54139
54431
  className: "navi_button_confirm",
54140
- ref: askingRef,
54432
+ ref: askingRef
54433
+ // A place for the focus, so a press anywhere in the question keeps it
54434
+ // inside: WebKit does not focus a pressed button, and a press on
54435
+ // "Confirmer" would otherwise blur toward <body> — a focusout with no
54436
+ // relatedTarget, read below as the person leaving. The callout answers
54437
+ // the same ambiguity the same way.
54438
+ ,
54439
+ tabIndex: -1
54440
+ // "not me, unless you have nothing else": the ladder in the effect above
54441
+ // walks past the question to what it holds.
54442
+ // eslint-disable-next-line react/no-unknown-property
54443
+ ,
54444
+ "navi-autofocus": "last-resort",
54141
54445
  onKeyDown: e => {
54142
54446
  if (e.key === "Escape") {
54143
54447
  cancel(true);
54144
54448
  }
54145
54449
  },
54146
54450
  onFocusOut: e => {
54147
- if (!askingRef.current.contains(e.relatedTarget)) {
54451
+ // Chrome also fires this from the question's own removal, once preact
54452
+ // has cleared the ref: nobody left, and cancel(false) would undo the
54453
+ // focus the answer gives back. That focus cannot go back before the
54454
+ // removal: the button it lands on is mounted by the same commit.
54455
+ const askingEl = askingRef.current;
54456
+ if (!askingEl) {
54457
+ return;
54458
+ }
54459
+ if (!askingEl.contains(e.relatedTarget)) {
54148
54460
  cancel(false);
54149
54461
  }
54150
54462
  },
@@ -59613,12 +59925,19 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
59613
59925
  * would bring alongside (neighbours on the map, along the axes
59614
59926
  * `travelByDrag` opens, the other end of a `loop` included) — and kept from
59615
59927
  * then on. For slides that are costly to build (a grid of a few hundred
59616
- * controls each): opening builds the slide shown and its neighbours rather
59928
+ * items each): opening builds the slide shown and its neighbours rather
59617
59929
  * than all of them, and a jump to a far slide builds that one and none of
59618
59930
  * those in between, which a travel never shows. The box is then as large as
59619
59931
  * the largest slide BUILT so far (see `sizing`), so it grows as a larger one
59620
59932
  * is reached: give it a size of its own when the slides differ.
59621
59933
  * `<Slide mount>` says it for one slide — a heavy one among light ones.
59934
+ * A slide not built has no controls, and a group around the container keeps
59935
+ * their keys for them: what it was handed reaches them once built. What is
59936
+ * shared between slides is written through that group (its `signal`, a
59937
+ * `--navi-update` aimed at it), never into one control's own `signal`, which
59938
+ * nobody reads while that control is not built; and nothing validates a
59939
+ * field before its slide is built. See docs/control_object.md, "A control
59940
+ * that is not there".
59622
59941
  */
59623
59942
  const SlideContainer = ({
59624
59943
  layout = "row",
@@ -69543,44 +69862,6 @@ document.addEventListener(
69543
69862
  { capture: true, passive: true },
69544
69863
  );
69545
69864
 
69546
- const FRAME_TIMEOUT_MS = 100;
69547
-
69548
- /**
69549
- * Calls back once the browser has painted what is committed now.
69550
- *
69551
- * The callbacks of the next frame run before that frame paints, so the call is
69552
- * made from a task queued from inside one — the same way preact schedules
69553
- * useEffect. Unlike an effect, nothing runs this early: preact flushes a
69554
- * component's pending effects as soon as that component renders again, and a
69555
- * re-render before the paint is exactly what a caller waiting for the paint
69556
- * has to survive. A frame not coming at all (a background tab) still answers,
69557
- * late, through the timeout.
69558
- *
69559
- * @param {() => void} callback
69560
- * @returns {() => void} cancel
69561
- */
69562
- const afterPaint = (callback) => {
69563
- let called = false;
69564
- let timeoutId;
69565
- let frameId;
69566
- const onFrame = () => {
69567
- if (called) {
69568
- return;
69569
- }
69570
- called = true;
69571
- clearTimeout(timeoutId);
69572
- cancelAnimationFrame(frameId);
69573
- timeoutId = setTimeout(callback, 0);
69574
- };
69575
- timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
69576
- frameId = requestAnimationFrame(onFrame);
69577
- return () => {
69578
- called = true;
69579
- clearTimeout(timeoutId);
69580
- cancelAnimationFrame(frameId);
69581
- };
69582
- };
69583
-
69584
69865
  const LoadingDotsSvg = () => {
69585
69866
  return jsxs("svg", {
69586
69867
  viewBox: "0 0 200 200",
@@ -71611,7 +71892,7 @@ const css$x = /* css */`@property --list-group-label-height {
71611
71892
  }
71612
71893
  }
71613
71894
 
71614
- .navi_list[navi-box-flow="grid"] > .navi_list_virtual_filler, .navi_list[navi-box-flow="grid"] > .navi_separator {
71895
+ .navi_list[navi-box-flow="grid"] > .navi_list_virtual_filler, .navi_list[navi-box-flow="grid"] > .navi_list_failed_items, .navi_list[navi-box-flow="grid"] > .navi_separator {
71615
71896
  grid-column: 1 / -1;
71616
71897
  }
71617
71898
 
@@ -71988,7 +72269,8 @@ const ListUI = props => {
71988
72269
  listId: rest.id,
71989
72270
  scroller,
71990
72271
  searchText,
71991
- horizontal
72272
+ horizontal,
72273
+ columns
71992
72274
  });
71993
72275
 
71994
72276
  // renderBudget frames the items of a run; a list whose items are all declared
@@ -72303,7 +72585,8 @@ const useListScrollSync = ({
72303
72585
  listId,
72304
72586
  scroller,
72305
72587
  searchText,
72306
- horizontal
72588
+ horizontal,
72589
+ columns
72307
72590
  }) => {
72308
72591
  const debugScroll = useDebugScroll();
72309
72592
  // The items drawn, [start, end) among the list's own. A ref as well as a
@@ -72318,6 +72601,7 @@ const useListScrollSync = ({
72318
72601
  return renderWindow.start > 0 || renderWindow.end < listItems.totalSignal.peek();
72319
72602
  };
72320
72603
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
72604
+ inLines: Boolean(columns),
72321
72605
  windowLeavesItemsOut,
72322
72606
  scrolledWanted: scrolled ?? defaultScrolled,
72323
72607
  // The fillers hold the room of items above the screen at this size: a
@@ -72386,6 +72670,34 @@ const useListScrollSync = ({
72386
72670
  });
72387
72671
  };
72388
72672
 
72673
+ // How many items share a line: the tracks a grid of items (`columns`)
72674
+ // resolves to, one otherwise. A layout result under auto-fill, so it is read
72675
+ // off the list once laid out, at each commit and each resize — for the runs
72676
+ // alone, which draw in lines: items declared one by one are all drawn. A
72677
+ // line of another length rewraps every item drawn: the item at the top of
72678
+ // the view is held where it is across it.
72679
+ const itemsPerLineRef = useRef(1);
72680
+ const [itemsPerLine, setItemsPerLine] = useState(1);
72681
+ const updateItemsPerLine = () => {
72682
+ if (!ref.current) {
72683
+ return;
72684
+ }
72685
+ const itemsPerLineNow = columns && listItems.hasRuns() ? readItemsPerLine(getListEl()) : 1;
72686
+ if (itemsPerLineNow === null || itemsPerLineNow === itemsPerLineRef.current) {
72687
+ return;
72688
+ }
72689
+ captureAnchor();
72690
+ itemsPerLineRef.current = itemsPerLineNow;
72691
+ setItemsPerLine(itemsPerLineNow);
72692
+ };
72693
+ const updateItemsPerLineRef = useRef(null);
72694
+ updateItemsPerLineRef.current = updateItemsPerLine;
72695
+ useLayoutEffect(() => {
72696
+ if (columns || itemsPerLineRef.current !== 1) {
72697
+ updateItemsPerLine();
72698
+ }
72699
+ });
72700
+
72389
72701
  // What the window holds goes through three stages (see settleWindow):
72390
72702
  // - "first": the first commit, a count of items since nothing is laid out
72391
72703
  // yet to measure a size with — `initial` or the budget when they are one,
@@ -72784,6 +73096,7 @@ const useListScrollSync = ({
72784
73096
  listEl: getListEl(),
72785
73097
  listItems,
72786
73098
  virtualItemSizeSignal,
73099
+ itemsPerLine: itemsPerLineRef.current,
72787
73100
  renderWindowRef,
72788
73101
  horizontal
72789
73102
  });
@@ -73124,20 +73437,14 @@ const useListScrollSync = ({
73124
73437
  }
73125
73438
  const scrollerEl = getScroller();
73126
73439
  const listEl = getListEl();
73127
- const observer = new ResizeObserver(entries => {
73128
- // A box that grows may be a box that starts to scroll — the scroller is
73129
- // resolved again before anything is done about the resize, so that a
73130
- // list which has outgrown a bounded ancestor stops holding on to the
73131
- // page.
73132
- resolveScroller();
73133
- rememberPosition();
73134
- // Two things resize here, and they call for opposite answers. The LIST
73135
- // growing is its own content settling: only a list holding itself
73136
- // somewhere cares (the end it aims at has moved), and a list the user is
73137
- // reading must not be touched — its items are held still by the anchoring,
73138
- // which this would undo. The SCROLLER resizing is the window around it
73139
- // changing shape, and then the item that was at the top goes back where it
73140
- // was.
73440
+ // Two things resize here, and they call for opposite answers. The LIST
73441
+ // growing is its own content settling: only a list holding itself
73442
+ // somewhere cares (the end it aims at has moved), and a list the user is
73443
+ // reading must not be touched — its items are held still by the anchoring,
73444
+ // which this would undo. The SCROLLER resizing is the window around it
73445
+ // changing shape, and then the item that was at the top goes back where it
73446
+ // was.
73447
+ const putTopItemBack = entries => {
73141
73448
  const listResized = entries.some(entry => entry.target === listEl);
73142
73449
  if (listResized && onGeometryChangeRef.current()) {
73143
73450
  return;
@@ -73166,6 +73473,19 @@ const useListScrollSync = ({
73166
73473
  } else {
73167
73474
  scrollerEl.scrollTop += delta;
73168
73475
  }
73476
+ };
73477
+ const observer = new ResizeObserver(entries => {
73478
+ // A box that grows may be a box that starts to scroll — the scroller is
73479
+ // resolved again before anything is done about the resize, so that a
73480
+ // list which has outgrown a bounded ancestor stops holding on to the
73481
+ // page.
73482
+ resolveScroller();
73483
+ rememberPosition();
73484
+ putTopItemBack(entries);
73485
+ // Once the top item is back where it was: that is the place a line of
73486
+ // another length has to keep, and putting it back drops any anchor
73487
+ // captured before.
73488
+ updateItemsPerLineRef.current();
73169
73489
  });
73170
73490
  observer.observe(scrollerEl);
73171
73491
  observer.observe(listEl);
@@ -73321,7 +73641,10 @@ const useListScrollSync = ({
73321
73641
  start,
73322
73642
  end
73323
73643
  } = renderWindowRef.current;
73324
- const itemSize = virtualItemSizeSignal.peek();
73644
+ // What an item not drawn weighs: its share of a line, which the items side
73645
+ // by side on it take together.
73646
+ const itemsPerLineNow = itemsPerLineRef.current;
73647
+ const itemSize = virtualItemSizeSignal.peek() / itemsPerLineNow;
73325
73648
  const pixelsOf = createItemSizeReader(items, start, end, itemSize);
73326
73649
  const walkBefore = (from, sizeWanted, sizeOf) => {
73327
73650
  let index = from;
@@ -73591,9 +73914,28 @@ const useListScrollSync = ({
73591
73914
  ifChanged: true
73592
73915
  });
73593
73916
  }, [windowStart, windowEnd]);
73917
+ // What the runs draw: the window widened to whole lines. The window itself
73918
+ // moves an item at a time, and a grid lays the first item drawn in its first
73919
+ // column — started anywhere but on a line, every item drawn would sit in the
73920
+ // column of another. Started on one, an item is in the column its place says
73921
+ // whatever the window frames. The same object while the numbers hold, so the
73922
+ // runs are not told about a window that did not move.
73923
+ const windowDrawnRef = useRef(null);
73924
+ const drawnStart = windowStart - windowStart % itemsPerLine;
73925
+ const endPastLine = windowEnd % itemsPerLine;
73926
+ const drawnEnd = endPastLine === 0 ? windowEnd : windowEnd + itemsPerLine - endPastLine;
73927
+ let windowDrawn = windowDrawnRef.current;
73928
+ if (!windowDrawn || windowDrawn.start !== drawnStart || windowDrawn.end !== drawnEnd || windowDrawn.itemsPerLine !== itemsPerLine) {
73929
+ windowDrawn = {
73930
+ start: drawnStart,
73931
+ end: drawnEnd,
73932
+ itemsPerLine
73933
+ };
73934
+ windowDrawnRef.current = windowDrawn;
73935
+ }
73594
73936
  return {
73595
73937
  virtualItemSizeSignal,
73596
- renderWindow: renderWindowRef.current,
73938
+ renderWindow: windowDrawn,
73597
73939
  pendingScrollRef,
73598
73940
  scrollToItem,
73599
73941
  captureAnchor
@@ -73617,6 +73959,21 @@ const getScrollerViewportRect = scrollerEl => {
73617
73959
  }
73618
73960
  return scrollerEl.getBoundingClientRect();
73619
73961
  };
73962
+ // The tracks a grid resolved its template to, as its computed
73963
+ // grid-template-columns lists them once laid out: "150px 150px [end] 150px".
73964
+ // Not laid out (a closed popup), that is the template as written, which says
73965
+ // nothing yet under auto-fill: `null`.
73966
+ const readItemsPerLine = listEl => {
73967
+ const tracks = getComputedStyle(listEl).gridTemplateColumns;
73968
+ if (tracks === "none" || tracks.includes("(")) {
73969
+ return null;
73970
+ }
73971
+ const sizes = tracks.replace(/\[[^\]]*\]/g, " ").trim();
73972
+ if (!sizes) {
73973
+ return null;
73974
+ }
73975
+ return sizes.split(/\s+/).length;
73976
+ };
73620
73977
  // A window that cannot move is the one failure of a virtualized list that
73621
73978
  // looks like nothing: the items outside it are fillers holding their room, so
73622
73979
  // the list simply ends on blank space, at exactly the height of the items never
@@ -74419,6 +74776,7 @@ const getScrollInfo = ({
74419
74776
  listEl,
74420
74777
  listItems,
74421
74778
  virtualItemSizeSignal,
74779
+ itemsPerLine,
74422
74780
  renderWindowRef,
74423
74781
  horizontal
74424
74782
  }) => {
@@ -74456,7 +74814,9 @@ const getScrollInfo = ({
74456
74814
  // mean we don't know the real on-screen index, only the scroll position,
74457
74815
  // so estimate from it rather than assume nothing changed.
74458
74816
  const estimateFromScrollPos = reasonPrefix => {
74459
- const virtualItemSize = virtualItemSizeSignal.peek();
74817
+ // An item's share of its line: the items side by side on it take it
74818
+ // together.
74819
+ const virtualItemSize = virtualItemSizeSignal.peek() / itemsPerLine;
74460
74820
  if (virtualItemSize === 0) {
74461
74821
  return null;
74462
74822
  }
@@ -74512,8 +74872,10 @@ const getScrollInfo = ({
74512
74872
  const VIRTUAL_ITEM_SIZE_EPSILON = 0.5;
74513
74873
  // Measures the items currently in the DOM, edge to edge: what a filler stands in
74514
74874
  // for is the room a run of items takes together — separators and group labels
74515
- // included — not the height of one <li>.
74516
- const measureItemSize = (listEl, horizontal) => {
74875
+ // included — not the height of one <li>. The size of a line of them: one item
74876
+ // in a list, the items side by side in a grid (`inLines`), counted where a new
74877
+ // line starts.
74878
+ const measureItemSize = (listEl, horizontal, inLines) => {
74517
74879
  let fromSkeletons = false;
74518
74880
  let itemEls = listEl.querySelectorAll(REAL_LIST_ITEM_SELECTOR);
74519
74881
  if (itemEls.length === 0) {
@@ -74535,13 +74897,27 @@ const measureItemSize = (listEl, horizontal) => {
74535
74897
  if (span <= 0) {
74536
74898
  return null;
74537
74899
  }
74900
+ let lineCount = itemEls.length;
74901
+ if (inLines) {
74902
+ lineCount = 0;
74903
+ let lineFrom = -Infinity;
74904
+ for (const itemEl of itemEls) {
74905
+ const rect = itemEl.getBoundingClientRect();
74906
+ const from = horizontal ? rect.left : rect.top;
74907
+ if (from > lineFrom + 0.5) {
74908
+ lineCount++;
74909
+ lineFrom = from;
74910
+ }
74911
+ }
74912
+ }
74538
74913
  return {
74539
- size: span / itemEls.length,
74540
- itemCount: itemEls.length,
74914
+ size: span / lineCount,
74915
+ lineCount,
74541
74916
  fromSkeletons
74542
74917
  };
74543
74918
  };
74544
74919
  const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74920
+ inLines,
74545
74921
  windowLeavesItemsOut,
74546
74922
  scrolledWanted,
74547
74923
  beforeSizeChange
@@ -74585,8 +74961,8 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74585
74961
  return;
74586
74962
  }
74587
74963
  }
74588
- samples.sum += measure.size * measure.itemCount;
74589
- samples.count += measure.itemCount;
74964
+ samples.sum += measure.size * measure.lineCount;
74965
+ samples.count += measure.lineCount;
74590
74966
  const next = samples.sum / samples.count;
74591
74967
  const current = virtualSizeSignal.peek();
74592
74968
  if (Math.abs(next - current) > VIRTUAL_ITEM_SIZE_EPSILON) {
@@ -74606,7 +74982,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74606
74982
  const itemsHeldOffScreen = windowLeavesItemsOut();
74607
74983
  if (!virtualItemSizeProp && sizeAlreadyKnown && itemsHeldOffScreen && ref.current) {
74608
74984
  const listEl = ref.current.querySelector(".navi_list");
74609
- const measure = listEl ? measureItemSize(listEl, horizontal) : null;
74985
+ const measure = listEl ? measureItemSize(listEl, horizontal, inLines) : null;
74610
74986
  if (measure) {
74611
74987
  feedSample(measure);
74612
74988
  }
@@ -74627,11 +75003,11 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74627
75003
  if (!listEl) {
74628
75004
  return undefined;
74629
75005
  }
74630
- const measure = measureItemSize(listEl, horizontal);
75006
+ const measure = measureItemSize(listEl, horizontal, inLines);
74631
75007
  if (measure) {
74632
75008
  const samples = samplesRef.current;
74633
- samples.sum = measure.size * measure.itemCount;
74634
- samples.count = measure.itemCount;
75009
+ samples.sum = measure.size * measure.lineCount;
75010
+ samples.count = measure.lineCount;
74635
75011
  samples.fromSkeletons = measure.fromSkeletons;
74636
75012
  virtualSizeSignal.value = measure.size;
74637
75013
  return undefined;
@@ -74776,14 +75152,17 @@ const Fallback = ({
74776
75152
  // Reads the item size itself: it is what the size is for, and a run holding
74777
75153
  // every item it draws must not be redrawn — every item of it — because the size
74778
75154
  // settled after the first commit.
75155
+ // It holds lines: the items side by side on one take its room together, and a
75156
+ // last line left short takes it all the same.
74779
75157
  const VirtualFiller = ({
74780
75158
  edge,
74781
75159
  itemCount,
75160
+ itemsPerLine,
74782
75161
  findChunks
74783
75162
  }) => {
74784
75163
  const listItems = useContext(ListItemsContext);
74785
- const virtualItemSize = listItems.virtualItemSizeSignal.value;
74786
- const sizeToFill = itemCount * virtualItemSize;
75164
+ const lineSize = listItems.virtualItemSizeSignal.value;
75165
+ const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize;
74787
75166
  // A filler resizing moves what stands below it — the items on screen, when it
74788
75167
  // holds the room of items above them — and it resizes in a commit of its own
74789
75168
  // when the item size settles after the list has rendered: the list puts its
@@ -74802,7 +75181,7 @@ const VirtualFiller = ({
74802
75181
  "aria-hidden": true,
74803
75182
  style: {
74804
75183
  "--size-to-fill": `${sizeToFill}px`,
74805
- "--x-find-line-size": findChunks ? `${virtualItemSize}px` : undefined
75184
+ "--x-find-line-size": findChunks ? `${lineSize / itemsPerLine}px` : undefined
74806
75185
  },
74807
75186
  children: findChunks && findChunks.map(chunk => jsx("div", {
74808
75187
  className: "navi_list_find_stand_in",
@@ -75682,6 +76061,9 @@ const useRunItems = (store, {
75682
76061
  const rankOf = itemIndex => itemIndex - runStart;
75683
76062
  const indexOfRank = rank => rank + runStart;
75684
76063
  const getItemAt = itemIndex => store.getItem(rankOf(itemIndex));
76064
+ const {
76065
+ itemsPerLine
76066
+ } = renderWindow;
75685
76067
  const windowFrom = renderWindow.start > runStart ? renderWindow.start : runStart;
75686
76068
  const windowTo = renderWindow.end < runEnd ? renderWindow.end : runEnd;
75687
76069
  store.forget(rankOf(windowFrom), rankOf(windowTo));
@@ -75789,8 +76171,20 @@ const useRunItems = (store, {
75789
76171
  // band is drawn among the items. A negative start is not a rank but a count
75790
76172
  // back from the end (the very first ask, before the count is known) — there
75791
76173
  // is no item to convert it to, and the band falls back to the window.
75792
- const failureFrom = store.failure === null ? -1 : store.failure.start < 0 || indexOfRank(store.failure.start) < windowFrom ? windowFrom : indexOfRank(store.failure.start);
75793
- const failureTo = store.failure === null ? -1 : store.failure.end < 0 || indexOfRank(store.failure.end) > windowTo - 1 ? windowTo - 1 : indexOfRank(store.failure.end);
76174
+ let failureFrom = store.failure === null ? -1 : store.failure.start < 0 || indexOfRank(store.failure.start) < windowFrom ? windowFrom : indexOfRank(store.failure.start);
76175
+ let failureTo = store.failure === null ? -1 : store.failure.end < 0 || indexOfRank(store.failure.end) > windowTo - 1 ? windowTo - 1 : indexOfRank(store.failure.end);
76176
+ if (store.failure !== null && itemsPerLine > 1) {
76177
+ // In a grid the band takes whole lines, like the window: begun mid-line,
76178
+ // it would push every item after it into another column.
76179
+ failureFrom -= failureFrom % itemsPerLine;
76180
+ if (failureFrom < windowFrom) {
76181
+ failureFrom = windowFrom;
76182
+ }
76183
+ failureTo += itemsPerLine - 1 - failureTo % itemsPerLine;
76184
+ if (failureTo > windowTo - 1) {
76185
+ failureTo = windowTo - 1;
76186
+ }
76187
+ }
75794
76188
  const nodes = [];
75795
76189
  // Items that belong together, as the data says (a day of messages, a month of
75796
76190
  // games): consecutive items sharing a group key are wrapped in one group, so
@@ -75867,6 +76261,7 @@ const useRunItems = (store, {
75867
76261
  nodes.push(jsx(VirtualFiller, {
75868
76262
  edge: "before",
75869
76263
  itemCount: windowFrom - runStart,
76264
+ itemsPerLine: itemsPerLine,
75870
76265
  findChunks: getFindChunks(runStart, windowFrom)
75871
76266
  }, "navi-list-filler-before"));
75872
76267
  }
@@ -75881,7 +76276,7 @@ const useRunItems = (store, {
75881
76276
  nodes.push(jsx("li", {
75882
76277
  className: "navi_list_failed_items",
75883
76278
  style: {
75884
- "--size-to-fill": `${failedItemCount * listItems.virtualItemSizeSignal.value}px`
76279
+ "--size-to-fill": `${Math.ceil(failedItemCount / itemsPerLine) * listItems.virtualItemSizeSignal.value}px`
75885
76280
  },
75886
76281
  children: renderError ? renderError({
75887
76282
  error: store.failure.error,
@@ -75971,6 +76366,7 @@ const useRunItems = (store, {
75971
76366
  nodes.push(jsx(VirtualFiller, {
75972
76367
  edge: "after",
75973
76368
  itemCount: runEnd - windowTo,
76369
+ itemsPerLine: itemsPerLine,
75974
76370
  findChunks: getFindChunks(windowTo, runEnd)
75975
76371
  }, "navi-list-filler-after"));
75976
76372
  }
@@ -76694,12 +77090,16 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76694
77090
  * @param {string} [props.columns]
76695
77091
  * The list's own columns: a `grid-template-columns` value the ITEMS are laid
76696
77092
  * into — a sheet of icons (`repeat(auto-fill, minmax(2.5rem, 1fr))`), a row of
76697
- * choices (`repeat(3, minmax(0, 1fr))`). Each item takes one cell, and an item
76698
- * meant to take a whole line says so for itself
76699
- * (`style={{ gridColumn: "1 / -1" }}`). Several items to a line is a shape the
76700
- * list cannot virtualize — its render window and the room it holds for the
76701
- * items it does not draw both count one item per line — so it is for a set of
76702
- * items the caller renders whole, not for a `<List.Items>` collection.
77093
+ * choices (`repeat(3, minmax(0, 1fr))`), a feed of covers
77094
+ * (`repeat(auto-fill, minmax(150px, 1fr))` over a `<List.Items>`). Each item
77095
+ * takes one cell, and an item meant to take a whole line says so for itself
77096
+ * (`style={{ gridColumn: "1 / -1" }}`). A `<List.Items>` run is drawn in whole
77097
+ * lines — as many items to a line as the template resolves to, read again
77098
+ * when the list's width changes: its window starts and ends on a line, so an
77099
+ * item is always in the column its place says, and the fillers hold the room
77100
+ * of the lines it does not draw (`virtualItemSize` is then a line's size).
77101
+ * That count holds only while every item of the run takes one cell: an item
77102
+ * spanning a line, or a `groupBy`, puts the ones after it in other columns.
76703
77103
  * @param {string} [props.itemColumns]
76704
77104
  * The columns inside an ITEM: a `grid-template-columns` value each item fills
76705
77105
  * with its own children, a table whose cells line up down the list. Every item
@@ -76715,8 +77115,9 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76715
77115
  * scroll box, which fills it and has nothing to arrange.
76716
77116
  * @param {boolean} [props.flexWrap]
76717
77117
  * Lets a horizontal list's items fall to the next line instead of running
76718
- * past the edge — a row of choices under a `maxWidth`, say. Same caveat as
76719
- * `columns` above: a line holding several items is not virtualizable.
77118
+ * past the edge — a row of choices under a `maxWidth`, say. A wrapped line
77119
+ * holding several items is not virtualizable: a long collection laid several
77120
+ * to a line goes in `columns`.
76720
77121
  * @param {string} [props.overflow]
76721
77122
  * `"visible"` lets the items paint outside the list — a check in an item's
76722
77123
  * corner, a badge crossing the edge. A list clips by default, which is what
@@ -76835,7 +77236,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76835
77236
  * one-line size) — an item guessed too small builds one more, an item
76836
77237
  * guessed too big leaves a blank (see docs/scroll.md, "What the list knows,
76837
77238
  * and what it guesses"). The items drawn are measured either way: the render
76838
- * window is sized on them.
77239
+ * window is sized on them. In a grid (`columns`), the size is a line's: a
77240
+ * card's, which the items side by side on it share.
76839
77241
  * @param {"self"|"parent"|"document"|Element|{current: Element}} [props.scroller="self"]
76840
77242
  * Which box scrolls — and with it, which box the render window follows and
76841
77243
  * which box a scroll position is read from (`onScrolledChange`). `"self"`