@jsenv/navi 0.29.442 → 0.29.444

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.
@@ -6281,6 +6281,10 @@ const CONTROL_PROP_SET = new Set([
6281
6281
  "charGuard",
6282
6282
  "maxLengthGuard",
6283
6283
 
6284
+ // A checkbox that is a yes/no: `true` checked, `false` unchecked, instead of
6285
+ // HTML's value-or-nothing (see resolveEmptyUIState).
6286
+ "boolean",
6287
+
6284
6288
  // This control answers for itself: it registers into no group, no form and no
6285
6289
  // picker around it (see useUIStateController). It belongs here rather than
6286
6290
  // being deleted off `props` where it is read: props is the vnode's own object
@@ -42149,6 +42153,14 @@ const useUIStateController = (
42149
42153
  return ownUIState || parentUIState;
42150
42154
  })
42151
42155
  : ownUIStateSignal;
42156
+ // What the control is worth, which its action is bound to: what it
42157
+ // holds, or its own empty when it holds nothing. Apart from
42158
+ // uiStateSignal because unchecked is `undefined` there (the DOM reads any
42159
+ // other value as checked), while a yes/no unchecked is worth `false`.
42160
+ const emptyUIStateSignal = signal(emptyUIState);
42161
+ const valueSignal = computed(() =>
42162
+ uiStateOrEmpty(uiStateSignal.value, emptyUIStateSignal.value),
42163
+ );
42152
42164
 
42153
42165
  // The two-way half of a bound `signal` prop: setting it re-renders and
42154
42166
  // re-syncs via state_prop_change, but with the same value → guarded as a
@@ -42197,6 +42209,7 @@ const useUIStateController = (
42197
42209
  // travel command ended up carrying a picker's selection: see
42198
42210
  // resolveCommandValue in commands.js.
42199
42211
  ownUIStateSignal,
42212
+ valueSignal,
42200
42213
  value: controlInfo.value,
42201
42214
  // The suggestion this control started on — what tells a field showing
42202
42215
  // its default from one carrying an answer (see isUIStateHeld).
@@ -42256,7 +42269,11 @@ const useUIStateController = (
42256
42269
  }
42257
42270
  s.uiActionInternal?.(currentUIState, e);
42258
42271
  if (s.uiAction) {
42259
- s.uiAction(currentUIState, e);
42272
+ const value = uiStateOrEmpty(
42273
+ currentUIState,
42274
+ controller.emptyUIState,
42275
+ );
42276
+ s.uiAction(value, e);
42260
42277
  }
42261
42278
  if (skipCommand) ; else {
42262
42279
  const command = controller.controlHostProps.command;
@@ -42743,6 +42760,7 @@ const useUIStateController = (
42743
42760
  return {
42744
42761
  controller,
42745
42762
  parentUiStateSignalHolder,
42763
+ emptyUIStateSignal,
42746
42764
  ...liveValues(),
42747
42765
  };
42748
42766
  },
@@ -42864,6 +42882,7 @@ const useUIStateController = (
42864
42882
  );
42865
42883
  scope.parentUiStateSignalHolder.value =
42866
42884
  parentUIStateController?.uiStateSignal ?? null;
42885
+ scope.emptyUIStateSignal.value = emptyUIState;
42867
42886
 
42868
42887
  const { controller } = scope;
42869
42888
  const controllerRef = controller.ref;
@@ -43074,14 +43093,11 @@ const keptAndNamedChildUIStates = (children, fallbackState, kept) => {
43074
43093
  const readNamedChildUIStates = (children, { warnNameless }) => {
43075
43094
  const values = {};
43076
43095
  for (const child of children) {
43077
- const { name, emptyUIState } = child;
43096
+ const { name } = child;
43078
43097
  // A control holding nothing writes its own empty, not a hole: the key is in
43079
43098
  // the object either way, and what is read from it keeps the shape the
43080
43099
  // reader was promised (see resolveEmptyUIState).
43081
- const uiState =
43082
- child.uiState === undefined && emptyUIState !== undefined
43083
- ? emptyUIState
43084
- : child.uiState;
43100
+ const uiState = uiStateOrEmpty(child.uiState, child.emptyUIState);
43085
43101
  if (!name) {
43086
43102
  if (isNamelessGrouping(child, uiState)) {
43087
43103
  Object.assign(
@@ -44413,11 +44429,10 @@ const useUIFacadeStateController = (props, realUIStateController) => {
44413
44429
  // whose last item was unselected holds [], the same thing clearing it
44414
44430
  // leaves — not a value that changes type on its owner the moment it
44415
44431
  // empties.
44416
- const { emptyUIState } = s.realUIStateController;
44417
- const uiStateToAdopt =
44418
- child.uiState === undefined && emptyUIState !== undefined
44419
- ? emptyUIState
44420
- : child.uiState;
44432
+ const uiStateToAdopt = uiStateOrEmpty(
44433
+ child.uiState,
44434
+ s.realUIStateController.emptyUIState,
44435
+ );
44421
44436
  s.realUIStateController.setUIState(uiStateToAdopt, propagateUpEvent);
44422
44437
  updatingRef.current = false;
44423
44438
  },
@@ -44657,8 +44672,9 @@ const resolveEmptyUIState = (props, controlType) => {
44657
44672
  if (controlType === "input" && props.type === "checkbox") {
44658
44673
  // A checkbox is a member of a set, the way HTML has it: checked it sends
44659
44674
  // its value ("on" by default), unchecked it sends nothing at all. Only one
44660
- // holding `true` is a yes/no, and a yes/no nobody said yes to is `false`.
44661
- return props.value === true ? false : undefined;
44675
+ // declared `boolean` is a yes/no, and a yes/no nobody said yes to is
44676
+ // `false`.
44677
+ return props.boolean ? false : undefined;
44662
44678
  }
44663
44679
  const stateShape = props["navi-state-shape"];
44664
44680
  if (stateShape === "array") {
@@ -44670,6 +44686,15 @@ const resolveEmptyUIState = (props, controlType) => {
44670
44686
  return undefined;
44671
44687
  };
44672
44688
 
44689
+ // What a control holding `uiState` is worth: its own empty when it holds
44690
+ // nothing, so the reader gets the shape it was promised.
44691
+ const uiStateOrEmpty = (uiState, emptyUIState) => {
44692
+ if (uiState === undefined && emptyUIState !== undefined) {
44693
+ return emptyUIState;
44694
+ }
44695
+ return uiState;
44696
+ };
44697
+
44673
44698
  // What a cleared control shows: its own empty, kept in the shape it was holding.
44674
44699
  const resolveClearedUIState = (controller) => {
44675
44700
  const { controlType, props } = controller;
@@ -44904,7 +44929,7 @@ const useControlProps = (props, {
44904
44929
  // of a form would re-render at each change of any of its fields.
44905
44930
  // eslint-disable-next-line no-unused-expressions
44906
44931
  uiStateController.ownUIStateSignal.value;
44907
- const boundAction = useAction(props.action, uiStateController.uiStateSignal);
44932
+ const boundAction = useAction(props.action, uiStateController.valueSignal);
44908
44933
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
44909
44934
  uiStateController,
44910
44935
  boundAction,
@@ -56981,7 +57006,12 @@ const css$M = /* css */`@layer navi {
56981
57006
  const InputCheckbox = props => {
56982
57007
  const defaultRef = useRef();
56983
57008
  props.ref = props.ref || defaultRef;
56984
- props.value = props.value === undefined ? "on" : props.value;
57009
+ useRef(false);
57010
+ if (props.boolean) {
57011
+ props.value = true;
57012
+ } else if (props.value === undefined) {
57013
+ props.value = "on";
57014
+ }
56985
57015
  if (props.headless) {
56986
57016
  return jsx(InputCheckboxHeadless, {
56987
57017
  ...props,
@@ -71419,15 +71449,16 @@ document.addEventListener(
71419
71449
  );
71420
71450
 
71421
71451
  /*
71422
- * Whether writing a scroll position now would cost the user the scroll in
71423
- * progress. A write is an instant scroll, and the browser abandons for it any
71424
- * scroll it is playing on its own, short of where that one was going: a key
71425
- * (Home, PageDown, Space), a smooth `scrollTo` or `scrollIntoView`, the status
71426
- * bar tap on iOS. A scroll following a hand is not abandoned: the wheel's next
71427
- * turn carries on, and on most engines a finger keeps dragging from wherever
71428
- * the write left it, and the fling it leaves behind once it lifts carries on
71429
- * from there too (Chrome under DevTools' touch emulation: flings written to at
71430
- * every move of a list's window went as far as flings never written to).
71452
+ * Moves a scroll by what the content moved under the user, unless the write
71453
+ * would cost them the scroll in progress. A write is an instant scroll, and
71454
+ * the browser abandons for it any scroll it is playing on its own, short of
71455
+ * where that one was going: a key (Home, PageDown, Space), a smooth `scrollTo`
71456
+ * or `scrollIntoView`, the status bar tap on iOS. A scroll following a hand is
71457
+ * not abandoned: the wheel's next turn carries on, and on most engines a
71458
+ * finger keeps dragging from wherever the write left it, and the fling it
71459
+ * leaves behind once it lifts carries on from there too (Chrome under
71460
+ * DevTools' touch emulation: flings written to at every move of a list's
71461
+ * window went as far as flings never written to).
71431
71462
  *
71432
71463
  * A fling must not be taken for a scroll the browser plays on its own. A list
71433
71464
  * then leaves uncorrected, for the whole fling, the room its fillers lose above
@@ -71454,6 +71485,18 @@ document.addEventListener(
71454
71485
  * while the finger is still on one of them, and the touchend of a removed
71455
71486
  * element never reaches the document.
71456
71487
  *
71488
+ * Chrome ends a scroll at every write, a finger's fling included: "scrollend"
71489
+ * a frame after the write, and the fling goes on a frame later as a scroll of
71490
+ * its own, as far as it was going (a bare page written to every 120ms of a
71491
+ * fling: 9 "scrollend" for 8 writes, the fling as long). Taken for a new
71492
+ * scroll, it is nobody's once it starts far enough from the finger's last
71493
+ * move, and the list's corrections are refused for the rest of the fling: in
71494
+ * wematch, cards drawn above the screen pushing it down at every window slide.
71495
+ * So a scroll starting right after the "scrollend" of a scroll written to here
71496
+ * goes on as that scroll: same start, same hand. Only a written one: a scroll
71497
+ * starting right after one that ended on its own is a new one, a key or a
71498
+ * smooth `scrollTo` must not pass for the fling before it.
71499
+ *
71457
71500
  * In a browser without "scrollend", no write is said to interrupt anything: a
71458
71501
  * scroll would never be seen ending.
71459
71502
  */
@@ -71463,8 +71506,8 @@ document.addEventListener(
71463
71506
  // hand on a scroll that came from elsewhere.
71464
71507
  const WHEEL_REACH = 100;
71465
71508
  // The fling Chrome plays as a scroll of its own starts a frame or two after the
71466
- // drag's last move (DevTools' touch emulation, 4× CPU: 12ms after the drag's
71467
- // "scrollend").
71509
+ // "scrollend" of the drag, or of a write (DevTools' touch emulation, 4× CPU:
71510
+ // 12ms after the drag's, 24 to 40ms after a write's in wematch).
71468
71511
  const FLING_REACH = 150;
71469
71512
  // iOS's touch handling, the one engine whose finger drops writes (see above).
71470
71513
  const FINGER_KEEPS_WRITES = !window.CSS.supports(
@@ -71473,14 +71516,41 @@ const FINGER_KEEPS_WRITES = !window.CSS.supports(
71473
71516
  );
71474
71517
 
71475
71518
  const flightMap = new WeakMap();
71519
+ // Scrollers written to since their last "scrollend": that "scrollend" may be
71520
+ // the write's.
71521
+ const writtenScrollerSet = new WeakSet();
71522
+ // The flight of a written scroller, from its "scrollend" until a scroll goes on
71523
+ // with it.
71524
+ const writtenFlightEndMap = new WeakMap();
71476
71525
  let fingersDown = 0;
71477
71526
  let wheelTimeStamp = -Infinity;
71478
71527
  let fingerScrollTimeStamp = -Infinity;
71479
71528
 
71480
71529
  /**
71530
+ * Answers whether it wrote.
71531
+ *
71481
71532
  * @param {Element} scrollerEl
71533
+ * @param {number} delta
71534
+ * @param {{ horizontal?: boolean }} [options]
71482
71535
  * @returns {boolean}
71483
71536
  */
71537
+ const scrollByUnlessInterrupting = (
71538
+ scrollerEl,
71539
+ delta,
71540
+ { horizontal } = {},
71541
+ ) => {
71542
+ if (wouldInterruptScroll(scrollerEl)) {
71543
+ return false;
71544
+ }
71545
+ writtenScrollerSet.add(scrollerEl);
71546
+ if (horizontal) {
71547
+ scrollerEl.scrollLeft += delta;
71548
+ } else {
71549
+ scrollerEl.scrollTop += delta;
71550
+ }
71551
+ return true;
71552
+ };
71553
+
71484
71554
  const wouldInterruptScroll = (scrollerEl) => {
71485
71555
  const flight = flightMap.get(scrollerEl);
71486
71556
  if (!flight) {
@@ -71523,6 +71593,15 @@ if ("onscrollend" in window) {
71523
71593
  if (flightMap.has(scroller)) {
71524
71594
  return;
71525
71595
  }
71596
+ const writtenFlightEnd = writtenFlightEndMap.get(scroller);
71597
+ writtenFlightEndMap.delete(scroller);
71598
+ if (
71599
+ writtenFlightEnd &&
71600
+ e.timeStamp - writtenFlightEnd.timeStamp < FLING_REACH
71601
+ ) {
71602
+ flightMap.set(scroller, writtenFlightEnd.flight);
71603
+ return;
71604
+ }
71526
71605
  flightMap.set(scroller, { timeStamp: e.timeStamp });
71527
71606
  },
71528
71607
  { capture: true, passive: true },
@@ -71530,7 +71609,13 @@ if ("onscrollend" in window) {
71530
71609
  document.addEventListener(
71531
71610
  "scrollend",
71532
71611
  (e) => {
71533
- flightMap.delete(asScroller(e.target));
71612
+ const scroller = asScroller(e.target);
71613
+ const flight = flightMap.get(scroller);
71614
+ flightMap.delete(scroller);
71615
+ if (flight && writtenScrollerSet.has(scroller)) {
71616
+ writtenFlightEndMap.set(scroller, { flight, timeStamp: e.timeStamp });
71617
+ }
71618
+ writtenScrollerSet.delete(scroller);
71534
71619
  },
71535
71620
  { capture: true, passive: true },
71536
71621
  );
@@ -72252,13 +72337,24 @@ const createListItems = () => {
72252
72337
  }
72253
72338
  return null;
72254
72339
  },
72255
- // The room the item at that index took when it was last drawn, or
72256
- // undefined: never drawn, not held, or not an item of a run.
72340
+ // The room the item at that index takes: the room it took when it was
72341
+ // last drawn, else the room its run is given for it (see List.Items'
72342
+ // itemSize), else undefined.
72257
72343
  roomAt: (index) => {
72258
72344
  for (const locator of locatorByOwner.values()) {
72259
- const id = locator.idAt(index);
72260
- if (id !== undefined) {
72261
- return roomById.get(id);
72345
+ const room = locator.roomAt(index);
72346
+ if (room !== undefined) {
72347
+ return room;
72348
+ }
72349
+ }
72350
+ return undefined;
72351
+ },
72352
+ // The room the run of that index is given for it, whatever was measured.
72353
+ givenRoomAt: (index) => {
72354
+ for (const locator of locatorByOwner.values()) {
72355
+ const room = locator.givenRoomAt(index);
72356
+ if (room !== undefined) {
72357
+ return room;
72262
72358
  }
72263
72359
  }
72264
72360
  return undefined;
@@ -73747,6 +73843,23 @@ const css$x = /* css */`@property --list-group-label-height {
73747
73843
  &[data-hidden-while-empty]:not(:has([navi-list-item-real])) {
73748
73844
  display: none;
73749
73845
  }
73846
+
73847
+ &[data-continued] {
73848
+ margin-top: calc(-1 * var(--x-list-gap, 0px));
73849
+ display: grid;
73850
+
73851
+ & > .navi_list_item_group_label, & > .navi_list_item_group_list {
73852
+ grid-area: 1 / 1;
73853
+ }
73854
+
73855
+ & > .navi_list_item_group_label {
73856
+ align-self: start;
73857
+ }
73858
+ }
73859
+
73860
+ &[data-continued="hole"] > .navi_list_item_group_label {
73861
+ visibility: hidden;
73862
+ }
73750
73863
  }
73751
73864
 
73752
73865
  .navi_list_container[data-horizontal] > .navi_list_scroll_container > .navi_list > .navi_list_item_group {
@@ -74528,6 +74641,7 @@ const useListScrollSync = ({
74528
74641
  // that changed since is measured again when drawn again, and the anchoring
74529
74642
  // holds the view across the difference. Items laid side by side in a grid
74530
74643
  // share a line, which the fillers hold at the one size.
74644
+ useRef(false);
74531
74645
  const rememberItemRooms = () => {
74532
74646
  if (!ref.current || itemsPerLineRef.current !== 1) {
74533
74647
  return;
@@ -74536,7 +74650,12 @@ const useListScrollSync = ({
74536
74650
  if (!listEl) {
74537
74651
  return;
74538
74652
  }
74539
- readItemRooms(listEl, horizontal, listItems.roomById);
74653
+ readItemRooms(listEl, horizontal, (item, room, complete) => {
74654
+ listItems.roomById.set(item.id, room);
74655
+ {
74656
+ return;
74657
+ }
74658
+ });
74540
74659
  };
74541
74660
  const updateRenderWindow = (newStart, newEnd, reason) => {
74542
74661
  const {
@@ -75294,18 +75413,14 @@ const useListScrollSync = ({
75294
75413
  if (delta > -0.5 && delta < 0.5) {
75295
75414
  return;
75296
75415
  }
75297
- if (wouldInterruptScroll(scrollerEl)) {
75298
- // As the anchoring's write would (see holdAnchorStill). The document's
75299
- // box is the scroller of a list scrolling the page, and it resizes with
75300
- // every item drawn taller than its filler: while the page scrolls, that
75301
- // is at every window move.
75302
- return;
75303
- }
75304
- anchorRef.current = null;
75305
- if (horizontal) {
75306
- scrollerEl.scrollLeft += delta;
75307
- } else {
75308
- scrollerEl.scrollTop += delta;
75416
+ // Not into a scroll it would cut short, as the anchoring's write (see
75417
+ // holdAnchorStill). The document's box is the scroller of a list
75418
+ // scrolling the page, and it resizes with every item drawn taller than
75419
+ // its filler: while the page scrolls, that is at every window move.
75420
+ if (scrollByUnlessInterrupting(scrollerEl, delta, {
75421
+ horizontal
75422
+ })) {
75423
+ anchorRef.current = null;
75309
75424
  }
75310
75425
  };
75311
75426
  const observer = new ResizeObserver(entries => {
@@ -75398,19 +75513,15 @@ const useListScrollSync = ({
75398
75513
  return;
75399
75514
  }
75400
75515
  anchorRef.current = null;
75401
- if (wouldInterruptScroll(scrollerEl)) {
75402
- // Nobody is looking at items standing still: they are passing by, on
75403
- // their way somewhere the browser takes them (a key, a smooth scrollTo,
75404
- // the status bar, a fling on iOS). The write would stop that scroll short
75405
- // of where it was going. Left alone, the items drawn above push the rest
75406
- // by `drift` while all of it moves, and the scroll lands.
75407
- return;
75408
- }
75409
- scrolledByListRef.current = true;
75410
- if (horizontal) {
75411
- scrollerEl.scrollLeft += drift;
75412
- } else {
75413
- scrollerEl.scrollTop += drift;
75516
+ // Not written into a scroll nobody is looking at: its items are passing
75517
+ // by, on their way somewhere the browser takes them (a key, a smooth
75518
+ // scrollTo, the status bar, a fling on iOS). The write would stop that
75519
+ // scroll short of where it was going. Left alone, the items drawn above
75520
+ // push the rest by `drift` while all of it moves, and the scroll lands.
75521
+ if (scrollByUnlessInterrupting(scrollerEl, drift, {
75522
+ horizontal
75523
+ })) {
75524
+ scrolledByListRef.current = true;
75414
75525
  }
75415
75526
  };
75416
75527
  useLayoutEffect(holdAnchorStill);
@@ -76451,18 +76562,17 @@ const readWindowGeometry = (scrollerEl, listEl, horizontal) => {
76451
76562
  };
76452
76563
  };
76453
76564
  // The room each item drawn takes along the scroll axis (see
76454
- // rememberItemRooms): up to the next item when that one is drawn too, so that
76455
- // what stands between them — a separator, the label of a group the next one
76456
- // opens — is counted; its own box otherwise. A stand-in is not measured: it
76457
- // takes the room it is given, not the room of its item.
76458
- const readItemRooms = (listEl, horizontal, roomById) => {
76565
+ // rememberItemRooms): from where the item before it ends to where it ends, so
76566
+ // that what stands between the two — a separator, the label of the group it
76567
+ // opens — is counted with the item it comes with. A group the window cuts
76568
+ // draws its label without room (see the data-continued rule above): that
76569
+ // label's room is held by the item that opens the group, in the filler. The
76570
+ // first item drawn is measured from the end of what stands before it in the
76571
+ // list (a filler, a header); a stand-in is not measured, it takes the room it
76572
+ // is given and not the room of its item, but an item after it ends where it
76573
+ // ends. `complete` says whether the room was read up to an end before it.
76574
+ const readItemRooms = (listEl, horizontal, onRoom) => {
76459
76575
  let previous = null;
76460
- const keepRoom = (item, next) => {
76461
- if (item.id === null) {
76462
- return;
76463
- }
76464
- roomById.set(item.id, next && next.index === item.index + 1 ? next.from - item.from : item.to - item.from);
76465
- };
76466
76576
  for (const itemEl of listEl.querySelectorAll(`[${LIST_ITEM_INDEX_ATTRIBUTE}]`)) {
76467
76577
  if (!isOwnItem(itemEl, listEl)) {
76468
76578
  continue;
@@ -76477,14 +76587,41 @@ const readItemRooms = (listEl, horizontal, roomById) => {
76477
76587
  from: horizontal ? rect.left : rect.top,
76478
76588
  to: horizontal ? rect.right : rect.bottom
76479
76589
  };
76480
- if (previous) {
76481
- keepRoom(previous, item);
76590
+ if (item.id !== null) {
76591
+ const endBefore = previous && previous.index === item.index - 1 ? previous.to : readEndBefore(itemEl, listEl, horizontal);
76592
+ if (endBefore === null) {
76593
+ onRoom(item, item.to - item.from, false);
76594
+ } else {
76595
+ onRoom(item, item.to - endBefore, true);
76596
+ }
76482
76597
  }
76483
76598
  previous = item;
76484
76599
  }
76485
- if (previous) {
76486
- keepRoom(previous, null);
76600
+ };
76601
+ // Where what stands before an item in the list ends: the element before the
76602
+ // item, or before its group when it is the first of it — or the start of the
76603
+ // list's content when nothing does. Null when the element before is not laid
76604
+ // out.
76605
+ const readEndBefore = (itemEl, listEl, horizontal) => {
76606
+ let slotEl = itemEl;
76607
+ const groupListEl = itemEl.parentElement;
76608
+ if (groupListEl && groupListEl.classList.contains("navi_list_item_group_list") && groupListEl.firstElementChild === itemEl) {
76609
+ slotEl = groupListEl.closest(".navi_list_item_group") || itemEl;
76610
+ }
76611
+ const beforeEl = slotEl.previousElementSibling;
76612
+ if (beforeEl) {
76613
+ const rect = beforeEl.getBoundingClientRect();
76614
+ if (rect.width === 0 && rect.height === 0) {
76615
+ return null;
76616
+ }
76617
+ return horizontal ? rect.right : rect.bottom;
76487
76618
  }
76619
+ if (slotEl.parentElement !== listEl) {
76620
+ return null;
76621
+ }
76622
+ const listRect = listEl.getBoundingClientRect();
76623
+ const listStyle = window.getComputedStyle(listEl);
76624
+ return horizontal ? listRect.left + parseFloat(listStyle.borderLeftWidth) + parseFloat(listStyle.paddingLeft) : listRect.top + parseFloat(listStyle.borderTopWidth) + parseFloat(listStyle.paddingTop);
76488
76625
  };
76489
76626
  // The first item the screen shows from `position` on, and the item after the
76490
76627
  // last one it shows up to `position` (exclusive, like the window's end). An
@@ -77034,6 +77171,8 @@ const UnorderedList = ({
77034
77171
  grid: trackColumns ? true : undefined,
77035
77172
  gridTemplateColumns: trackColumns,
77036
77173
  ...rest,
77174
+ // The gap a group the window cuts takes back (see data-continued).
77175
+ style: withListGap(rest.style, spacing),
77037
77176
  spacing: spacing,
77038
77177
  baseClassName: "navi_list",
77039
77178
  children: [!suppressFallback && searchFallbackShown && jsx(SearchFallback, {
@@ -77066,6 +77205,21 @@ const UnorderedList = ({
77066
77205
  })]
77067
77206
  });
77068
77207
  };
77208
+ // The list's spacing as a variable, next to the style it is given (an object
77209
+ // or a css string, as Box takes either).
77210
+ const withListGap = (style, spacing) => {
77211
+ if (spacing === undefined) {
77212
+ return style;
77213
+ }
77214
+ const gap = stringifySpacingStyle(spacing, "gap");
77215
+ if (typeof style === "string") {
77216
+ return `${style};--x-list-gap:${gap}`;
77217
+ }
77218
+ return {
77219
+ ...style,
77220
+ "--x-list-gap": gap
77221
+ };
77222
+ };
77069
77223
 
77070
77224
  // The "no match" message. Whether it shows is decided by ListUI (see its
77071
77225
  // searchFallbackShown): a search left nothing to display, and the caller did
@@ -77103,19 +77257,20 @@ const Fallback = ({
77103
77257
  // every item it draws must not be redrawn — every item of it — because the size
77104
77258
  // settled after the first commit.
77105
77259
  // It holds lines: the items side by side on one take its room together, and a
77106
- // last line left short takes it all the same. An item the list drew before
77107
- // takes the room it took then instead (see rememberItemRooms).
77260
+ // last line left short takes it all the same. An item whose room is known takes
77261
+ // that room instead: the one it took when the list drew it before (see
77262
+ // rememberItemRooms), or the one its run is given (see List.Items' itemSize).
77108
77263
  const VirtualFiller = ({
77109
77264
  edge,
77110
77265
  itemCount,
77111
77266
  itemsPerLine,
77112
- itemsRemembered,
77113
- roomRemembered,
77267
+ itemsKnown,
77268
+ roomKnown,
77114
77269
  findChunks
77115
77270
  }) => {
77116
77271
  const listItems = useContext(ListItemsContext);
77117
77272
  const lineSize = listItems.virtualItemSizeSignal.value;
77118
- const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize - itemsRemembered * lineSize / itemsPerLine + roomRemembered;
77273
+ const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize - itemsKnown * lineSize / itemsPerLine + roomKnown;
77119
77274
  // A filler resizing moves what stands below it — the items on screen, when it
77120
77275
  // holds the room of items above them — and it resizes in a commit of its own
77121
77276
  // when the item size settles after the list has rendered: the list puts its
@@ -77829,6 +77984,7 @@ const VISIBILITY_HIDDEN_STYLE = {
77829
77984
  * pageSize?: number,
77830
77985
  * memoryBudget?: number,
77831
77986
  * renderSkeleton?: false | ((index: number) => import("ignore:preact").ComponentChildren),
77987
+ * itemSize?: (index: number) => number,
77832
77988
  * renderError?: (failure: {error: any, retry: () => void, start: number, end: number}) => import("ignore:preact").ComponentChildren,
77833
77989
  * onRequestStateChange?: (state: {busy: boolean, refreshing: boolean, range: {start: number, end: number}|null}) => void,
77834
77990
  * }>}
@@ -77861,6 +78017,10 @@ const VISIBILITY_HIDDEN_STYLE = {
77861
78017
  * `<List.Group>` whose label (`renderGroupLabel`) stays on screen for as long
77862
78018
  * as one of them is. The groups are found in the data as it arrives, which is
77863
78019
  * the only way a list that discovers its items page by page can have any.
78020
+ * Style the label, not the group element: a group the render window cuts is
78021
+ * laid out by the list, its label over its first items, so that the label
78022
+ * and the gap before it — held in the room of the item opening the group —
78023
+ * take no room twice.
77864
78024
  * @param {(item: any, index: number) => any} [props.renderGroupLabel]
77865
78025
  * The label of the group an item opens, given that item.
77866
78026
  * @param {(item: any, index: number) => object} [props.groupLabelProps]
@@ -77879,9 +78039,26 @@ const VISIBILITY_HIDDEN_STYLE = {
77879
78039
  * further out. `Infinity` keeps every item the run ever received; `0` keeps
77880
78040
  * only the ones around the window.
77881
78041
  * @param {false|(index: number) => any} [props.renderSkeleton]
77882
- * What to draw for an item the run does not hold. Defaults to List's own
78042
+ * What to draw for an item the run does not hold, at the room that item will
78043
+ * take: a screen of skeletons alone has no item for the list to hold still,
78044
+ * and when the page lands, what is below a skeleton of the wrong size moves
78045
+ * by the difference (see docs/scroll.md). Defaults to List's own
77883
78046
  * `renderSkeleton`, then to a bare `<List.Item skeleton>`; `false` leaves the
77884
78047
  * item empty (its room is still held, or the list would jump as it loads).
78048
+ * @param {(index: number) => number} [props.itemSize]
78049
+ * The room the item at an index takes along the scroll axis, in px, known
78050
+ * before the item arrives: from where the item before it ends to where it
78051
+ * ends — what stands between the two is its own (the gap, a separator, the
78052
+ * label of the group it opens; the first item of the list has no gap before
78053
+ * it). The fillers then hold every item the list has not drawn at its own
78054
+ * room instead of List's `virtualItemSize`, and an item drawn above the
78055
+ * screen takes exactly the room held for it: nothing on screen moves. It is
78056
+ * the only way the screen holds still during a fling on iOS, where writing
78057
+ * the scroll to correct it would stop the fling. The same index
78058
+ * `renderSkeleton` is given — the two draw on the same knowledge. A room
78059
+ * measured on an item drawn wins over it, and in development one that
78060
+ * differs from it is reported. Items side by side in a grid (`columns`)
78061
+ * share a line, which keeps `virtualItemSize`.
77885
78062
  * @param {(failure: {error: any, retry: () => void, start: number, end: number}) => any} [props.renderError]
77886
78063
  * What to draw where items were asked for and never came: given the `error`,
77887
78064
  * a `retry` to call, and the `start`/`end` of the range that failed — the
@@ -77954,6 +78131,7 @@ const useRunItems = (store, {
77954
78131
  renderItem,
77955
78132
  findText,
77956
78133
  pageSize,
78134
+ itemSize,
77957
78135
  groupBy,
77958
78136
  renderGroupLabel,
77959
78137
  groupLabelProps,
@@ -78037,6 +78215,22 @@ const useRunItems = (store, {
78037
78215
  const item = getItemAt(itemIndex);
78038
78216
  return item === undefined ? undefined : idOf(item, itemIndex);
78039
78217
  };
78218
+ // The room the caller says an item takes (`itemSize`), drawn or not.
78219
+ const givenRoomOf = itemIndex => {
78220
+ if (!itemSize || itemsPerLine !== 1 || itemIndex < runStart || itemIndex >= runEnd) {
78221
+ return undefined;
78222
+ }
78223
+ const room = itemSize(itemIndex);
78224
+ return room > 0 ? room : undefined;
78225
+ };
78226
+ // The room an item took when the list last drew it (see rememberItemRooms),
78227
+ // else the room the caller gives it, else undefined: a filler holds an item
78228
+ // it knows nothing about at the item size.
78229
+ const roomOf = itemIndex => {
78230
+ const id = idAt(itemIndex);
78231
+ const remembered = id === undefined ? undefined : listItems.roomById.get(id);
78232
+ return remembered === undefined ? givenRoomOf(itemIndex) : remembered;
78233
+ };
78040
78234
  listItems.setItemLocator(ownerId, {
78041
78235
  indexOf: id => {
78042
78236
  let found = null;
@@ -78048,32 +78242,27 @@ const useRunItems = (store, {
78048
78242
  });
78049
78243
  return found;
78050
78244
  },
78051
- idAt
78245
+ roomAt: roomOf,
78246
+ givenRoomAt: givenRoomOf
78052
78247
  });
78053
- // The room an item took when the list last drew it (see rememberItemRooms),
78054
- // or undefined: a filler holds an item it never drew at the item size.
78055
- const roomOf = itemIndex => {
78056
- const id = idAt(itemIndex);
78057
- return id === undefined ? undefined : listItems.roomById.get(id);
78058
- };
78059
- const readRemembered = (from, to) => {
78060
- const remembered = {
78248
+ const readKnownRooms = (from, to) => {
78249
+ const known = {
78061
78250
  room: 0,
78062
78251
  count: 0
78063
78252
  };
78064
- if (listItems.roomById.size === 0) {
78065
- return remembered;
78253
+ if (listItems.roomById.size === 0 && !itemSize) {
78254
+ return known;
78066
78255
  }
78067
78256
  let itemIndex = from;
78068
78257
  while (itemIndex < to) {
78069
78258
  const room = roomOf(itemIndex);
78070
78259
  if (room !== undefined) {
78071
- remembered.room += room;
78072
- remembered.count++;
78260
+ known.room += room;
78261
+ known.count++;
78073
78262
  }
78074
78263
  itemIndex++;
78075
78264
  }
78076
- return remembered;
78265
+ return known;
78077
78266
  };
78078
78267
  useLayoutEffect(() => {
78079
78268
  return () => {
@@ -78194,6 +78383,7 @@ const useRunItems = (store, {
78194
78383
  nodes.push(jsx(ListItemGroup, {
78195
78384
  label: group.label,
78196
78385
  labelProps: group.labelProps,
78386
+ continued: group.continued,
78197
78387
  children: group.children
78198
78388
  }, groupCount === 1 ? `${ownerId}_group_${group.key}` : `${ownerId}_group_${group.key}_${groupCount}`));
78199
78389
  group = null;
@@ -78231,10 +78421,38 @@ const useRunItems = (store, {
78231
78421
  }
78232
78422
  return groupBy(item, itemIndex);
78233
78423
  };
78424
+ // Where a group opened at that item began, when not there (see
78425
+ // data-continued): "above" the window, or in the "hole" of items not held
78426
+ // drawn right before it. Items not held belong to the group they lead to as
78427
+ // often as not — a page lands every hundred items, a group starts a few
78428
+ // times in a thread — so a group the window starts in is taken to go on
78429
+ // above it unless the item before, held, says otherwise.
78430
+ // Along y only, never built along x: there a group stands its label over
78431
+ // its items, so the label takes no room on the axis (unless it is wider than
78432
+ // the items drawn) and only the gap would be taken back — `margin-left`,
78433
+ // without the overlay of the data-continued rule. Nothing measured it yet.
78434
+ let afterHole = false;
78435
+ const whereGroupContinues = (groupKey, itemIndex) => {
78436
+ if (listItems.horizontal) {
78437
+ return undefined;
78438
+ }
78439
+ if (afterHole) {
78440
+ return "hole";
78441
+ }
78442
+ if (itemIndex !== windowFrom || windowFrom <= runStart) {
78443
+ return undefined;
78444
+ }
78445
+ const itemBefore = getItemAt(windowFrom - 1);
78446
+ if (itemBefore === undefined || groupBy(itemBefore, windowFrom - 1) === groupKey) {
78447
+ return "above";
78448
+ }
78449
+ return undefined;
78450
+ };
78234
78451
  const pushItem = (itemNode, item, itemIndex, groupKey) => {
78235
78452
  if (groupKey === undefined) {
78236
78453
  closeGroup();
78237
78454
  nodes.push(itemNode);
78455
+ afterHole = item === undefined;
78238
78456
  return;
78239
78457
  }
78240
78458
  if (!group || group.key !== groupKey) {
@@ -78243,9 +78461,11 @@ const useRunItems = (store, {
78243
78461
  key: groupKey,
78244
78462
  label: renderGroupLabel ? renderGroupLabel(item, itemIndex) : groupKey,
78245
78463
  labelProps: groupLabelProps ? groupLabelProps(item, itemIndex) : undefined,
78464
+ continued: whereGroupContinues(groupKey, itemIndex),
78246
78465
  children: []
78247
78466
  };
78248
78467
  }
78468
+ afterHole = false;
78249
78469
  group.children.push(itemNode);
78250
78470
  };
78251
78471
  // The text of the items a filler stands for, cut in chunks aligned on the
@@ -78288,13 +78508,13 @@ const useRunItems = (store, {
78288
78508
  // one run, and what sits before or after it (a header, items given one by
78289
78509
  // one) is not virtualized at all.
78290
78510
  if (windowFrom > runStart) {
78291
- const remembered = readRemembered(runStart, windowFrom);
78511
+ const known = readKnownRooms(runStart, windowFrom);
78292
78512
  nodes.push(jsx(VirtualFiller, {
78293
78513
  edge: "before",
78294
78514
  itemCount: windowFrom - runStart,
78295
78515
  itemsPerLine: itemsPerLine,
78296
- itemsRemembered: remembered.count,
78297
- roomRemembered: remembered.room,
78516
+ itemsKnown: known.count,
78517
+ roomKnown: known.room,
78298
78518
  findChunks: getFindChunks(runStart, windowFrom)
78299
78519
  }, "navi-list-filler-before"));
78300
78520
  }
@@ -78321,6 +78541,7 @@ const useRunItems = (store, {
78321
78541
  retry: store.retry
78322
78542
  })
78323
78543
  }, `${ownerId}_failure_${failureFrom}`));
78544
+ afterHole = false;
78324
78545
  itemIndex = failureTo + 1;
78325
78546
  continue;
78326
78547
  }
@@ -78396,13 +78617,13 @@ const useRunItems = (store, {
78396
78617
  }
78397
78618
  closeGroup();
78398
78619
  if (runEnd > windowTo) {
78399
- const remembered = readRemembered(windowTo, runEnd);
78620
+ const known = readKnownRooms(windowTo, runEnd);
78400
78621
  nodes.push(jsx(VirtualFiller, {
78401
78622
  edge: "after",
78402
78623
  itemCount: runEnd - windowTo,
78403
78624
  itemsPerLine: itemsPerLine,
78404
- itemsRemembered: remembered.count,
78405
- roomRemembered: remembered.room,
78625
+ itemsKnown: known.count,
78626
+ roomKnown: known.room,
78406
78627
  findChunks: getFindChunks(windowTo, runEnd)
78407
78628
  }, "navi-list-filler-after"));
78408
78629
  }
@@ -79047,12 +79268,17 @@ const useItemStore = ({
79047
79268
  * hiddenWhileEmpty — the group leaves the flow (`display: none`) while it
79048
79269
  * holds no real item — a search that emptied it, items not
79049
79270
  * arrived yet
79271
+ * continued — set by a run of items, not by callers: the group began
79272
+ * before the first item drawn ("above" the render window,
79273
+ * or in a "hole" of items not held), and what stands
79274
+ * before that item takes no room (see data-continued)
79050
79275
  * ...rest — forwarded to the outer <li role="presentation">
79051
79276
  */
79052
79277
  const ListItemGroup = ({
79053
79278
  label,
79054
79279
  labelProps,
79055
79280
  hiddenWhileEmpty,
79281
+ continued,
79056
79282
  children,
79057
79283
  ...rest
79058
79284
  }) => {
@@ -79095,6 +79321,7 @@ const ListItemGroup = ({
79095
79321
  baseClassName: "navi_list_item_group",
79096
79322
  role: "presentation",
79097
79323
  "data-hidden-while-empty": hiddenWhileEmpty ? "" : undefined,
79324
+ "data-continued": continued,
79098
79325
  children: [jsx("span", {
79099
79326
  ...labelRest,
79100
79327
  ref: labelRef,
@@ -79341,7 +79568,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
79341
79568
  * the fillers hold for it outside the render window, what a scroll position
79342
79569
  * inside them is read with, and the least an item on its way takes (see
79343
79570
  * `renderSkeleton`). An item the list has drawn is held at the room it took
79344
- * then. Left out, it is the average of the items measured so far — once when
79571
+ * then, and an item whose run says its room (`<List.Items itemSize>`) at
79572
+ * that room. Left out, it is the average of the items measured so far — once when
79345
79573
  * the list mounts, again when a popup around it opens, and after each commit
79346
79574
  * while items are held off screen. Given, it is the worst case: the size
79347
79575
  * every item has, or, in a list whose items differ, the smallest an item can