@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, prefixFirstAndIndentRemainingLines, 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";
@@ -11240,6 +11240,179 @@ const dropGeneratedIdKeys = (state) => {
11240
11240
  return stateWithoutGeneratedIdKeys;
11241
11241
  };
11242
11242
 
11243
+ const FRAME_TIMEOUT_MS = 100;
11244
+
11245
+ /**
11246
+ * Calls back once the browser has painted what is committed now.
11247
+ *
11248
+ * The callbacks of the next frame run before that frame paints, so the call is
11249
+ * made from a task queued from inside one — the same way preact schedules
11250
+ * useEffect. Unlike an effect, nothing runs this early: preact flushes a
11251
+ * component's pending effects as soon as that component renders again, and a
11252
+ * re-render before the paint is exactly what a caller waiting for the paint
11253
+ * has to survive. A frame not coming at all (a background tab) still answers,
11254
+ * late, through the timeout.
11255
+ *
11256
+ * @param {() => void} callback
11257
+ * @returns {() => void} cancel
11258
+ */
11259
+ const afterPaint = (callback) => {
11260
+ let called = false;
11261
+ let timeoutId;
11262
+ let frameId;
11263
+ const onFrame = () => {
11264
+ if (called) {
11265
+ return;
11266
+ }
11267
+ called = true;
11268
+ clearTimeout(timeoutId);
11269
+ cancelAnimationFrame(frameId);
11270
+ timeoutId = setTimeout(callback, 0);
11271
+ };
11272
+ timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
11273
+ frameId = requestAnimationFrame(onFrame);
11274
+ return () => {
11275
+ called = true;
11276
+ clearTimeout(timeoutId);
11277
+ cancelAnimationFrame(frameId);
11278
+ };
11279
+ };
11280
+
11281
+ /**
11282
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
11283
+ * letting Preact batch it into the next microtask. Layout effects of what gets
11284
+ * mounted run inside the call too, exactly as they would on any other commit.
11285
+ *
11286
+ * For the caller that has to read the DOM it just asked for — measuring an
11287
+ * element whose content it mounts in the same breath — and cannot wait a tick
11288
+ * to do it, because what comes after is a browser event still in flight
11289
+ * (preventDefault, focus placement) that no longer accepts being answered late.
11290
+ *
11291
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
11292
+ * render queue drains; swapping it for "right now" for the duration of the call
11293
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
11294
+ * case above: rendering synchronously in the middle of an event gives up the
11295
+ * batching that makes several state changes one commit.
11296
+ */
11297
+ const flushSyncRendering = (fn) => {
11298
+ const debounceRenderingPrevious = options.debounceRendering;
11299
+ options.debounceRendering = (drainRenderQueue) => {
11300
+ drainRenderQueue();
11301
+ };
11302
+ try {
11303
+ fn();
11304
+ } finally {
11305
+ options.debounceRendering = debounceRenderingPrevious;
11306
+ }
11307
+ };
11308
+
11309
+ /**
11310
+ * The content of a `<Box mount="after-paint">`: built right after the first
11311
+ * frame that shows the box, unless that frame would show it.
11312
+ *
11313
+ * The browser paints nothing until the render that builds a page has ended, and
11314
+ * what lies below the first screen weighs in that render as much as what is on
11315
+ * it. A box marked this way is left empty in that render, then built once the
11316
+ * frame is painted — the page-level twin of a list's render window (see
11317
+ * `renderBudget` in list.jsx), for a page made of sections.
11318
+ *
11319
+ * Which boxes lie below the screen is measured, not taken from the caller. The
11320
+ * caller cannot know: a taller phone, a profile with nothing in its first
11321
+ * section, and the box it marked is on screen. So each box looks where it
11322
+ * stands in the animation callbacks of its first frame — laid out, not yet
11323
+ * painted — and one the frame would show, or one above it, is built there:
11324
+ * content appearing on screen is a pop-in, and content growing ABOVE what is
11325
+ * read pushes it down. Marking too much costs a second render in that frame,
11326
+ * never a wrong picture.
11327
+ *
11328
+ * The document put back where it was read is the other frame that shows it.
11329
+ * That offset is written by navi (see scroll_restoration.js), and written into
11330
+ * a document still missing its bottom it is clamped: the page opens higher than
11331
+ * it was left, and the bottom arrives under a page that has already jumped. So
11332
+ * whoever writes an arrival's offset builds first the boxes that offset shows
11333
+ * (buildDeferredBoxesAbove), in the same task — synchronously, since what comes
11334
+ * right after it (the offset, a route travel measuring its window) reads the
11335
+ * layout.
11336
+ */
11337
+
11338
+ const deferredBoxes = new Set();
11339
+ const MountAfterPaint = ({
11340
+ boxRef,
11341
+ children
11342
+ }) => {
11343
+ const [built, setBuilt] = useState(false);
11344
+ useLayoutEffect(() => {
11345
+ let frame;
11346
+ let cancelAfterPaint;
11347
+ const stopWaiting = () => {
11348
+ deferredBoxes.delete(deferredBox);
11349
+ cancelAnimationFrame(frame);
11350
+ cancelAfterPaint();
11351
+ };
11352
+ // The first of the three that come — the offset of an arrival, the frame
11353
+ // finding the box on screen, the paint — builds it and cancels the others.
11354
+ const deferredBox = {
11355
+ getElement: () => boxRef.current,
11356
+ build: () => {
11357
+ stopWaiting();
11358
+ setBuilt(true);
11359
+ }
11360
+ };
11361
+ deferredBoxes.add(deferredBox);
11362
+ // Requested before afterPaint's own frame callback, so the box is looked at
11363
+ // before that one turns the paint into a build.
11364
+ frame = requestAnimationFrame(() => {
11365
+ const top = readTopInWindow(boxRef.current);
11366
+ if (top !== null && top < window.innerHeight) {
11367
+ deferredBox.build();
11368
+ }
11369
+ });
11370
+ cancelAfterPaint = afterPaint(deferredBox.build);
11371
+ return stopWaiting;
11372
+ }, []);
11373
+ return built ? children : null;
11374
+ };
11375
+
11376
+ /**
11377
+ * Builds, now, every box still waiting whose top is above `bottom` (in the
11378
+ * document's coordinates): what the document will show once scrolled so that
11379
+ * `bottom` is the bottom of the screen. Boxes built push those below them
11380
+ * further down, so the ones measured above `bottom` are all the ones that can
11381
+ * be shown there, and a few more at worst.
11382
+ *
11383
+ * Renders synchronously, so it must be called outside a Preact commit: a render
11384
+ * flushed from a layout effect runs inside the commit still walking the tree.
11385
+ */
11386
+ const buildDeferredBoxesAbove = bottom => {
11387
+ if (deferredBoxes.size === 0) {
11388
+ return;
11389
+ }
11390
+ const toBuild = [];
11391
+ for (const deferredBox of deferredBoxes) {
11392
+ const top = readTopInWindow(deferredBox.getElement());
11393
+ if (top !== null && top + window.scrollY < bottom) {
11394
+ toBuild.push(deferredBox);
11395
+ }
11396
+ }
11397
+ if (toBuild.length === 0) {
11398
+ return;
11399
+ }
11400
+ flushSyncRendering(() => {
11401
+ for (const deferredBox of toBuild) {
11402
+ deferredBox.build();
11403
+ }
11404
+ });
11405
+ };
11406
+
11407
+ // null for a box nothing draws (a closed popup, a hidden tab): no frame shows
11408
+ // it, wherever the document is.
11409
+ const readTopInWindow = element => {
11410
+ if (!element || element.getClientRects().length === 0) {
11411
+ return null;
11412
+ }
11413
+ return element.getBoundingClientRect().top;
11414
+ };
11415
+
11243
11416
  /**
11244
11417
  * A container has put its page on screen — or as much of it as it can.
11245
11418
  *
@@ -11295,7 +11468,9 @@ const [publishRouteRender, observeRouteRender] = createPubSub();
11295
11468
  * What is NOT covered, and cannot be from here: a page whose height depends on
11296
11469
  * something still loading. Its content is not there at the moment it is put
11297
11470
  * back, so a position beyond what has arrived is clamped as before. Only the
11298
- * page knows when it is whole.
11471
+ * page knows when it is whole. What navi itself leaves out of a first render
11472
+ * is the exception — a `<Box mount="after-paint">` — and is built before the
11473
+ * offset that shows it is written (see mount_after_paint.jsx).
11299
11474
  *
11300
11475
  * WHEN a page is arrived at is not decided here either. A document navigation
11301
11476
  * lands where its kind says (see via_history.js), and one scrollport can be
@@ -11444,14 +11619,18 @@ const installScrollRestoration = () => {
11444
11619
  // What a reload asks for, now that the browser has been told not to do it.
11445
11620
  // Once, and at the first render of a route: the position is only meaningful
11446
11621
  // once there is a page under it.
11622
+ // Written once the commit that rendered it has ended: said from inside it,
11623
+ // and what the offset shows may still have to be built (see scrollTo).
11447
11624
  const positionOnLoad = positionByUrl.get(window.location.href);
11448
11625
  if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11449
11626
  const stopListening = observeRouteRender(() => {
11450
11627
  stopListening();
11451
- if (documentHoldCount) {
11452
- return;
11453
- }
11454
- scrollTo(positionOnLoad);
11628
+ queueMicrotask(() => {
11629
+ if (documentHoldCount) {
11630
+ return;
11631
+ }
11632
+ scrollTo(positionOnLoad);
11633
+ });
11455
11634
  });
11456
11635
  }
11457
11636
  };
@@ -11558,14 +11737,25 @@ const forgetScrollersOnArrival = (url, { from } = {}) => {
11558
11737
  // last read. Where this one was read, and its top when it never was — leaving
11559
11738
  // the offset alone would seat the reader wherever the neighbour happened to
11560
11739
  // be, so here "nothing recorded" and "stay" are not the same thing.
11740
+ //
11741
+ // Called outside a Preact commit only, like scrollTo: a row of tabs measures
11742
+ // its window right after, so what the top shows is built first here rather
11743
+ // than in the frame (see mount_after_paint.jsx).
11561
11744
  const arriveAtScrollPosition = (url) => {
11562
11745
  if (restoreScrollPosition(url)) {
11563
11746
  return;
11564
11747
  }
11748
+ buildDeferredBoxesAbove(window.innerHeight);
11565
11749
  startAtTop(url);
11566
11750
  };
11567
11751
 
11752
+ // What the offset shows is built first: written into a document still missing
11753
+ // a deferred box, the offset is clamped (see mount_after_paint.jsx). That build
11754
+ // is a render of its own, so this is never called from inside a commit — a
11755
+ // caller that learns the page is there from a layout effect (observeRouteRender)
11756
+ // writes once that commit has ended.
11568
11757
  const scrollTo = ({ x, y }) => {
11758
+ buildDeferredBoxesAbove(y + window.innerHeight);
11569
11759
  window.scrollTo({ top: y, left: x, behavior: "instant" });
11570
11760
  };
11571
11761
 
@@ -16691,9 +16881,9 @@ const armOutsidePressClose = (
16691
16881
  * `font-family` is deliberately absent: a surface keeps the face of what
16692
16882
  * opened it, so a section written in a display font gets its tooltips in that
16693
16883
  * font. `color`, `font-size` and `font-weight` are absent too — each surface
16694
- * answers those its own way (a popup writes in --navi-popup-color and follows
16695
- * the size of what opened it; a callout reverts to the document's ink and
16696
- * size).
16884
+ * answers those its own way (a popup and a callout write in
16885
+ * --navi-popup-color; a popup follows the size of what opened it, a callout
16886
+ * resets to the document's size).
16697
16887
  *
16698
16888
  * In `@layer navi` so an app that wants one of these back says so on the
16699
16889
  * surface and wins, without having to out-specify anything.
@@ -16923,8 +17113,7 @@ const css$14 = /* css */ `
16923
17113
  --callout-error-color: var(--navi-callout-error-color);
16924
17114
  --callout-neutral-color: var(--navi-callout-neutral-color);
16925
17115
 
16926
- --callout-background-color: white;
16927
- --callout-icon-color: black;
17116
+ --callout-background-color: var(--navi-popup-background-color);
16928
17117
  --callout-padding: 8px;
16929
17118
  --callout-z-index: var(--navi-z-index-callout);
16930
17119
  /* The page's line, said here rather than inherited: the icon and the
@@ -16978,11 +17167,11 @@ const css$14 = /* css */ `
16978
17167
  height: auto; /* User agent reset */
16979
17168
  margin: 0;
16980
17169
  padding: 0; /* User agent reset */
16981
- /* The UA's ink for a [popover] element (CanvasText), not the element's:
16982
- a callout sits inside the element but writes on its own paper. The
16983
- color keywords are re-declared against that same ink in
16984
- navi_css_vars.js (.navi_callout). */
16985
- color: revert;
17170
+ /* A callout sits inside the element but writes on its own paper: the
17171
+ popup's ink, paired with the popup's paper it is drawn on, so the two
17172
+ follow color-scheme and the theme together. The color keywords are
17173
+ re-declared against that same ink in navi_css_vars.js. */
17174
+ color: var(--navi-popup-color);
16986
17175
  font-weight: initial; /* Callout fells disconnected from the element, font weight should be predictible and stable */
16987
17176
  font-size: initial; /* Callout fells disconnected from the element, font size should be predictible and stable */
16988
17177
  line-height: var(
@@ -19294,16 +19483,18 @@ const findBusySource = (field) => {
19294
19483
  // a new interaction is queued behind the run (see the action queue in
19295
19484
  // control_hooks.jsx) rather than refused.
19296
19485
  if (!field.optimistic && boundAction) {
19297
- // The INSTANCE the proxy resolves to right now, not the proxy's own
19298
- // signal: that one is a MIRROR, synced by an effect the settling batch
19299
- // defers — read mid-batch (a state echo carrying the user's event back
19300
- // down, an automatic follow-up), it still says RUNNING for an action
19301
- // that is already over, and the gate would refuse — callout included —
19302
- // for nothing. The resolved instance is the live truth: at that echo it
19303
- // is the instance that just settled, already COMPLETED. And it IS the
19304
- // running one whenever one runs — a non-optimistic control's state
19305
- // cannot move mid-run, this very gate blocks it.
19306
- const liveAction = boundAction.getCurrentAction?.() ?? boundAction;
19486
+ // The instance the run in flight uses, as the render reads it (see
19487
+ // runningActionSignal in control_hooks.jsx): the state a proxy follows can
19488
+ // move mid-run — a popup content unmounting takes the group's value — and
19489
+ // the proxy would resolve to an idle instance. With no run out, the
19490
+ // instance the proxy resolves to right now, never the proxy's own signal:
19491
+ // a MIRROR synced by an effect the settling batch defers, which still says
19492
+ // RUNNING mid-batch (a state echo, an automatic follow-up) for an action
19493
+ // already over. An instance is COMPLETED the moment it settles.
19494
+ const liveAction =
19495
+ field.runningActionSignal.value ??
19496
+ boundAction.getCurrentAction?.() ??
19497
+ boundAction;
19307
19498
  if (liveAction.runningStateSignal.value === RUNNING) {
19308
19499
  return { field, action: liveAction };
19309
19500
  }
@@ -20341,11 +20532,10 @@ const READONLY_CONSTRAINT = {
20341
20532
  name: "readonly",
20342
20533
  messageAttribute: "data-readonly-message",
20343
20534
  check: (field, { intent } = {}) => {
20344
- const readOnly = Boolean(
20345
- field.controlHostProps.readOnly ||
20346
- field.controlHostProps["aria-readonly"] === "true",
20347
- );
20348
- if (!readOnly) {
20535
+ // Not off the rendered `readOnly`/`aria-readonly`: they also carry the run
20536
+ // of the control's own action, a frame behind (see readOnlyWithoutRun in
20537
+ // control_hooks.jsx). That run is BUSY_CONSTRAINT's, which reads it live.
20538
+ if (!field.readOnlyWithoutRun) {
20349
20539
  return null;
20350
20540
  }
20351
20541
 
@@ -30928,6 +31118,7 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30928
31118
  * footer?: boolean,
30929
31119
  * body?: boolean,
30930
31120
  * sticky?: boolean,
31121
+ * mount?: "always" | "after-paint",
30931
31122
  * children?: import("ignore:preact").ComponentChildren,
30932
31123
  * [key: string]: any,
30933
31124
  * }>}
@@ -30976,6 +31167,16 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30976
31167
  * (see docs/interactions.md). A plain box has no wiring of its own: it does
30977
31168
  * nothing with `action` (that is a control's prop), so a click on it is
30978
31169
  * declared here like any other interaction: `interactions={{ click: fn }}`.
31170
+ * @param {"always"|"after-paint"} [mount="always"] When `children` are built.
31171
+ * `"after-paint"` leaves the box empty in the render that first mounts it and
31172
+ * builds them right after the first frame that shows the page, then keeps
31173
+ * them: for the sections of a page that lie below its first screen. navi
31174
+ * measures where the box stands before that frame, and one the frame would
31175
+ * show (or where the document is put back, on a return) is built before the
31176
+ * paint all the same. Until built, what is inside is not in the document:
31177
+ * no id there is found, no named control there is there to hear its own
31178
+ * `signal` (see docs/control_object.md). Only for content nothing reaches
31179
+ * before the first frame (see docs/scroll.md).
30979
31180
  */
30980
31181
  const Box = props => {
30981
31182
  const {
@@ -30983,6 +31184,7 @@ const Box = props => {
30983
31184
  children,
30984
31185
  separator,
30985
31186
  interactions,
31187
+ mount,
30986
31188
  ...computeProps
30987
31189
  } = props;
30988
31190
  const parentBoxFlow = useContext(BoxFlowContext);
@@ -31060,6 +31262,12 @@ const Box = props => {
31060
31262
  children: innerChildren
31061
31263
  });
31062
31264
  }
31265
+ if (mount === "after-paint") {
31266
+ innerChildren = jsx(MountAfterPaint, {
31267
+ boxRef: finalRef,
31268
+ children: innerChildren
31269
+ });
31270
+ }
31063
31271
  return jsx(TagName, {
31064
31272
  ref: finalRef,
31065
31273
  className: innerClassName,
@@ -34630,6 +34838,13 @@ const OLD_BAND_TOP_PROPERTY = "--navi-transition-old-band-top";
34630
34838
  const OLD_BAND_RIGHT_PROPERTY = "--navi-transition-old-band-right";
34631
34839
  const OLD_BAND_BOTTOM_PROPERTY = "--navi-transition-old-band-bottom";
34632
34840
  const OLD_BAND_LEFT_PROPERTY = "--navi-transition-old-band-left";
34841
+ const VIEWPORT_TOP_PROPERTY = "--navi-transition-viewport-top";
34842
+ const VIEWPORT_LEFT_PROPERTY = "--navi-transition-viewport-left";
34843
+ // Worn by the root once the group is held at the window by our numbers alone
34844
+ // (see placeTransitionWindow): the group rule of each movement is written on
34845
+ // it. Read by rules on pseudo-elements only, so wearing it restyles nothing
34846
+ // the document inherits.
34847
+ const WINDOW_PLACED_ATTRIBUTE = "data-navi-transition-window-placed";
34633
34848
  const WINDOW_PROPERTIES = [
34634
34849
  WINDOW_TOP_PROPERTY,
34635
34850
  WINDOW_LEFT_PROPERTY,
@@ -34643,6 +34858,8 @@ const WINDOW_PROPERTIES = [
34643
34858
  OLD_BAND_RIGHT_PROPERTY,
34644
34859
  OLD_BAND_BOTTOM_PROPERTY,
34645
34860
  OLD_BAND_LEFT_PROPERTY,
34861
+ VIEWPORT_TOP_PROPERTY,
34862
+ VIEWPORT_LEFT_PROPERTY,
34646
34863
  ];
34647
34864
 
34648
34865
  // Whose numbers are currently published. The window belongs to the movement
@@ -34650,6 +34867,8 @@ const WINDOW_PROPERTIES = [
34650
34867
  // after another has replaced it must not wipe numbers the new one is standing
34651
34868
  // on.
34652
34869
  let windowOwner = null;
34870
+ // The element the box is captured as, for as long as the window is held.
34871
+ let windowElement = null;
34653
34872
  // Stops re-reading the arriving state's corner, when a movement is on.
34654
34873
  let unfollowArrivingState = null;
34655
34874
 
@@ -34705,6 +34924,10 @@ const holdTransitionWindow = (
34705
34924
  const right =
34706
34925
  rectBefore.right > rectAfter.right ? rectBefore.right : rectAfter.right;
34707
34926
  windowOwner = owner;
34927
+ windowElement = element;
34928
+ // Pictures of a movement this one took over from are placed by the browser
34929
+ // until this one's own pictures exist.
34930
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
34708
34931
  setTransitionValue(WINDOW_TOP_PROPERTY, `${top}px`);
34709
34932
  setTransitionValue(WINDOW_LEFT_PROPERTY, `${left}px`);
34710
34933
  setTransitionValue(WINDOW_WIDTH_PROPERTY, `${right - left}px`);
@@ -34729,6 +34952,33 @@ const holdTransitionWindow = (
34729
34952
  });
34730
34953
  };
34731
34954
 
34955
+ /**
34956
+ * Called once the pictures exist — the view transition's `ready` — with the
34957
+ * name the box was captured under. Where the viewport's corner stands in the
34958
+ * space the pictures are drawn in is the browser's placement of the group
34959
+ * minus our reading of the same element, both taken in this one instant; the
34960
+ * group is held at the window with it from then on (see the top of the file).
34961
+ * A browser that does not hand the group's placement back leaves the group
34962
+ * where it places it, moved to the window by the arriving corner.
34963
+ */
34964
+ const placeTransitionWindow = (owner, groupName) => {
34965
+ if (owner !== windowOwner) {
34966
+ return;
34967
+ }
34968
+ const { transform } = getComputedStyle(
34969
+ document.documentElement,
34970
+ `::view-transition-group(${groupName})`,
34971
+ );
34972
+ if (!transform || transform === "none") {
34973
+ return;
34974
+ }
34975
+ const placement = new DOMMatrixReadOnly(transform);
34976
+ const rect = windowElement.getBoundingClientRect();
34977
+ setTransitionValue(VIEWPORT_TOP_PROPERTY, `${placement.m42 - rect.top}px`);
34978
+ setTransitionValue(VIEWPORT_LEFT_PROPERTY, `${placement.m41 - rect.left}px`);
34979
+ document.documentElement.setAttribute(WINDOW_PLACED_ATTRIBUTE, "");
34980
+ };
34981
+
34732
34982
  // The live layout takes the box back. A discontinuity by construction — the
34733
34983
  // window stands at the held height, the box is at its own — and an invisible
34734
34984
  // one: the page arriving is fully in place, and the strip below it that the
@@ -34738,20 +34988,22 @@ const releaseTransitionWindow = (owner) => {
34738
34988
  return;
34739
34989
  }
34740
34990
  windowOwner = null;
34991
+ windowElement = null;
34741
34992
  if (unfollowArrivingState) {
34742
34993
  unfollowArrivingState();
34743
34994
  }
34995
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
34744
34996
  for (const property of WINDOW_PROPERTIES) {
34745
34997
  removeTransitionValue(property);
34746
34998
  }
34747
34999
  };
34748
35000
 
34749
- // The arriving state's corner, re-read every frame — as often as the browser
34750
- // refreshes the group's placement from the same element (see the top of the
34751
- // file) — and with it the offset outside the box, which carries the window
34752
- // and the corner of the state being left along. Requested, never awaited: the
34753
- // first call runs inside the update callback, where a frame cannot come.
34754
- // Written only when it moved: every write restyles the pictures.
35001
+ // The arriving state's corner, re-read every frame — as often as anything can
35002
+ // move the page arriving (see the top of the file) — and with it the offset
35003
+ // outside the box, which carries the window and the corner of the state being
35004
+ // left along. Requested, never awaited: the first call runs inside the update
35005
+ // callback, where a frame cannot come. Written only when it moved: every write
35006
+ // restyles the pictures.
34755
35007
  const followArrivingState = (element, rectAtHold, heldAt) => {
34756
35008
  if (unfollowArrivingState) {
34757
35009
  unfollowArrivingState();
@@ -35256,6 +35508,13 @@ const css$12 = /* css */`@property --navi-route-transition-leave {
35256
35508
  animation-name: none;
35257
35509
  }
35258
35510
 
35511
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-transition) {
35512
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
35513
+ left: calc(var(--navi-transition-window-left) +
35514
+ var(--navi-transition-viewport-left));
35515
+ transform: none;
35516
+ }
35517
+
35259
35518
  &::view-transition-old(navi-route-transition) {
35260
35519
  top: calc(var(--navi-transition-window-old-top) - var(--navi-transition-window-top));
35261
35520
  left: calc(var(--navi-transition-window-old-left) - var(--navi-transition-window-left));
@@ -36303,6 +36562,8 @@ const beginTransition = ({
36303
36562
  // the only place the silent misconfigurations show. They are all about the
36304
36563
  // same thing — a movement playing on pictures that are not the pages.
36305
36564
  const viewTransitionReady = () => {
36565
+ // Read before anything below writes to the document.
36566
+ placeTransitionWindow(transition, AREA_NAME);
36306
36567
  startTransitionFurniture(transition, {
36307
36568
  duration: transition.duration
36308
36569
  });
@@ -36814,6 +37075,13 @@ const css$11 = /* css */`.navi_route_travel {
36814
37075
  animation-duration: var(--navi-route-travel-duration, .3s);
36815
37076
  animation-name: none;
36816
37077
  }
37078
+
37079
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-travel) {
37080
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
37081
+ left: calc(var(--navi-transition-window-left) +
37082
+ var(--navi-transition-viewport-left));
37083
+ transform: none;
37084
+ }
36817
37085
  }
36818
37086
 
36819
37087
  :root[data-navi-route-travel-held] {
@@ -37192,6 +37460,10 @@ const RouteTravel = ({
37192
37460
  });
37193
37461
  });
37194
37462
  travel.viewTransition = viewTransition;
37463
+ // Subscribed first, so it reads before the scrub below writes anything.
37464
+ viewTransition.ready.then(() => {
37465
+ placeTransitionWindow(travel, TRAVEL_NAME);
37466
+ }, ignoreSkipped);
37195
37467
  if (scrub) {
37196
37468
  // Said only now: the release has to have something to let go of, and the
37197
37469
  // transition did not exist a line above.
@@ -37391,16 +37663,20 @@ const RouteTravel = ({
37391
37663
  // what makes the document tall enough to hold that offset again —
37392
37664
  // and the recording stays deaf until then, over the clamp the swap
37393
37665
  // back makes on the way. Its own deafness rather than the travel's:
37394
- // the travel ends here, and this outlives it by a render.
37666
+ // the travel ends here, and this outlives it by a render. Written
37667
+ // once the commit that says so has ended, as arriveAtScrollPosition
37668
+ // asks.
37395
37669
  const resumeScrollRecording = suspendScrollRecording();
37396
37670
  const stopWatchingRender = observeRouteRender(() => {
37397
37671
  stopWatchingRender();
37398
- const {
37399
- route,
37400
- params
37401
- } = travel.fromPage;
37402
- arriveAtScrollPosition(route.buildUrl(params));
37403
- resumeScrollRecording();
37672
+ queueMicrotask(() => {
37673
+ const {
37674
+ route,
37675
+ params
37676
+ } = travel.fromPage;
37677
+ arriveAtScrollPosition(route.buildUrl(params));
37678
+ resumeScrollRecording();
37679
+ });
37404
37680
  });
37405
37681
  }
37406
37682
  releaseRendering();
@@ -40184,34 +40460,6 @@ registerNaviCommand("--navi-unselect", (source, event) => {
40184
40460
  };
40185
40461
  });
40186
40462
 
40187
- /**
40188
- * Runs `fn` and commits whatever it re-renders before returning, instead of
40189
- * letting Preact batch it into the next microtask. Layout effects of what gets
40190
- * mounted run inside the call too, exactly as they would on any other commit.
40191
- *
40192
- * For the caller that has to read the DOM it just asked for — measuring an
40193
- * element whose content it mounts in the same breath — and cannot wait a tick
40194
- * to do it, because what comes after is a browser event still in flight
40195
- * (preventDefault, focus placement) that no longer accepts being answered late.
40196
- *
40197
- * `options.debounceRendering` is Preact's own hook for deciding *when* the
40198
- * render queue drains; swapping it for "right now" for the duration of the call
40199
- * is exactly how preact/compat implements React's flushSync. Reserve it for the
40200
- * case above: rendering synchronously in the middle of an event gives up the
40201
- * batching that makes several state changes one commit.
40202
- */
40203
- const flushSyncRendering = (fn) => {
40204
- const debounceRenderingPrevious = options.debounceRendering;
40205
- options.debounceRendering = (drainRenderQueue) => {
40206
- drainRenderQueue();
40207
- };
40208
- try {
40209
- fn();
40210
- } finally {
40211
- options.debounceRendering = debounceRenderingPrevious;
40212
- }
40213
- };
40214
-
40215
40463
  /**
40216
40464
  * When a popup builds what it holds, and when it throws it away.
40217
40465
  *
@@ -42534,6 +42782,65 @@ const keptOrFirstDefinedChildUIState = (children, fallbackState, kept) => {
42534
42782
  }
42535
42783
  return kept;
42536
42784
  };
42785
+ // The same claim, for an object: a key whose control is not there cannot be
42786
+ // contradicted by it — a slide not built yet (SlideContainer mount="near"), a
42787
+ // row scrolled out, a page parked. A key of `kept` stays until a control of
42788
+ // that name, there, says otherwise; a value put ON the group replaces the
42789
+ // whole object, which is how a key is dropped on purpose.
42790
+ const keptAndNamedChildUIStates = (children, fallbackState, kept) => {
42791
+ const groupValues =
42792
+ kept !== null && typeof kept === "object" && !Array.isArray(kept)
42793
+ ? { ...kept }
42794
+ : {};
42795
+ return Object.assign(
42796
+ groupValues,
42797
+ readNamedChildUIStates(children, { warnNameless: true }),
42798
+ );
42799
+ };
42800
+ // What the named controls among `children` say, one key each. A nameless
42801
+ // GROUP is a grouping, not a value: it exists to hold its children together
42802
+ // (a WheelGroup sharing navigation, a fieldset-ish cluster) without claiming a
42803
+ // key of its own, so what its controls say is written as if they were here.
42804
+ // Naming it is what turns the same group into one key — see ControlGroup's
42805
+ // `name`. Only what its CONTROLS say: such a grouping is handed the whole
42806
+ // object around it (see distributeChildUIState below) and keeps what they do
42807
+ // not answer for, so merged whole it would carry its siblings' keys back up,
42808
+ // stale.
42809
+ const readNamedChildUIStates = (children, { warnNameless }) => {
42810
+ const values = {};
42811
+ for (const child of children) {
42812
+ const { name, emptyUIState } = child;
42813
+ // A control holding nothing writes its own empty, not a hole: the key is in
42814
+ // the object either way, and what is read from it keeps the shape the
42815
+ // reader was promised (see resolveEmptyUIState).
42816
+ const uiState =
42817
+ child.uiState === undefined && emptyUIState !== undefined
42818
+ ? emptyUIState
42819
+ : child.uiState;
42820
+ if (!name) {
42821
+ if (isNamelessGrouping(child, uiState)) {
42822
+ Object.assign(
42823
+ values,
42824
+ child.keepsUnansweredKeys
42825
+ ? readNamedChildUIStates(child.getChildControllers(), {
42826
+ warnNameless: false,
42827
+ })
42828
+ : uiState,
42829
+ );
42830
+ continue;
42831
+ }
42832
+ if (warnNameless) {
42833
+ console.warn(
42834
+ "A group child is missing a name property, its state won't be included in the group state",
42835
+ child,
42836
+ );
42837
+ }
42838
+ continue;
42839
+ }
42840
+ values[name] = uiState;
42841
+ }
42842
+ return values;
42843
+ };
42537
42844
 
42538
42845
  // Default aggregate/distribute implementations keyed by controlType or stateType.
42539
42846
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
@@ -42603,37 +42910,7 @@ const GROUP_DEFAULTS = {
42603
42910
  childControlFilter: (child) => {
42604
42911
  return child.controlType !== "button" && child.controlType !== "link";
42605
42912
  },
42606
- aggregateChildStates: (children) => {
42607
- const groupValues = {};
42608
- for (const child of children) {
42609
- const { name, emptyUIState } = child;
42610
- // A control holding nothing writes its own empty, not a hole: the key
42611
- // is in the object either way, and what is read from it keeps the shape
42612
- // the reader was promised (see resolveEmptyUIState).
42613
- const uiState =
42614
- child.uiState === undefined && emptyUIState !== undefined
42615
- ? emptyUIState
42616
- : child.uiState;
42617
- if (!name) {
42618
- // A nameless GROUP is a grouping, not a value: it exists to hold its
42619
- // children together (a WheelGroup sharing navigation, a fieldset-ish
42620
- // cluster) without claiming a key of its own, so what it holds is
42621
- // merged in as if its children had been written here. Naming it is
42622
- // what turns the same group into one key — see ControlGroup's `name`.
42623
- if (isNamelessGrouping(child, uiState)) {
42624
- Object.assign(groupValues, uiState);
42625
- continue;
42626
- }
42627
- console.warn(
42628
- "A group child is missing a name property, its state won't be included in the group state",
42629
- child,
42630
- );
42631
- continue;
42632
- }
42633
- groupValues[name] = uiState;
42634
- }
42635
- return groupValues;
42636
- },
42913
+ aggregateChildStates: keptAndNamedChildUIStates,
42637
42914
  distributeChildUIState: (newUIState, child) => {
42638
42915
  const childName = child.name;
42639
42916
  if (
@@ -42657,7 +42934,8 @@ const GROUP_DEFAULTS = {
42657
42934
  return childUIState;
42658
42935
  }
42659
42936
  // Merged in on the way up (see above), so on the way down it takes the
42660
- // whole object and picks out its own keys — the same value it produced.
42937
+ // whole object: its controls pick out their own keys, and it keeps the
42938
+ // rest for those of its controls that are not there yet.
42661
42939
  // Only for a child with no name of its own: the same condition the
42662
42940
  // aggregate applies before merging. A NAMED child holding an object (a
42663
42941
  // `type="object"` picker, say) answers for one key, and the object it is
@@ -43145,6 +43423,11 @@ const useUIGroupStateController = (
43145
43423
  // a picker filling its popup, a value prop) rather than worked out from
43146
43424
  // its children. What it protects is read in onChange.
43147
43425
  stateGivenFromAbove: hasValueProp || hasDefaultValueProp,
43426
+ // Its value carries keys none of its controls answers for (see
43427
+ // keptAndNamedChildUIStates): a group merging it as a nameless
43428
+ // grouping reads its controls instead of its value.
43429
+ keepsUnansweredKeys:
43430
+ resolvedAggregateChildStates === keptAndNamedChildUIStates,
43148
43431
  uiStateSignal,
43149
43432
  wantRequesterButtonState,
43150
43433
  ref,
@@ -43782,6 +44065,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
43782
44065
  ref: realUIStateController.ref,
43783
44066
  uiStateSignal: realUIStateController.uiStateSignal,
43784
44067
  controlHostProps: realUIStateController.controlHostProps,
44068
+ readOnlyWithoutRun: realUIStateController.readOnlyWithoutRun,
43785
44069
  registerChild: (child) => {
43786
44070
  if (!canRegisterAsFacadeChild(child)) {
43787
44071
  return;
@@ -43911,6 +44195,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
43911
44195
  s.controller.ref = realUIStateController.ref;
43912
44196
  s.controller.uiStateSignal = realUIStateController.uiStateSignal;
43913
44197
  s.controller.controlHostProps = realUIStateController.controlHostProps;
44198
+ s.controller.readOnlyWithoutRun =
44199
+ realUIStateController.readOnlyWithoutRun;
43914
44200
  realUIStateController.pushStateDownToFacadeChild =
43915
44201
  s.controller.pushStateDownToChild;
43916
44202
 
@@ -45819,6 +46105,11 @@ const useInteractiveProps = (props, {
45819
46105
  const actionLoading = optimistic ? false : actionRunning;
45820
46106
  const loadingResolved = loadingBase || actionLoading;
45821
46107
  const readOnlyResolved = readOnlyBase || actionLoading;
46108
+ // What READONLY_CONSTRAINT refuses for. The run of the control's own action
46109
+ // is BUSY_CONSTRAINT's, read live: readOnlyResolved is a render snapshot,
46110
+ // still read-only in the tick the run settles — the tick a form's
46111
+ // after-send `--navi-reset` asks the form to empty itself.
46112
+ uiStateController.readOnlyWithoutRun = Boolean(readOnlyBase);
45822
46113
  // Read-only, and what this control opens still opens: reading what is in
45823
46114
  // there changes nothing. Read by READONLY_CONSTRAINT, which lets an
45824
46115
  // interaction that only reads through on it.
@@ -46273,10 +46564,10 @@ const useInteractiveProps = (props, {
46273
46564
  });
46274
46565
  }
46275
46566
  // controlHostProps is a curated subset of props with resolved values applied
46276
- // (e.g. readOnly resolved from context + action loading). The interaction system
46277
- // reads off uiStateController.controlHostProps at runtime (e.g. READONLY_CONSTRAINT
46278
- // checks controlHostProps.readOnly), so pointing the controller at controlHostProps
46279
- // keeps those reads current without any extra bookkeeping.
46567
+ // (e.g. readOnly resolved from context + action loading). The constraints
46568
+ // read off uiStateController.controlHostProps at runtime (e.g. DISABLED_CONSTRAINT,
46569
+ // the readonly reason), so pointing the controller at controlHostProps keeps
46570
+ // those reads current without any extra bookkeeping.
46280
46571
  const firstRender = uiStateController.controlHostProps === undefined;
46281
46572
  uiStateController.controlHostProps = controlHostProps;
46282
46573
  // The action itself, not just what the last render made of it: its running
@@ -53746,7 +54037,8 @@ const useExpandableContext = partName => {
53746
54037
  * other three, intent on the UI part (pointer entering it, focus landing in
53747
54038
  * it) builds the content ahead of the click; `"while-opened"` content is
53748
54039
  * built by a press on it only, and thrown away if that press does not
53749
- * expand it.
54040
+ * expand it. Named controls in content not built are kept by the group
54041
+ * around them (docs/control_object.md, "A control that is not there").
53750
54042
  */
53751
54043
  const Expandable = props => {
53752
54044
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -55332,14 +55624,34 @@ const ButtonConfirm = ({
55332
55624
  }
55333
55625
  return jsxs("span", {
55334
55626
  className: "navi_button_confirm",
55335
- ref: askingRef,
55627
+ ref: askingRef
55628
+ // A place for the focus, so a press anywhere in the question keeps it
55629
+ // inside: WebKit does not focus a pressed button, and a press on
55630
+ // "Confirmer" would otherwise blur toward <body> — a focusout with no
55631
+ // relatedTarget, read below as the person leaving. The callout answers
55632
+ // the same ambiguity the same way.
55633
+ ,
55634
+ tabIndex: -1
55635
+ // "not me, unless you have nothing else": the ladder in the effect above
55636
+ // walks past the question to what it holds.
55637
+ // eslint-disable-next-line react/no-unknown-property
55638
+ ,
55639
+ "navi-autofocus": "last-resort",
55336
55640
  onKeyDown: e => {
55337
55641
  if (e.key === "Escape") {
55338
55642
  cancel(true);
55339
55643
  }
55340
55644
  },
55341
55645
  onFocusOut: e => {
55342
- if (!askingRef.current.contains(e.relatedTarget)) {
55646
+ // Chrome also fires this from the question's own removal, once preact
55647
+ // has cleared the ref: nobody left, and cancel(false) would undo the
55648
+ // focus the answer gives back. That focus cannot go back before the
55649
+ // removal: the button it lands on is mounted by the same commit.
55650
+ const askingEl = askingRef.current;
55651
+ if (!askingEl) {
55652
+ return;
55653
+ }
55654
+ if (!askingEl.contains(e.relatedTarget)) {
55343
55655
  cancel(false);
55344
55656
  }
55345
55657
  },
@@ -60837,12 +61149,19 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
60837
61149
  * would bring alongside (neighbours on the map, along the axes
60838
61150
  * `travelByDrag` opens, the other end of a `loop` included) — and kept from
60839
61151
  * then on. For slides that are costly to build (a grid of a few hundred
60840
- * controls each): opening builds the slide shown and its neighbours rather
61152
+ * items each): opening builds the slide shown and its neighbours rather
60841
61153
  * than all of them, and a jump to a far slide builds that one and none of
60842
61154
  * those in between, which a travel never shows. The box is then as large as
60843
61155
  * the largest slide BUILT so far (see `sizing`), so it grows as a larger one
60844
61156
  * is reached: give it a size of its own when the slides differ.
60845
61157
  * `<Slide mount>` says it for one slide — a heavy one among light ones.
61158
+ * A slide not built has no controls, and a group around the container keeps
61159
+ * their keys for them: what it was handed reaches them once built. What is
61160
+ * shared between slides is written through that group (its `signal`, a
61161
+ * `--navi-update` aimed at it), never into one control's own `signal`, which
61162
+ * nobody reads while that control is not built; and nothing validates a
61163
+ * field before its slide is built. See docs/control_object.md, "A control
61164
+ * that is not there".
60846
61165
  */
60847
61166
  const SlideContainer = ({
60848
61167
  layout = "row",
@@ -70926,44 +71245,6 @@ document.addEventListener(
70926
71245
  { capture: true, passive: true },
70927
71246
  );
70928
71247
 
70929
- const FRAME_TIMEOUT_MS = 100;
70930
-
70931
- /**
70932
- * Calls back once the browser has painted what is committed now.
70933
- *
70934
- * The callbacks of the next frame run before that frame paints, so the call is
70935
- * made from a task queued from inside one — the same way preact schedules
70936
- * useEffect. Unlike an effect, nothing runs this early: preact flushes a
70937
- * component's pending effects as soon as that component renders again, and a
70938
- * re-render before the paint is exactly what a caller waiting for the paint
70939
- * has to survive. A frame not coming at all (a background tab) still answers,
70940
- * late, through the timeout.
70941
- *
70942
- * @param {() => void} callback
70943
- * @returns {() => void} cancel
70944
- */
70945
- const afterPaint = (callback) => {
70946
- let called = false;
70947
- let timeoutId;
70948
- let frameId;
70949
- const onFrame = () => {
70950
- if (called) {
70951
- return;
70952
- }
70953
- called = true;
70954
- clearTimeout(timeoutId);
70955
- cancelAnimationFrame(frameId);
70956
- timeoutId = setTimeout(callback, 0);
70957
- };
70958
- timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
70959
- frameId = requestAnimationFrame(onFrame);
70960
- return () => {
70961
- called = true;
70962
- clearTimeout(timeoutId);
70963
- cancelAnimationFrame(frameId);
70964
- };
70965
- };
70966
-
70967
71248
  const LoadingDotsSvg = () => {
70968
71249
  return jsxs("svg", {
70969
71250
  viewBox: "0 0 200 200",
@@ -72994,7 +73275,7 @@ const css$x = /* css */`@property --list-group-label-height {
72994
73275
  }
72995
73276
  }
72996
73277
 
72997
- .navi_list[navi-box-flow="grid"] > .navi_list_virtual_filler, .navi_list[navi-box-flow="grid"] > .navi_separator {
73278
+ .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 {
72998
73279
  grid-column: 1 / -1;
72999
73280
  }
73000
73281
 
@@ -73371,7 +73652,8 @@ const ListUI = props => {
73371
73652
  listId: rest.id,
73372
73653
  scroller,
73373
73654
  searchText,
73374
- horizontal
73655
+ horizontal,
73656
+ columns
73375
73657
  });
73376
73658
 
73377
73659
  // renderBudget frames the items of a run; a list whose items are all declared
@@ -73686,7 +73968,8 @@ const useListScrollSync = ({
73686
73968
  listId,
73687
73969
  scroller,
73688
73970
  searchText,
73689
- horizontal
73971
+ horizontal,
73972
+ columns
73690
73973
  }) => {
73691
73974
  const debugScroll = useDebugScroll();
73692
73975
  // The items drawn, [start, end) among the list's own. A ref as well as a
@@ -73701,6 +73984,7 @@ const useListScrollSync = ({
73701
73984
  return renderWindow.start > 0 || renderWindow.end < listItems.totalSignal.peek();
73702
73985
  };
73703
73986
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
73987
+ inLines: Boolean(columns),
73704
73988
  windowLeavesItemsOut,
73705
73989
  scrolledWanted: scrolled ?? defaultScrolled,
73706
73990
  // The fillers hold the room of items above the screen at this size: a
@@ -73769,6 +74053,34 @@ const useListScrollSync = ({
73769
74053
  });
73770
74054
  };
73771
74055
 
74056
+ // How many items share a line: the tracks a grid of items (`columns`)
74057
+ // resolves to, one otherwise. A layout result under auto-fill, so it is read
74058
+ // off the list once laid out, at each commit and each resize — for the runs
74059
+ // alone, which draw in lines: items declared one by one are all drawn. A
74060
+ // line of another length rewraps every item drawn: the item at the top of
74061
+ // the view is held where it is across it.
74062
+ const itemsPerLineRef = useRef(1);
74063
+ const [itemsPerLine, setItemsPerLine] = useState(1);
74064
+ const updateItemsPerLine = () => {
74065
+ if (!ref.current) {
74066
+ return;
74067
+ }
74068
+ const itemsPerLineNow = columns && listItems.hasRuns() ? readItemsPerLine(getListEl()) : 1;
74069
+ if (itemsPerLineNow === null || itemsPerLineNow === itemsPerLineRef.current) {
74070
+ return;
74071
+ }
74072
+ captureAnchor();
74073
+ itemsPerLineRef.current = itemsPerLineNow;
74074
+ setItemsPerLine(itemsPerLineNow);
74075
+ };
74076
+ const updateItemsPerLineRef = useRef(null);
74077
+ updateItemsPerLineRef.current = updateItemsPerLine;
74078
+ useLayoutEffect(() => {
74079
+ if (columns || itemsPerLineRef.current !== 1) {
74080
+ updateItemsPerLine();
74081
+ }
74082
+ });
74083
+
73772
74084
  // What the window holds goes through three stages (see settleWindow):
73773
74085
  // - "first": the first commit, a count of items since nothing is laid out
73774
74086
  // yet to measure a size with — `initial` or the budget when they are one,
@@ -74170,6 +74482,7 @@ const useListScrollSync = ({
74170
74482
  listEl: getListEl(),
74171
74483
  listItems,
74172
74484
  virtualItemSizeSignal,
74485
+ itemsPerLine: itemsPerLineRef.current,
74173
74486
  renderWindowRef,
74174
74487
  horizontal
74175
74488
  });
@@ -74510,20 +74823,14 @@ const useListScrollSync = ({
74510
74823
  }
74511
74824
  const scrollerEl = getScroller();
74512
74825
  const listEl = getListEl();
74513
- const observer = new ResizeObserver(entries => {
74514
- // A box that grows may be a box that starts to scroll — the scroller is
74515
- // resolved again before anything is done about the resize, so that a
74516
- // list which has outgrown a bounded ancestor stops holding on to the
74517
- // page.
74518
- resolveScroller();
74519
- rememberPosition();
74520
- // Two things resize here, and they call for opposite answers. The LIST
74521
- // growing is its own content settling: only a list holding itself
74522
- // somewhere cares (the end it aims at has moved), and a list the user is
74523
- // reading must not be touched — its items are held still by the anchoring,
74524
- // which this would undo. The SCROLLER resizing is the window around it
74525
- // changing shape, and then the item that was at the top goes back where it
74526
- // was.
74826
+ // Two things resize here, and they call for opposite answers. The LIST
74827
+ // growing is its own content settling: only a list holding itself
74828
+ // somewhere cares (the end it aims at has moved), and a list the user is
74829
+ // reading must not be touched — its items are held still by the anchoring,
74830
+ // which this would undo. The SCROLLER resizing is the window around it
74831
+ // changing shape, and then the item that was at the top goes back where it
74832
+ // was.
74833
+ const putTopItemBack = entries => {
74527
74834
  const listResized = entries.some(entry => entry.target === listEl);
74528
74835
  if (listResized && onGeometryChangeRef.current()) {
74529
74836
  return;
@@ -74552,6 +74859,19 @@ const useListScrollSync = ({
74552
74859
  } else {
74553
74860
  scrollerEl.scrollTop += delta;
74554
74861
  }
74862
+ };
74863
+ const observer = new ResizeObserver(entries => {
74864
+ // A box that grows may be a box that starts to scroll — the scroller is
74865
+ // resolved again before anything is done about the resize, so that a
74866
+ // list which has outgrown a bounded ancestor stops holding on to the
74867
+ // page.
74868
+ resolveScroller();
74869
+ rememberPosition();
74870
+ putTopItemBack(entries);
74871
+ // Once the top item is back where it was: that is the place a line of
74872
+ // another length has to keep, and putting it back drops any anchor
74873
+ // captured before.
74874
+ updateItemsPerLineRef.current();
74555
74875
  });
74556
74876
  observer.observe(scrollerEl);
74557
74877
  observer.observe(listEl);
@@ -74707,7 +75027,10 @@ const useListScrollSync = ({
74707
75027
  start,
74708
75028
  end
74709
75029
  } = renderWindowRef.current;
74710
- const itemSize = virtualItemSizeSignal.peek();
75030
+ // What an item not drawn weighs: its share of a line, which the items side
75031
+ // by side on it take together.
75032
+ const itemsPerLineNow = itemsPerLineRef.current;
75033
+ const itemSize = virtualItemSizeSignal.peek() / itemsPerLineNow;
74711
75034
  const pixelsOf = createItemSizeReader(items, start, end, itemSize);
74712
75035
  const walkBefore = (from, sizeWanted, sizeOf) => {
74713
75036
  let index = from;
@@ -74775,10 +75098,10 @@ const useListScrollSync = ({
74775
75098
  const behindSize = countsItems ? Math.floor(spare / 4) : spare / 4;
74776
75099
  const aheadSize = spare - behindSize;
74777
75100
  if (stage === "steady" && !budgetWarnedRef.current && bandEnd > bandStart) {
74778
- const twoItems = countsItems ? 2 : 2 * (bandTo - bandFrom) / (bandEnd - bandStart);
74779
- if (spare < twoItems) {
75101
+ const twoLines = (countsItems ? 2 : 2 * (bandTo - bandFrom) / (bandEnd - bandStart)) * itemsPerLineNow;
75102
+ if (spare < twoLines) {
74780
75103
  budgetWarnedRef.current = true;
74781
- console.warn(`List: renderBudget=${renderBudget.value}${renderBudget.unit} leaves less than two items beyond what the screen shows (${bandEnd - bandStart} items): items will go blank as it scrolls. Give it room for a screen ahead or more — "300%" of the box that scrolls it, say.`);
75104
+ console.warn(`List: renderBudget=${renderBudget.value}${renderBudget.unit} leaves less than two ${itemsPerLineNow > 1 ? "lines of items" : "items"} beyond what the screen shows (${bandEnd - bandStart} items): items will go blank as it scrolls. Give it room for a screen ahead or more — "300%" of the box that scrolls it, say.`);
74782
75105
  }
74783
75106
  }
74784
75107
  if (!force) {
@@ -74984,9 +75307,28 @@ const useListScrollSync = ({
74984
75307
  ifChanged: true
74985
75308
  });
74986
75309
  }, [windowStart, windowEnd]);
75310
+ // What the runs draw: the window widened to whole lines. The window itself
75311
+ // moves an item at a time, and a grid lays the first item drawn in its first
75312
+ // column — started anywhere but on a line, every item drawn would sit in the
75313
+ // column of another. Started on one, an item is in the column its place says
75314
+ // whatever the window frames. The same object while the numbers hold, so the
75315
+ // runs are not told about a window that did not move.
75316
+ const windowDrawnRef = useRef(null);
75317
+ const drawnStart = windowStart - windowStart % itemsPerLine;
75318
+ const endPastLine = windowEnd % itemsPerLine;
75319
+ const drawnEnd = endPastLine === 0 ? windowEnd : windowEnd + itemsPerLine - endPastLine;
75320
+ let windowDrawn = windowDrawnRef.current;
75321
+ if (!windowDrawn || windowDrawn.start !== drawnStart || windowDrawn.end !== drawnEnd || windowDrawn.itemsPerLine !== itemsPerLine) {
75322
+ windowDrawn = {
75323
+ start: drawnStart,
75324
+ end: drawnEnd,
75325
+ itemsPerLine
75326
+ };
75327
+ windowDrawnRef.current = windowDrawn;
75328
+ }
74987
75329
  return {
74988
75330
  virtualItemSizeSignal,
74989
- renderWindow: renderWindowRef.current,
75331
+ renderWindow: windowDrawn,
74990
75332
  pendingScrollRef,
74991
75333
  scrollToItem,
74992
75334
  captureAnchor
@@ -75010,6 +75352,21 @@ const getScrollerViewportRect = scrollerEl => {
75010
75352
  }
75011
75353
  return scrollerEl.getBoundingClientRect();
75012
75354
  };
75355
+ // The tracks a grid resolved its template to, as its computed
75356
+ // grid-template-columns lists them once laid out: "150px 150px [end] 150px".
75357
+ // Not laid out (a closed popup), that is the template as written, which says
75358
+ // nothing yet under auto-fill: `null`.
75359
+ const readItemsPerLine = listEl => {
75360
+ const tracks = getComputedStyle(listEl).gridTemplateColumns;
75361
+ if (tracks === "none" || tracks.includes("(")) {
75362
+ return null;
75363
+ }
75364
+ const sizes = tracks.replace(/\[[^\]]*\]/g, " ").trim();
75365
+ if (!sizes) {
75366
+ return null;
75367
+ }
75368
+ return sizes.split(/\s+/).length;
75369
+ };
75013
75370
  // A window that cannot move is the one failure of a virtualized list that
75014
75371
  // looks like nothing: the items outside it are fillers holding their room, so
75015
75372
  // the list simply ends on blank space, at exactly the height of the items never
@@ -75897,6 +76254,7 @@ const getScrollInfo = ({
75897
76254
  listEl,
75898
76255
  listItems,
75899
76256
  virtualItemSizeSignal,
76257
+ itemsPerLine,
75900
76258
  renderWindowRef,
75901
76259
  horizontal
75902
76260
  }) => {
@@ -75934,7 +76292,9 @@ const getScrollInfo = ({
75934
76292
  // mean we don't know the real on-screen index, only the scroll position,
75935
76293
  // so estimate from it rather than assume nothing changed.
75936
76294
  const estimateFromScrollPos = reasonPrefix => {
75937
- const virtualItemSize = virtualItemSizeSignal.peek();
76295
+ // An item's share of its line: the items side by side on it take it
76296
+ // together.
76297
+ const virtualItemSize = virtualItemSizeSignal.peek() / itemsPerLine;
75938
76298
  if (virtualItemSize === 0) {
75939
76299
  return null;
75940
76300
  }
@@ -75990,8 +76350,10 @@ const getScrollInfo = ({
75990
76350
  const VIRTUAL_ITEM_SIZE_EPSILON = 0.5;
75991
76351
  // Measures the items currently in the DOM, edge to edge: what a filler stands in
75992
76352
  // for is the room a run of items takes together — separators and group labels
75993
- // included — not the height of one <li>.
75994
- const measureItemSize = (listEl, horizontal) => {
76353
+ // included — not the height of one <li>. The size of a line of them: one item
76354
+ // in a list, the items side by side in a grid (`inLines`), counted where a new
76355
+ // line starts.
76356
+ const measureItemSize = (listEl, horizontal, inLines) => {
75995
76357
  let fromSkeletons = false;
75996
76358
  let itemEls = listEl.querySelectorAll(REAL_LIST_ITEM_SELECTOR);
75997
76359
  if (itemEls.length === 0) {
@@ -76013,13 +76375,27 @@ const measureItemSize = (listEl, horizontal) => {
76013
76375
  if (span <= 0) {
76014
76376
  return null;
76015
76377
  }
76378
+ let lineCount = itemEls.length;
76379
+ if (inLines) {
76380
+ lineCount = 0;
76381
+ let lineFrom = -Infinity;
76382
+ for (const itemEl of itemEls) {
76383
+ const rect = itemEl.getBoundingClientRect();
76384
+ const from = horizontal ? rect.left : rect.top;
76385
+ if (from > lineFrom + 0.5) {
76386
+ lineCount++;
76387
+ lineFrom = from;
76388
+ }
76389
+ }
76390
+ }
76016
76391
  return {
76017
- size: span / itemEls.length,
76018
- itemCount: itemEls.length,
76392
+ size: span / lineCount,
76393
+ lineCount,
76019
76394
  fromSkeletons
76020
76395
  };
76021
76396
  };
76022
76397
  const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
76398
+ inLines,
76023
76399
  windowLeavesItemsOut,
76024
76400
  scrolledWanted,
76025
76401
  beforeSizeChange
@@ -76063,8 +76439,8 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
76063
76439
  return;
76064
76440
  }
76065
76441
  }
76066
- samples.sum += measure.size * measure.itemCount;
76067
- samples.count += measure.itemCount;
76442
+ samples.sum += measure.size * measure.lineCount;
76443
+ samples.count += measure.lineCount;
76068
76444
  const next = samples.sum / samples.count;
76069
76445
  const current = virtualSizeSignal.peek();
76070
76446
  if (Math.abs(next - current) > VIRTUAL_ITEM_SIZE_EPSILON) {
@@ -76084,7 +76460,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
76084
76460
  const itemsHeldOffScreen = windowLeavesItemsOut();
76085
76461
  if (!virtualItemSizeProp && sizeAlreadyKnown && itemsHeldOffScreen && ref.current) {
76086
76462
  const listEl = ref.current.querySelector(".navi_list");
76087
- const measure = listEl ? measureItemSize(listEl, horizontal) : null;
76463
+ const measure = listEl ? measureItemSize(listEl, horizontal, inLines) : null;
76088
76464
  if (measure) {
76089
76465
  feedSample(measure);
76090
76466
  }
@@ -76105,11 +76481,11 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
76105
76481
  if (!listEl) {
76106
76482
  return undefined;
76107
76483
  }
76108
- const measure = measureItemSize(listEl, horizontal);
76484
+ const measure = measureItemSize(listEl, horizontal, inLines);
76109
76485
  if (measure) {
76110
76486
  const samples = samplesRef.current;
76111
- samples.sum = measure.size * measure.itemCount;
76112
- samples.count = measure.itemCount;
76487
+ samples.sum = measure.size * measure.lineCount;
76488
+ samples.count = measure.lineCount;
76113
76489
  samples.fromSkeletons = measure.fromSkeletons;
76114
76490
  virtualSizeSignal.value = measure.size;
76115
76491
  return undefined;
@@ -76254,14 +76630,17 @@ const Fallback = ({
76254
76630
  // Reads the item size itself: it is what the size is for, and a run holding
76255
76631
  // every item it draws must not be redrawn — every item of it — because the size
76256
76632
  // settled after the first commit.
76633
+ // It holds lines: the items side by side on one take its room together, and a
76634
+ // last line left short takes it all the same.
76257
76635
  const VirtualFiller = ({
76258
76636
  edge,
76259
76637
  itemCount,
76638
+ itemsPerLine,
76260
76639
  findChunks
76261
76640
  }) => {
76262
76641
  const listItems = useContext(ListItemsContext);
76263
- const virtualItemSize = listItems.virtualItemSizeSignal.value;
76264
- const sizeToFill = itemCount * virtualItemSize;
76642
+ const lineSize = listItems.virtualItemSizeSignal.value;
76643
+ const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize;
76265
76644
  // A filler resizing moves what stands below it — the items on screen, when it
76266
76645
  // holds the room of items above them — and it resizes in a commit of its own
76267
76646
  // when the item size settles after the list has rendered: the list puts its
@@ -76280,7 +76659,7 @@ const VirtualFiller = ({
76280
76659
  "aria-hidden": true,
76281
76660
  style: {
76282
76661
  "--size-to-fill": `${sizeToFill}px`,
76283
- "--x-find-line-size": findChunks ? `${virtualItemSize}px` : undefined
76662
+ "--x-find-line-size": findChunks ? `${lineSize / itemsPerLine}px` : undefined
76284
76663
  },
76285
76664
  children: findChunks && findChunks.map(chunk => jsx("div", {
76286
76665
  className: "navi_list_find_stand_in",
@@ -77163,6 +77542,9 @@ const useRunItems = (store, {
77163
77542
  const rankOf = itemIndex => itemIndex - runStart;
77164
77543
  const indexOfRank = rank => rank + runStart;
77165
77544
  const getItemAt = itemIndex => store.getItem(rankOf(itemIndex));
77545
+ const {
77546
+ itemsPerLine
77547
+ } = renderWindow;
77166
77548
  const windowFrom = renderWindow.start > runStart ? renderWindow.start : runStart;
77167
77549
  const windowTo = renderWindow.end < runEnd ? renderWindow.end : runEnd;
77168
77550
  store.forget(rankOf(windowFrom), rankOf(windowTo));
@@ -77270,8 +77652,20 @@ const useRunItems = (store, {
77270
77652
  // band is drawn among the items. A negative start is not a rank but a count
77271
77653
  // back from the end (the very first ask, before the count is known) — there
77272
77654
  // is no item to convert it to, and the band falls back to the window.
77273
- const failureFrom = store.failure === null ? -1 : store.failure.start < 0 || indexOfRank(store.failure.start) < windowFrom ? windowFrom : indexOfRank(store.failure.start);
77274
- const failureTo = store.failure === null ? -1 : store.failure.end < 0 || indexOfRank(store.failure.end) > windowTo - 1 ? windowTo - 1 : indexOfRank(store.failure.end);
77655
+ let failureFrom = store.failure === null ? -1 : store.failure.start < 0 || indexOfRank(store.failure.start) < windowFrom ? windowFrom : indexOfRank(store.failure.start);
77656
+ let failureTo = store.failure === null ? -1 : store.failure.end < 0 || indexOfRank(store.failure.end) > windowTo - 1 ? windowTo - 1 : indexOfRank(store.failure.end);
77657
+ if (store.failure !== null && itemsPerLine > 1) {
77658
+ // In a grid the band takes whole lines, like the window: begun mid-line,
77659
+ // it would push every item after it into another column.
77660
+ failureFrom -= failureFrom % itemsPerLine;
77661
+ if (failureFrom < windowFrom) {
77662
+ failureFrom = windowFrom;
77663
+ }
77664
+ failureTo += itemsPerLine - 1 - failureTo % itemsPerLine;
77665
+ if (failureTo > windowTo - 1) {
77666
+ failureTo = windowTo - 1;
77667
+ }
77668
+ }
77275
77669
  const nodes = [];
77276
77670
  // Items that belong together, as the data says (a day of messages, a month of
77277
77671
  // games): consecutive items sharing a group key are wrapped in one group, so
@@ -77348,6 +77742,7 @@ const useRunItems = (store, {
77348
77742
  nodes.push(jsx(VirtualFiller, {
77349
77743
  edge: "before",
77350
77744
  itemCount: windowFrom - runStart,
77745
+ itemsPerLine: itemsPerLine,
77351
77746
  findChunks: getFindChunks(runStart, windowFrom)
77352
77747
  }, "navi-list-filler-before"));
77353
77748
  }
@@ -77362,7 +77757,7 @@ const useRunItems = (store, {
77362
77757
  nodes.push(jsx("li", {
77363
77758
  className: "navi_list_failed_items",
77364
77759
  style: {
77365
- "--size-to-fill": `${failedItemCount * listItems.virtualItemSizeSignal.value}px`
77760
+ "--size-to-fill": `${Math.ceil(failedItemCount / itemsPerLine) * listItems.virtualItemSizeSignal.value}px`
77366
77761
  },
77367
77762
  children: renderError ? renderError({
77368
77763
  error: store.failure.error,
@@ -77452,6 +77847,7 @@ const useRunItems = (store, {
77452
77847
  nodes.push(jsx(VirtualFiller, {
77453
77848
  edge: "after",
77454
77849
  itemCount: runEnd - windowTo,
77850
+ itemsPerLine: itemsPerLine,
77455
77851
  findChunks: getFindChunks(windowTo, runEnd)
77456
77852
  }, "navi-list-filler-after"));
77457
77853
  }
@@ -78184,12 +78580,16 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
78184
78580
  * @param {string} [props.columns]
78185
78581
  * The list's own columns: a `grid-template-columns` value the ITEMS are laid
78186
78582
  * into — a sheet of icons (`repeat(auto-fill, minmax(2.5rem, 1fr))`), a row of
78187
- * choices (`repeat(3, minmax(0, 1fr))`). Each item takes one cell, and an item
78188
- * meant to take a whole line says so for itself
78189
- * (`style={{ gridColumn: "1 / -1" }}`). Several items to a line is a shape the
78190
- * list cannot virtualize — its render window and the room it holds for the
78191
- * items it does not draw both count one item per line — so it is for a set of
78192
- * items the caller renders whole, not for a `<List.Items>` collection.
78583
+ * choices (`repeat(3, minmax(0, 1fr))`), a feed of covers
78584
+ * (`repeat(auto-fill, minmax(150px, 1fr))` over a `<List.Items>`). Each item
78585
+ * takes one cell, and an item meant to take a whole line says so for itself
78586
+ * (`style={{ gridColumn: "1 / -1" }}`). A `<List.Items>` run is drawn in whole
78587
+ * lines — as many items to a line as the template resolves to, read again
78588
+ * when the list's width changes: its window starts and ends on a line, so an
78589
+ * item is always in the column its place says, and the fillers hold the room
78590
+ * of the lines it does not draw (`virtualItemSize` is then a line's size).
78591
+ * That count holds only while every item of the run takes one cell: an item
78592
+ * spanning a line, or a `groupBy`, puts the ones after it in other columns.
78193
78593
  * @param {string} [props.itemColumns]
78194
78594
  * The columns inside an ITEM: a `grid-template-columns` value each item fills
78195
78595
  * with its own children, a table whose cells line up down the list. Every item
@@ -78205,8 +78605,9 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
78205
78605
  * scroll box, which fills it and has nothing to arrange.
78206
78606
  * @param {boolean} [props.flexWrap]
78207
78607
  * Lets a horizontal list's items fall to the next line instead of running
78208
- * past the edge — a row of choices under a `maxWidth`, say. Same caveat as
78209
- * `columns` above: a line holding several items is not virtualizable.
78608
+ * past the edge — a row of choices under a `maxWidth`, say. A wrapped line
78609
+ * holding several items is not virtualizable: a long collection laid several
78610
+ * to a line goes in `columns`.
78210
78611
  * @param {string} [props.overflow]
78211
78612
  * `"visible"` lets the items paint outside the list — a check in an item's
78212
78613
  * corner, a badge crossing the edge. A list clips by default, which is what
@@ -78325,7 +78726,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
78325
78726
  * one-line size) — an item guessed too small builds one more, an item
78326
78727
  * guessed too big leaves a blank (see docs/scroll.md, "What the list knows,
78327
78728
  * and what it guesses"). The items drawn are measured either way: the render
78328
- * window is sized on them.
78729
+ * window is sized on them. In a grid (`columns`), the size is a line's: a
78730
+ * card's, which the items side by side on it share.
78329
78731
  * @param {"self"|"parent"|"document"|Element|{current: Element}} [props.scroller="self"]
78330
78732
  * Which box scrolls — and with it, which box the render window follows and
78331
78733
  * which box a scroll position is read from (`onScrolledChange`). `"self"`