@jsenv/navi 0.29.426 → 0.29.428

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.
@@ -16238,9 +16238,9 @@ const armOutsidePressClose = (
16238
16238
  * `font-family` is deliberately absent: a surface keeps the face of what
16239
16239
  * opened it, so a section written in a display font gets its tooltips in that
16240
16240
  * font. `color`, `font-size` and `font-weight` are absent too — each surface
16241
- * answers those its own way (a popup writes in --navi-popup-color and follows
16242
- * the size of what opened it; a callout reverts to the document's ink and
16243
- * 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).
16244
16244
  *
16245
16245
  * In `@layer navi` so an app that wants one of these back says so on the
16246
16246
  * surface and wins, without having to out-specify anything.
@@ -16470,8 +16470,7 @@ const css$14 = /* css */ `
16470
16470
  --callout-error-color: var(--navi-callout-error-color);
16471
16471
  --callout-neutral-color: var(--navi-callout-neutral-color);
16472
16472
 
16473
- --callout-background-color: white;
16474
- --callout-icon-color: black;
16473
+ --callout-background-color: var(--navi-popup-background-color);
16475
16474
  --callout-padding: 8px;
16476
16475
  --callout-z-index: var(--navi-z-index-callout);
16477
16476
  /* The page's line, said here rather than inherited: the icon and the
@@ -16525,11 +16524,11 @@ const css$14 = /* css */ `
16525
16524
  height: auto; /* User agent reset */
16526
16525
  margin: 0;
16527
16526
  padding: 0; /* User agent reset */
16528
- /* The UA's ink for a [popover] element (CanvasText), not the element's:
16529
- a callout sits inside the element but writes on its own paper. The
16530
- color keywords are re-declared against that same ink in
16531
- navi_css_vars.js (.navi_callout). */
16532
- 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);
16533
16532
  font-weight: initial; /* Callout fells disconnected from the element, font weight should be predictible and stable */
16534
16533
  font-size: initial; /* Callout fells disconnected from the element, font size should be predictible and stable */
16535
16534
  line-height: var(
@@ -18752,16 +18751,18 @@ const findBusySource = (field) => {
18752
18751
  // a new interaction is queued behind the run (see the action queue in
18753
18752
  // control_hooks.jsx) rather than refused.
18754
18753
  if (!field.optimistic && boundAction) {
18755
- // The INSTANCE the proxy resolves to right now, not the proxy's own
18756
- // signal: that one is a MIRROR, synced by an effect the settling batch
18757
- // defers — read mid-batch (a state echo carrying the user's event back
18758
- // down, an automatic follow-up), it still says RUNNING for an action
18759
- // that is already over, and the gate would refuse — callout included —
18760
- // for nothing. The resolved instance is the live truth: at that echo it
18761
- // is the instance that just settled, already COMPLETED. And it IS the
18762
- // running one whenever one runs — a non-optimistic control's state
18763
- // cannot move mid-run, this very gate blocks it.
18764
- 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;
18765
18766
  if (liveAction.runningStateSignal.value === RUNNING) {
18766
18767
  return { field, action: liveAction };
18767
18768
  }
@@ -19799,11 +19800,10 @@ const READONLY_CONSTRAINT = {
19799
19800
  name: "readonly",
19800
19801
  messageAttribute: "data-readonly-message",
19801
19802
  check: (field, { intent } = {}) => {
19802
- const readOnly = Boolean(
19803
- field.controlHostProps.readOnly ||
19804
- field.controlHostProps["aria-readonly"] === "true",
19805
- );
19806
- 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) {
19807
19807
  return null;
19808
19808
  }
19809
19809
 
@@ -25978,10 +25978,11 @@ const createRangeReader = (
25978
25978
  });
25979
25979
  return { byIndex, count: composition.count };
25980
25980
  };
25981
- // `replace` is a revalidation: the ranks that are not in what just came back
25982
- // stood for a composition that has moved on. Otherwise the ranks are merged,
25983
- // so two lists reading the same collection through their own windows add up
25984
- // to one composition instead of taking turns erasing each other.
25981
+ // `replace` says the list dropped ranks it held (an item the collection moved
25982
+ // elsewhere, ranks past its end): kept here, they would come back at the next
25983
+ // mount. Otherwise the ranks are merged, so two lists reading the same
25984
+ // collection through their own windows add up to one composition instead of
25985
+ // taking turns erasing each other.
25985
25986
  readRange.writeComposition = ({ byIndex, count, replace }) => {
25986
25987
  const params = currentParams();
25987
25988
  let composition = findComposition(params);
@@ -42988,6 +42989,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
42988
42989
  ref: realUIStateController.ref,
42989
42990
  uiStateSignal: realUIStateController.uiStateSignal,
42990
42991
  controlHostProps: realUIStateController.controlHostProps,
42992
+ readOnlyWithoutRun: realUIStateController.readOnlyWithoutRun,
42991
42993
  registerChild: (child) => {
42992
42994
  if (!canRegisterAsFacadeChild(child)) {
42993
42995
  return;
@@ -43116,6 +43118,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
43116
43118
  s.controller.ref = realUIStateController.ref;
43117
43119
  s.controller.uiStateSignal = realUIStateController.uiStateSignal;
43118
43120
  s.controller.controlHostProps = realUIStateController.controlHostProps;
43121
+ s.controller.readOnlyWithoutRun =
43122
+ realUIStateController.readOnlyWithoutRun;
43119
43123
  realUIStateController.pushStateDownToFacadeChild =
43120
43124
  s.controller.pushStateDownToChild;
43121
43125
 
@@ -44922,6 +44926,11 @@ const useInteractiveProps = (props, {
44922
44926
  const actionLoading = optimistic ? false : actionRunning;
44923
44927
  const loadingResolved = loadingBase || actionLoading;
44924
44928
  const readOnlyResolved = readOnlyBase || actionLoading;
44929
+ // What READONLY_CONSTRAINT refuses for. The run of the control's own action
44930
+ // is BUSY_CONSTRAINT's, read live: readOnlyResolved is a render snapshot,
44931
+ // still read-only in the tick the run settles — the tick a form's
44932
+ // after-send `--navi-reset` asks the form to empty itself.
44933
+ uiStateController.readOnlyWithoutRun = Boolean(readOnlyBase);
44925
44934
  // Read-only, and what this control opens still opens: reading what is in
44926
44935
  // there changes nothing. Read by READONLY_CONSTRAINT, which lets an
44927
44936
  // interaction that only reads through on it.
@@ -45374,10 +45383,10 @@ const useInteractiveProps = (props, {
45374
45383
  });
45375
45384
  }
45376
45385
  // controlHostProps is a curated subset of props with resolved values applied
45377
- // (e.g. readOnly resolved from context + action loading). The interaction system
45378
- // reads off uiStateController.controlHostProps at runtime (e.g. READONLY_CONSTRAINT
45379
- // checks controlHostProps.readOnly), so pointing the controller at controlHostProps
45380
- // keeps those reads current without any extra bookkeeping.
45386
+ // (e.g. readOnly resolved from context + action loading). The constraints
45387
+ // read off uiStateController.controlHostProps at runtime (e.g. DISABLED_CONSTRAINT,
45388
+ // the readonly reason), so pointing the controller at controlHostProps keeps
45389
+ // those reads current without any extra bookkeeping.
45381
45390
  const firstRender = uiStateController.controlHostProps === undefined;
45382
45391
  uiStateController.controlHostProps = controlHostProps;
45383
45392
  // The action itself, not just what the last render made of it: its running
@@ -54421,14 +54430,34 @@ const ButtonConfirm = ({
54421
54430
  }
54422
54431
  return jsxs("span", {
54423
54432
  className: "navi_button_confirm",
54424
- ref: askingRef,
54433
+ ref: askingRef
54434
+ // A place for the focus, so a press anywhere in the question keeps it
54435
+ // inside: WebKit does not focus a pressed button, and a press on
54436
+ // "Confirmer" would otherwise blur toward <body> — a focusout with no
54437
+ // relatedTarget, read below as the person leaving. The callout answers
54438
+ // the same ambiguity the same way.
54439
+ ,
54440
+ tabIndex: -1
54441
+ // "not me, unless you have nothing else": the ladder in the effect above
54442
+ // walks past the question to what it holds.
54443
+ // eslint-disable-next-line react/no-unknown-property
54444
+ ,
54445
+ "navi-autofocus": "last-resort",
54425
54446
  onKeyDown: e => {
54426
54447
  if (e.key === "Escape") {
54427
54448
  cancel(true);
54428
54449
  }
54429
54450
  },
54430
54451
  onFocusOut: e => {
54431
- if (!askingRef.current.contains(e.relatedTarget)) {
54452
+ // Chrome also fires this from the question's own removal, once preact
54453
+ // has cleared the ref: nobody left, and cancel(false) would undo the
54454
+ // focus the answer gives back. That focus cannot go back before the
54455
+ // removal: the button it lands on is mounted by the same commit.
54456
+ const askingEl = askingRef.current;
54457
+ if (!askingEl) {
54458
+ return;
54459
+ }
54460
+ if (!askingEl.contains(e.relatedTarget)) {
54432
54461
  cancel(false);
54433
54462
  }
54434
54463
  },
@@ -71864,7 +71893,7 @@ const css$x = /* css */`@property --list-group-label-height {
71864
71893
  }
71865
71894
  }
71866
71895
 
71867
- .navi_list[navi-box-flow="grid"] > .navi_list_virtual_filler, .navi_list[navi-box-flow="grid"] > .navi_separator {
71896
+ .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 {
71868
71897
  grid-column: 1 / -1;
71869
71898
  }
71870
71899
 
@@ -72241,7 +72270,8 @@ const ListUI = props => {
72241
72270
  listId: rest.id,
72242
72271
  scroller,
72243
72272
  searchText,
72244
- horizontal
72273
+ horizontal,
72274
+ columns
72245
72275
  });
72246
72276
 
72247
72277
  // renderBudget frames the items of a run; a list whose items are all declared
@@ -72556,7 +72586,8 @@ const useListScrollSync = ({
72556
72586
  listId,
72557
72587
  scroller,
72558
72588
  searchText,
72559
- horizontal
72589
+ horizontal,
72590
+ columns
72560
72591
  }) => {
72561
72592
  const debugScroll = useDebugScroll();
72562
72593
  // The items drawn, [start, end) among the list's own. A ref as well as a
@@ -72571,6 +72602,7 @@ const useListScrollSync = ({
72571
72602
  return renderWindow.start > 0 || renderWindow.end < listItems.totalSignal.peek();
72572
72603
  };
72573
72604
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
72605
+ inLines: Boolean(columns),
72574
72606
  windowLeavesItemsOut,
72575
72607
  scrolledWanted: scrolled ?? defaultScrolled,
72576
72608
  // The fillers hold the room of items above the screen at this size: a
@@ -72639,6 +72671,34 @@ const useListScrollSync = ({
72639
72671
  });
72640
72672
  };
72641
72673
 
72674
+ // How many items share a line: the tracks a grid of items (`columns`)
72675
+ // resolves to, one otherwise. A layout result under auto-fill, so it is read
72676
+ // off the list once laid out, at each commit and each resize — for the runs
72677
+ // alone, which draw in lines: items declared one by one are all drawn. A
72678
+ // line of another length rewraps every item drawn: the item at the top of
72679
+ // the view is held where it is across it.
72680
+ const itemsPerLineRef = useRef(1);
72681
+ const [itemsPerLine, setItemsPerLine] = useState(1);
72682
+ const updateItemsPerLine = () => {
72683
+ if (!ref.current) {
72684
+ return;
72685
+ }
72686
+ const itemsPerLineNow = columns && listItems.hasRuns() ? readItemsPerLine(getListEl()) : 1;
72687
+ if (itemsPerLineNow === null || itemsPerLineNow === itemsPerLineRef.current) {
72688
+ return;
72689
+ }
72690
+ captureAnchor();
72691
+ itemsPerLineRef.current = itemsPerLineNow;
72692
+ setItemsPerLine(itemsPerLineNow);
72693
+ };
72694
+ const updateItemsPerLineRef = useRef(null);
72695
+ updateItemsPerLineRef.current = updateItemsPerLine;
72696
+ useLayoutEffect(() => {
72697
+ if (columns || itemsPerLineRef.current !== 1) {
72698
+ updateItemsPerLine();
72699
+ }
72700
+ });
72701
+
72642
72702
  // What the window holds goes through three stages (see settleWindow):
72643
72703
  // - "first": the first commit, a count of items since nothing is laid out
72644
72704
  // yet to measure a size with — `initial` or the budget when they are one,
@@ -73037,6 +73097,7 @@ const useListScrollSync = ({
73037
73097
  listEl: getListEl(),
73038
73098
  listItems,
73039
73099
  virtualItemSizeSignal,
73100
+ itemsPerLine: itemsPerLineRef.current,
73040
73101
  renderWindowRef,
73041
73102
  horizontal
73042
73103
  });
@@ -73377,20 +73438,14 @@ const useListScrollSync = ({
73377
73438
  }
73378
73439
  const scrollerEl = getScroller();
73379
73440
  const listEl = getListEl();
73380
- const observer = new ResizeObserver(entries => {
73381
- // A box that grows may be a box that starts to scroll — the scroller is
73382
- // resolved again before anything is done about the resize, so that a
73383
- // list which has outgrown a bounded ancestor stops holding on to the
73384
- // page.
73385
- resolveScroller();
73386
- rememberPosition();
73387
- // Two things resize here, and they call for opposite answers. The LIST
73388
- // growing is its own content settling: only a list holding itself
73389
- // somewhere cares (the end it aims at has moved), and a list the user is
73390
- // reading must not be touched — its items are held still by the anchoring,
73391
- // which this would undo. The SCROLLER resizing is the window around it
73392
- // changing shape, and then the item that was at the top goes back where it
73393
- // was.
73441
+ // Two things resize here, and they call for opposite answers. The LIST
73442
+ // growing is its own content settling: only a list holding itself
73443
+ // somewhere cares (the end it aims at has moved), and a list the user is
73444
+ // reading must not be touched — its items are held still by the anchoring,
73445
+ // which this would undo. The SCROLLER resizing is the window around it
73446
+ // changing shape, and then the item that was at the top goes back where it
73447
+ // was.
73448
+ const putTopItemBack = entries => {
73394
73449
  const listResized = entries.some(entry => entry.target === listEl);
73395
73450
  if (listResized && onGeometryChangeRef.current()) {
73396
73451
  return;
@@ -73419,6 +73474,19 @@ const useListScrollSync = ({
73419
73474
  } else {
73420
73475
  scrollerEl.scrollTop += delta;
73421
73476
  }
73477
+ };
73478
+ const observer = new ResizeObserver(entries => {
73479
+ // A box that grows may be a box that starts to scroll — the scroller is
73480
+ // resolved again before anything is done about the resize, so that a
73481
+ // list which has outgrown a bounded ancestor stops holding on to the
73482
+ // page.
73483
+ resolveScroller();
73484
+ rememberPosition();
73485
+ putTopItemBack(entries);
73486
+ // Once the top item is back where it was: that is the place a line of
73487
+ // another length has to keep, and putting it back drops any anchor
73488
+ // captured before.
73489
+ updateItemsPerLineRef.current();
73422
73490
  });
73423
73491
  observer.observe(scrollerEl);
73424
73492
  observer.observe(listEl);
@@ -73574,7 +73642,10 @@ const useListScrollSync = ({
73574
73642
  start,
73575
73643
  end
73576
73644
  } = renderWindowRef.current;
73577
- const itemSize = virtualItemSizeSignal.peek();
73645
+ // What an item not drawn weighs: its share of a line, which the items side
73646
+ // by side on it take together.
73647
+ const itemsPerLineNow = itemsPerLineRef.current;
73648
+ const itemSize = virtualItemSizeSignal.peek() / itemsPerLineNow;
73578
73649
  const pixelsOf = createItemSizeReader(items, start, end, itemSize);
73579
73650
  const walkBefore = (from, sizeWanted, sizeOf) => {
73580
73651
  let index = from;
@@ -73844,9 +73915,28 @@ const useListScrollSync = ({
73844
73915
  ifChanged: true
73845
73916
  });
73846
73917
  }, [windowStart, windowEnd]);
73918
+ // What the runs draw: the window widened to whole lines. The window itself
73919
+ // moves an item at a time, and a grid lays the first item drawn in its first
73920
+ // column — started anywhere but on a line, every item drawn would sit in the
73921
+ // column of another. Started on one, an item is in the column its place says
73922
+ // whatever the window frames. The same object while the numbers hold, so the
73923
+ // runs are not told about a window that did not move.
73924
+ const windowDrawnRef = useRef(null);
73925
+ const drawnStart = windowStart - windowStart % itemsPerLine;
73926
+ const endPastLine = windowEnd % itemsPerLine;
73927
+ const drawnEnd = endPastLine === 0 ? windowEnd : windowEnd + itemsPerLine - endPastLine;
73928
+ let windowDrawn = windowDrawnRef.current;
73929
+ if (!windowDrawn || windowDrawn.start !== drawnStart || windowDrawn.end !== drawnEnd || windowDrawn.itemsPerLine !== itemsPerLine) {
73930
+ windowDrawn = {
73931
+ start: drawnStart,
73932
+ end: drawnEnd,
73933
+ itemsPerLine
73934
+ };
73935
+ windowDrawnRef.current = windowDrawn;
73936
+ }
73847
73937
  return {
73848
73938
  virtualItemSizeSignal,
73849
- renderWindow: renderWindowRef.current,
73939
+ renderWindow: windowDrawn,
73850
73940
  pendingScrollRef,
73851
73941
  scrollToItem,
73852
73942
  captureAnchor
@@ -73870,6 +73960,21 @@ const getScrollerViewportRect = scrollerEl => {
73870
73960
  }
73871
73961
  return scrollerEl.getBoundingClientRect();
73872
73962
  };
73963
+ // The tracks a grid resolved its template to, as its computed
73964
+ // grid-template-columns lists them once laid out: "150px 150px [end] 150px".
73965
+ // Not laid out (a closed popup), that is the template as written, which says
73966
+ // nothing yet under auto-fill: `null`.
73967
+ const readItemsPerLine = listEl => {
73968
+ const tracks = getComputedStyle(listEl).gridTemplateColumns;
73969
+ if (tracks === "none" || tracks.includes("(")) {
73970
+ return null;
73971
+ }
73972
+ const sizes = tracks.replace(/\[[^\]]*\]/g, " ").trim();
73973
+ if (!sizes) {
73974
+ return null;
73975
+ }
73976
+ return sizes.split(/\s+/).length;
73977
+ };
73873
73978
  // A window that cannot move is the one failure of a virtualized list that
73874
73979
  // looks like nothing: the items outside it are fillers holding their room, so
73875
73980
  // the list simply ends on blank space, at exactly the height of the items never
@@ -74672,6 +74777,7 @@ const getScrollInfo = ({
74672
74777
  listEl,
74673
74778
  listItems,
74674
74779
  virtualItemSizeSignal,
74780
+ itemsPerLine,
74675
74781
  renderWindowRef,
74676
74782
  horizontal
74677
74783
  }) => {
@@ -74709,7 +74815,9 @@ const getScrollInfo = ({
74709
74815
  // mean we don't know the real on-screen index, only the scroll position,
74710
74816
  // so estimate from it rather than assume nothing changed.
74711
74817
  const estimateFromScrollPos = reasonPrefix => {
74712
- const virtualItemSize = virtualItemSizeSignal.peek();
74818
+ // An item's share of its line: the items side by side on it take it
74819
+ // together.
74820
+ const virtualItemSize = virtualItemSizeSignal.peek() / itemsPerLine;
74713
74821
  if (virtualItemSize === 0) {
74714
74822
  return null;
74715
74823
  }
@@ -74765,8 +74873,10 @@ const getScrollInfo = ({
74765
74873
  const VIRTUAL_ITEM_SIZE_EPSILON = 0.5;
74766
74874
  // Measures the items currently in the DOM, edge to edge: what a filler stands in
74767
74875
  // for is the room a run of items takes together — separators and group labels
74768
- // included — not the height of one <li>.
74769
- const measureItemSize = (listEl, horizontal) => {
74876
+ // included — not the height of one <li>. The size of a line of them: one item
74877
+ // in a list, the items side by side in a grid (`inLines`), counted where a new
74878
+ // line starts.
74879
+ const measureItemSize = (listEl, horizontal, inLines) => {
74770
74880
  let fromSkeletons = false;
74771
74881
  let itemEls = listEl.querySelectorAll(REAL_LIST_ITEM_SELECTOR);
74772
74882
  if (itemEls.length === 0) {
@@ -74788,13 +74898,27 @@ const measureItemSize = (listEl, horizontal) => {
74788
74898
  if (span <= 0) {
74789
74899
  return null;
74790
74900
  }
74901
+ let lineCount = itemEls.length;
74902
+ if (inLines) {
74903
+ lineCount = 0;
74904
+ let lineFrom = -Infinity;
74905
+ for (const itemEl of itemEls) {
74906
+ const rect = itemEl.getBoundingClientRect();
74907
+ const from = horizontal ? rect.left : rect.top;
74908
+ if (from > lineFrom + 0.5) {
74909
+ lineCount++;
74910
+ lineFrom = from;
74911
+ }
74912
+ }
74913
+ }
74791
74914
  return {
74792
- size: span / itemEls.length,
74793
- itemCount: itemEls.length,
74915
+ size: span / lineCount,
74916
+ lineCount,
74794
74917
  fromSkeletons
74795
74918
  };
74796
74919
  };
74797
74920
  const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74921
+ inLines,
74798
74922
  windowLeavesItemsOut,
74799
74923
  scrolledWanted,
74800
74924
  beforeSizeChange
@@ -74838,8 +74962,8 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74838
74962
  return;
74839
74963
  }
74840
74964
  }
74841
- samples.sum += measure.size * measure.itemCount;
74842
- samples.count += measure.itemCount;
74965
+ samples.sum += measure.size * measure.lineCount;
74966
+ samples.count += measure.lineCount;
74843
74967
  const next = samples.sum / samples.count;
74844
74968
  const current = virtualSizeSignal.peek();
74845
74969
  if (Math.abs(next - current) > VIRTUAL_ITEM_SIZE_EPSILON) {
@@ -74859,7 +74983,7 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74859
74983
  const itemsHeldOffScreen = windowLeavesItemsOut();
74860
74984
  if (!virtualItemSizeProp && sizeAlreadyKnown && itemsHeldOffScreen && ref.current) {
74861
74985
  const listEl = ref.current.querySelector(".navi_list");
74862
- const measure = listEl ? measureItemSize(listEl, horizontal) : null;
74986
+ const measure = listEl ? measureItemSize(listEl, horizontal, inLines) : null;
74863
74987
  if (measure) {
74864
74988
  feedSample(measure);
74865
74989
  }
@@ -74880,11 +75004,11 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal, {
74880
75004
  if (!listEl) {
74881
75005
  return undefined;
74882
75006
  }
74883
- const measure = measureItemSize(listEl, horizontal);
75007
+ const measure = measureItemSize(listEl, horizontal, inLines);
74884
75008
  if (measure) {
74885
75009
  const samples = samplesRef.current;
74886
- samples.sum = measure.size * measure.itemCount;
74887
- samples.count = measure.itemCount;
75010
+ samples.sum = measure.size * measure.lineCount;
75011
+ samples.count = measure.lineCount;
74888
75012
  samples.fromSkeletons = measure.fromSkeletons;
74889
75013
  virtualSizeSignal.value = measure.size;
74890
75014
  return undefined;
@@ -75029,14 +75153,17 @@ const Fallback = ({
75029
75153
  // Reads the item size itself: it is what the size is for, and a run holding
75030
75154
  // every item it draws must not be redrawn — every item of it — because the size
75031
75155
  // settled after the first commit.
75156
+ // It holds lines: the items side by side on one take its room together, and a
75157
+ // last line left short takes it all the same.
75032
75158
  const VirtualFiller = ({
75033
75159
  edge,
75034
75160
  itemCount,
75161
+ itemsPerLine,
75035
75162
  findChunks
75036
75163
  }) => {
75037
75164
  const listItems = useContext(ListItemsContext);
75038
- const virtualItemSize = listItems.virtualItemSizeSignal.value;
75039
- const sizeToFill = itemCount * virtualItemSize;
75165
+ const lineSize = listItems.virtualItemSizeSignal.value;
75166
+ const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize;
75040
75167
  // A filler resizing moves what stands below it — the items on screen, when it
75041
75168
  // holds the room of items above them — and it resizes in a commit of its own
75042
75169
  // when the item size settles after the list has rendered: the list puts its
@@ -75055,7 +75182,7 @@ const VirtualFiller = ({
75055
75182
  "aria-hidden": true,
75056
75183
  style: {
75057
75184
  "--size-to-fill": `${sizeToFill}px`,
75058
- "--x-find-line-size": findChunks ? `${virtualItemSize}px` : undefined
75185
+ "--x-find-line-size": findChunks ? `${lineSize / itemsPerLine}px` : undefined
75059
75186
  },
75060
75187
  children: findChunks && findChunks.map(chunk => jsx("div", {
75061
75188
  className: "navi_list_find_stand_in",
@@ -75935,6 +76062,9 @@ const useRunItems = (store, {
75935
76062
  const rankOf = itemIndex => itemIndex - runStart;
75936
76063
  const indexOfRank = rank => rank + runStart;
75937
76064
  const getItemAt = itemIndex => store.getItem(rankOf(itemIndex));
76065
+ const {
76066
+ itemsPerLine
76067
+ } = renderWindow;
75938
76068
  const windowFrom = renderWindow.start > runStart ? renderWindow.start : runStart;
75939
76069
  const windowTo = renderWindow.end < runEnd ? renderWindow.end : runEnd;
75940
76070
  store.forget(rankOf(windowFrom), rankOf(windowTo));
@@ -76042,8 +76172,20 @@ const useRunItems = (store, {
76042
76172
  // band is drawn among the items. A negative start is not a rank but a count
76043
76173
  // back from the end (the very first ask, before the count is known) — there
76044
76174
  // is no item to convert it to, and the band falls back to the window.
76045
- const failureFrom = store.failure === null ? -1 : store.failure.start < 0 || indexOfRank(store.failure.start) < windowFrom ? windowFrom : indexOfRank(store.failure.start);
76046
- const failureTo = store.failure === null ? -1 : store.failure.end < 0 || indexOfRank(store.failure.end) > windowTo - 1 ? windowTo - 1 : indexOfRank(store.failure.end);
76175
+ let failureFrom = store.failure === null ? -1 : store.failure.start < 0 || indexOfRank(store.failure.start) < windowFrom ? windowFrom : indexOfRank(store.failure.start);
76176
+ let failureTo = store.failure === null ? -1 : store.failure.end < 0 || indexOfRank(store.failure.end) > windowTo - 1 ? windowTo - 1 : indexOfRank(store.failure.end);
76177
+ if (store.failure !== null && itemsPerLine > 1) {
76178
+ // In a grid the band takes whole lines, like the window: begun mid-line,
76179
+ // it would push every item after it into another column.
76180
+ failureFrom -= failureFrom % itemsPerLine;
76181
+ if (failureFrom < windowFrom) {
76182
+ failureFrom = windowFrom;
76183
+ }
76184
+ failureTo += itemsPerLine - 1 - failureTo % itemsPerLine;
76185
+ if (failureTo > windowTo - 1) {
76186
+ failureTo = windowTo - 1;
76187
+ }
76188
+ }
76047
76189
  const nodes = [];
76048
76190
  // Items that belong together, as the data says (a day of messages, a month of
76049
76191
  // games): consecutive items sharing a group key are wrapped in one group, so
@@ -76051,19 +76193,57 @@ const useRunItems = (store, {
76051
76193
  // rebuilt as the window slides — a group holds the items of its day that are
76052
76194
  // currently drawn, which is exactly the span its label has to survive.
76053
76195
  let group = null;
76196
+ // Two groups can still share a key (a failure band standing mid-group, data
76197
+ // not sorted by group): the later ones are told apart by their rank among
76198
+ // them, so the first keeps its element. A group keyed by where it starts
76199
+ // would lose its element each time the window slides past its first item.
76200
+ const groupCountByKey = new Map();
76054
76201
  const closeGroup = () => {
76055
76202
  if (!group) {
76056
76203
  return;
76057
76204
  }
76205
+ const groupCount = (groupCountByKey.get(group.key) || 0) + 1;
76206
+ groupCountByKey.set(group.key, groupCount);
76058
76207
  nodes.push(jsx(ListItemGroup, {
76059
76208
  label: group.label,
76060
76209
  labelProps: group.labelProps,
76061
76210
  children: group.children
76062
- }, `${ownerId}_group_${group.key}`));
76211
+ }, groupCount === 1 ? `${ownerId}_group_${group.key}` : `${ownerId}_group_${group.key}_${groupCount}`));
76063
76212
  group = null;
76064
76213
  };
76214
+ // An item not held has no group of its own. Between two items of the open
76215
+ // group it is a line of that group whose content has not arrived: drawn
76216
+ // outside, it would cut the group in two and show its label twice. A hole at
76217
+ // a group's edge stays outside — nothing says which side it belongs to.
76218
+ let holeTo = -1;
76219
+ let holeGroupKey;
76220
+ const groupKeyOfHole = itemIndex => {
76221
+ if (itemIndex < holeTo) {
76222
+ return holeGroupKey;
76223
+ }
76224
+ holeTo = itemIndex + 1;
76225
+ while (holeTo < windowTo && holeTo !== failureFrom && getItemAt(holeTo) === undefined) {
76226
+ holeTo++;
76227
+ }
76228
+ holeGroupKey = undefined;
76229
+ if (group && holeTo < windowTo && holeTo !== failureFrom) {
76230
+ const keyAfterHole = groupBy(getItemAt(holeTo), holeTo);
76231
+ if (keyAfterHole === group.key) {
76232
+ holeGroupKey = keyAfterHole;
76233
+ }
76234
+ }
76235
+ return holeGroupKey;
76236
+ };
76065
76237
  // Which group an item belongs to, or undefined when it belongs to none.
76066
- const groupKeyOf = (item, itemIndex) => groupBy && item !== undefined ? groupBy(item, itemIndex) : undefined;
76238
+ const groupKeyOf = (item, itemIndex) => {
76239
+ if (!groupBy) {
76240
+ return undefined;
76241
+ }
76242
+ if (item === undefined) {
76243
+ return groupKeyOfHole(itemIndex);
76244
+ }
76245
+ return groupBy(item, itemIndex);
76246
+ };
76067
76247
  const pushItem = (itemNode, item, itemIndex, groupKey) => {
76068
76248
  if (groupKey === undefined) {
76069
76249
  closeGroup();
@@ -76120,6 +76300,7 @@ const useRunItems = (store, {
76120
76300
  nodes.push(jsx(VirtualFiller, {
76121
76301
  edge: "before",
76122
76302
  itemCount: windowFrom - runStart,
76303
+ itemsPerLine: itemsPerLine,
76123
76304
  findChunks: getFindChunks(runStart, windowFrom)
76124
76305
  }, "navi-list-filler-before"));
76125
76306
  }
@@ -76134,7 +76315,7 @@ const useRunItems = (store, {
76134
76315
  nodes.push(jsx("li", {
76135
76316
  className: "navi_list_failed_items",
76136
76317
  style: {
76137
- "--size-to-fill": `${failedItemCount * listItems.virtualItemSizeSignal.value}px`
76318
+ "--size-to-fill": `${Math.ceil(failedItemCount / itemsPerLine) * listItems.virtualItemSizeSignal.value}px`
76138
76319
  },
76139
76320
  children: renderError ? renderError({
76140
76321
  error: store.failure.error,
@@ -76224,6 +76405,7 @@ const useRunItems = (store, {
76224
76405
  nodes.push(jsx(VirtualFiller, {
76225
76406
  edge: "after",
76226
76407
  itemCount: runEnd - windowTo,
76408
+ itemsPerLine: itemsPerLine,
76227
76409
  findChunks: getFindChunks(windowTo, runEnd)
76228
76410
  }, "navi-list-filler-after"));
76229
76411
  }
@@ -76389,10 +76571,15 @@ const useItemStore = ({
76389
76571
  }
76390
76572
  const pages = pagesRef.current;
76391
76573
  const [, setPageVersion] = useState(0);
76392
- // The items held are out of date and the run has not asked for the new ones
76393
- // yet. They stay on screen until the answer comes: what is drawn is from
76394
- // before, which is not the same thing as nothing to draw.
76395
- const staleRef = useRef(restored);
76574
+ // The ranks whose item is from before: the run came back to the screen, or
76575
+ // the source said the collection moved. They stay drawn until a page confirms
76576
+ // or replaces them, rank by rank. A page covers what the window framed when it
76577
+ // was asked for, and the window keeps growing meanwhile: dropping what the
76578
+ // page did not cover empties items on screen.
76579
+ const staleRanksRef = useRef(null);
76580
+ if (staleRanksRef.current === null) {
76581
+ staleRanksRef.current = restored ? new Set(pages.byIndex.keys()) : new Set();
76582
+ }
76396
76583
  const [refreshing, setRefreshing] = useState(false);
76397
76584
  // A source that says when what it reads has moved (a resource range reader
76398
76585
  // does: see rerunOn.GET_RANGE) is heard here — a write deciding who belongs
@@ -76403,7 +76590,7 @@ const useItemStore = ({
76403
76590
  const invalidationRef = useRef(invalidation);
76404
76591
  if (invalidationRef.current !== invalidation) {
76405
76592
  invalidationRef.current = invalidation;
76406
- staleRef.current = true;
76593
+ staleRanksRef.current = new Set(pages.byIndex.keys());
76407
76594
  }
76408
76595
  // The one request in flight, with the means to call it off: a page asked for
76409
76596
  // a window the list has left is work the server and the browser are doing for
@@ -76436,11 +76623,6 @@ const useItemStore = ({
76436
76623
  // It stands for a page of them: a list that is about to be filled looks
76437
76624
  // like items on their way, not like an empty list.
76438
76625
  const itemCount = pages.count ?? count ?? listItems.pageSize;
76439
- // A run that never received anything has nothing to keep on screen: asking
76440
- // again is its first ask, not a refresh.
76441
- if (staleRef.current && pages.count === undefined) {
76442
- staleRef.current = false;
76443
- }
76444
76626
  useLayoutEffect(() => {
76445
76627
  if (!refreshing) {
76446
76628
  return null;
@@ -76513,9 +76695,11 @@ const useItemStore = ({
76513
76695
  const keepFrom = windowFrom - ITEM_STORE_KEEP_AROUND;
76514
76696
  const keepTo = windowTo + ITEM_STORE_KEEP_AROUND;
76515
76697
  if (pages.byIndex.size > budget) {
76698
+ const staleRanks = staleRanksRef.current;
76516
76699
  for (const index of pages.byIndex.keys()) {
76517
76700
  if (index < keepFrom || index > keepTo) {
76518
76701
  pages.byIndex.delete(index);
76702
+ staleRanks.delete(index);
76519
76703
  }
76520
76704
  }
76521
76705
  }
@@ -76550,10 +76734,24 @@ const useItemStore = ({
76550
76734
  let start = missingStart;
76551
76735
  let end = missingEnd;
76552
76736
  let around;
76553
- // Items that are all there but out of date: the ask is the window itself,
76554
- // anchored on the item at its top — a source paginating by cursor gets an
76555
- // item to count from, and the reading position is what must survive.
76556
- const revalidating = staleRef.current;
76737
+ // The window draws items from before: they are asked for again, and stay
76738
+ // drawn meanwhile.
76739
+ const staleRanks = staleRanksRef.current;
76740
+ let staleFrom = -1;
76741
+ let staleTo = -1;
76742
+ if (staleRanks.size > 0) {
76743
+ let rank = windowFrom;
76744
+ while (rank < windowTo) {
76745
+ if (staleRanks.has(rank)) {
76746
+ if (staleFrom === -1) {
76747
+ staleFrom = rank;
76748
+ }
76749
+ staleTo = rank;
76750
+ }
76751
+ rank++;
76752
+ }
76753
+ }
76754
+ const revalidating = staleFrom !== -1;
76557
76755
  // The list is held on an item nothing on screen leads to: the items it holds
76558
76756
  // do not contain it, so no window it could draw will ever bring it. Only
76559
76757
  // asking for it by name does.
@@ -76570,18 +76768,36 @@ const useItemStore = ({
76570
76768
  start = from < 0 ? 0 : from;
76571
76769
  end = start + budget - 1;
76572
76770
  } else if (revalidating) {
76573
- start = windowFrom;
76574
- end = windowTo - 1;
76575
- if (end < start) {
76576
- // Nothing of this run is on screen (the window frames another one, or
76577
- // the list is scrolled past it): its own first items are what it will
76578
- // draw next.
76579
- start = 0;
76580
- end = budget - 1;
76581
- }
76582
- const firstHeld = pages.byIndex.get(windowFrom);
76583
- if (firstHeld && firstHeld.id !== undefined) {
76584
- around = firstHeld.id;
76771
+ // The items from before that the window draws, and the missing ones
76772
+ // with them: one ask.
76773
+ if (start === -1 || staleFrom < start) {
76774
+ start = staleFrom;
76775
+ }
76776
+ if (staleTo > end) {
76777
+ end = staleTo;
76778
+ }
76779
+ // Grown over the items from before on either side, up to a page: the
76780
+ // window grows past its first picture right after it is painted, and
76781
+ // what it grows onto would otherwise be asked for once more.
76782
+ while (end - start + 1 < budget) {
76783
+ const staleBefore = staleRanks.has(start - 1);
76784
+ const staleAfter = staleRanks.has(end + 1);
76785
+ if (!staleBefore && !staleAfter) {
76786
+ break;
76787
+ }
76788
+ if (staleBefore) {
76789
+ start--;
76790
+ }
76791
+ if (staleAfter && end - start + 1 < budget) {
76792
+ end++;
76793
+ }
76794
+ }
76795
+ // Anchored on the first item from before on screen: a source
76796
+ // paginating by cursor gets an item to count from, and the reading
76797
+ // position is what must survive.
76798
+ const firstStale = pages.byIndex.get(staleFrom);
76799
+ if (firstStale && firstStale.id !== undefined) {
76800
+ around = firstStale.id;
76585
76801
  }
76586
76802
  } else if (pages.count === undefined) {
76587
76803
  const scrolled = listItems.scrolled;
@@ -76635,7 +76851,8 @@ const useItemStore = ({
76635
76851
  debugAsk("already revalidating");
76636
76852
  return;
76637
76853
  }
76638
- // A page for a window that is about to be replaced wholesale.
76854
+ // A page asked for before the items went stale answers for the
76855
+ // collection as it was.
76639
76856
  request.controller?.abort();
76640
76857
  request.busy = false;
76641
76858
  }
@@ -76656,11 +76873,11 @@ const useItemStore = ({
76656
76873
  request.controller?.abort();
76657
76874
  request.busy = false;
76658
76875
  }
76659
- const held = pages.byIndex.size;
76660
76876
  // Asking again for a range that was already asked for, having received
76661
- // nothing since, can only produce the same answer. A revalidation is
76662
- // exactly the case where it produces another one.
76663
- if (!revalidating && request.start === start && request.end === end && request.held === held) {
76877
+ // nothing since, can only produce the same answer — unless the
76878
+ // collection moved since, which is what an invalidation says.
76879
+ const held = `${pages.byIndex.size}/${staleRanks.size}/${invalidation}`;
76880
+ if (request.start === start && request.end === end && request.held === held) {
76664
76881
  debugAsk("this range was asked for already");
76665
76882
  return;
76666
76883
  }
@@ -76696,7 +76913,6 @@ const useItemStore = ({
76696
76913
  request.revalidating = false;
76697
76914
  setFailure(null);
76698
76915
  if (revalidating) {
76699
- staleRef.current = false;
76700
76916
  setRefreshing(false);
76701
76917
  }
76702
76918
  publishRequestState();
@@ -76715,15 +76931,35 @@ const useItemStore = ({
76715
76931
  // Before the items land: what is on screen has to stay where it is,
76716
76932
  // and the DOM still shows the state to hold onto.
76717
76933
  listItems.captureAnchor();
76718
- if (revalidating) {
76719
- // The items held stood for a composition that has moved on; the
76720
- // ones outside the window are forgotten and asked for again if the
76721
- // user goes back to them.
76722
- pages.byIndex = new Map();
76934
+ // An item from before is gone from where it stood when the page puts
76935
+ // it at another rank (it would be drawn twice), or when its rank is
76936
+ // past the end of the collection.
76937
+ const staleRanksNow = staleRanksRef.current;
76938
+ const pageEnd = pageStart + pageItems.length;
76939
+ let dropped = false;
76940
+ if (staleRanksNow.size > 0) {
76941
+ const pageIds = new Set();
76942
+ for (const pageItem of pageItems) {
76943
+ if (pageItem && pageItem.id !== undefined) {
76944
+ pageIds.add(pageItem.id);
76945
+ }
76946
+ }
76947
+ for (const rank of staleRanksNow) {
76948
+ if (rank >= pageStart && rank < pageEnd) {
76949
+ continue;
76950
+ }
76951
+ const staleItem = pages.byIndex.get(rank);
76952
+ if (rank >= pageCount || staleItem && pageIds.has(staleItem.id)) {
76953
+ staleRanksNow.delete(rank);
76954
+ pages.byIndex.delete(rank);
76955
+ dropped = true;
76956
+ }
76957
+ }
76723
76958
  }
76724
76959
  let i = 0;
76725
76960
  while (i < pageItems.length) {
76726
76961
  pages.byIndex.set(pageStart + i, pageItems[i]);
76962
+ staleRanksNow.delete(pageStart + i);
76727
76963
  i++;
76728
76964
  }
76729
76965
  pages.count = pageCount;
@@ -76733,7 +76969,7 @@ const useItemStore = ({
76733
76969
  itemsAction.writeComposition({
76734
76970
  byIndex: pages.byIndex,
76735
76971
  count: pageCount,
76736
- replace: revalidating
76972
+ replace: dropped
76737
76973
  });
76738
76974
  }
76739
76975
  listItems.pagesSignal.value = listItems.pagesSignal.peek() + 1;
@@ -76748,9 +76984,14 @@ const useItemStore = ({
76748
76984
  request.revalidating = false;
76749
76985
  publishRequestState();
76750
76986
  if (revalidating) {
76751
- // The items from before stay: a revalidation that failed has
76987
+ // The items from before stay, and are not asked for again until the
76988
+ // source says the collection moved: a revalidation that failed has
76752
76989
  // nothing better to put in their place.
76753
- staleRef.current = false;
76990
+ let rank = start;
76991
+ while (rank <= end) {
76992
+ staleRanksRef.current.delete(rank);
76993
+ rank++;
76994
+ }
76754
76995
  setRefreshing(false);
76755
76996
  return;
76756
76997
  }
@@ -76947,12 +77188,16 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76947
77188
  * @param {string} [props.columns]
76948
77189
  * The list's own columns: a `grid-template-columns` value the ITEMS are laid
76949
77190
  * into — a sheet of icons (`repeat(auto-fill, minmax(2.5rem, 1fr))`), a row of
76950
- * choices (`repeat(3, minmax(0, 1fr))`). Each item takes one cell, and an item
76951
- * meant to take a whole line says so for itself
76952
- * (`style={{ gridColumn: "1 / -1" }}`). Several items to a line is a shape the
76953
- * list cannot virtualize — its render window and the room it holds for the
76954
- * items it does not draw both count one item per line — so it is for a set of
76955
- * items the caller renders whole, not for a `<List.Items>` collection.
77191
+ * choices (`repeat(3, minmax(0, 1fr))`), a feed of covers
77192
+ * (`repeat(auto-fill, minmax(150px, 1fr))` over a `<List.Items>`). Each item
77193
+ * takes one cell, and an item meant to take a whole line says so for itself
77194
+ * (`style={{ gridColumn: "1 / -1" }}`). A `<List.Items>` run is drawn in whole
77195
+ * lines — as many items to a line as the template resolves to, read again
77196
+ * when the list's width changes: its window starts and ends on a line, so an
77197
+ * item is always in the column its place says, and the fillers hold the room
77198
+ * of the lines it does not draw (`virtualItemSize` is then a line's size).
77199
+ * That count holds only while every item of the run takes one cell: an item
77200
+ * spanning a line, or a `groupBy`, puts the ones after it in other columns.
76956
77201
  * @param {string} [props.itemColumns]
76957
77202
  * The columns inside an ITEM: a `grid-template-columns` value each item fills
76958
77203
  * with its own children, a table whose cells line up down the list. Every item
@@ -76968,8 +77213,9 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76968
77213
  * scroll box, which fills it and has nothing to arrange.
76969
77214
  * @param {boolean} [props.flexWrap]
76970
77215
  * Lets a horizontal list's items fall to the next line instead of running
76971
- * past the edge — a row of choices under a `maxWidth`, say. Same caveat as
76972
- * `columns` above: a line holding several items is not virtualizable.
77216
+ * past the edge — a row of choices under a `maxWidth`, say. A wrapped line
77217
+ * holding several items is not virtualizable: a long collection laid several
77218
+ * to a line goes in `columns`.
76973
77219
  * @param {string} [props.overflow]
76974
77220
  * `"visible"` lets the items paint outside the list — a check in an item's
76975
77221
  * corner, a badge crossing the edge. A list clips by default, which is what
@@ -77088,7 +77334,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
77088
77334
  * one-line size) — an item guessed too small builds one more, an item
77089
77335
  * guessed too big leaves a blank (see docs/scroll.md, "What the list knows,
77090
77336
  * and what it guesses"). The items drawn are measured either way: the render
77091
- * window is sized on them.
77337
+ * window is sized on them. In a grid (`columns`), the size is a line's: a
77338
+ * card's, which the items side by side on it share.
77092
77339
  * @param {"self"|"parent"|"document"|Element|{current: Element}} [props.scroller="self"]
77093
77340
  * Which box scrolls — and with it, which box the render window follows and
77094
77341
  * which box a scroll position is read from (`onScrolledChange`). `"self"`