@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.
@@ -6502,6 +6502,10 @@ const CONTROL_PROP_SET = new Set([
6502
6502
  "charGuard",
6503
6503
  "maxLengthGuard",
6504
6504
 
6505
+ // A checkbox that is a yes/no: `true` checked, `false` unchecked, instead of
6506
+ // HTML's value-or-nothing (see resolveEmptyUIState).
6507
+ "boolean",
6508
+
6505
6509
  // This control answers for itself: it registers into no group, no form and no
6506
6510
  // picker around it (see useUIStateController). It belongs here rather than
6507
6511
  // being deleted off `props` where it is read: props is the vnode's own object
@@ -43198,6 +43202,14 @@ const useUIStateController = (
43198
43202
  return ownUIState || parentUIState;
43199
43203
  })
43200
43204
  : ownUIStateSignal;
43205
+ // What the control is worth, which its action is bound to: what it
43206
+ // holds, or its own empty when it holds nothing. Apart from
43207
+ // uiStateSignal because unchecked is `undefined` there (the DOM reads any
43208
+ // other value as checked), while a yes/no unchecked is worth `false`.
43209
+ const emptyUIStateSignal = signal(emptyUIState);
43210
+ const valueSignal = computed(() =>
43211
+ uiStateOrEmpty(uiStateSignal.value, emptyUIStateSignal.value),
43212
+ );
43201
43213
 
43202
43214
  // The two-way half of a bound `signal` prop: setting it re-renders and
43203
43215
  // re-syncs via state_prop_change, but with the same value → guarded as a
@@ -43246,6 +43258,7 @@ const useUIStateController = (
43246
43258
  // travel command ended up carrying a picker's selection: see
43247
43259
  // resolveCommandValue in commands.js.
43248
43260
  ownUIStateSignal,
43261
+ valueSignal,
43249
43262
  value: controlInfo.value,
43250
43263
  // The suggestion this control started on — what tells a field showing
43251
43264
  // its default from one carrying an answer (see isUIStateHeld).
@@ -43309,8 +43322,12 @@ const useUIStateController = (
43309
43322
  }
43310
43323
  s.uiActionInternal?.(currentUIState, e);
43311
43324
  if (s.uiAction) {
43312
- debugUIState(`calling uiAction for ${controlType}`, currentUIState);
43313
- s.uiAction(currentUIState, e);
43325
+ const value = uiStateOrEmpty(
43326
+ currentUIState,
43327
+ controller.emptyUIState,
43328
+ );
43329
+ debugUIState(`calling uiAction for ${controlType}`, value);
43330
+ s.uiAction(value, e);
43314
43331
  }
43315
43332
  if (skipCommand) ; else {
43316
43333
  const command = controller.controlHostProps.command;
@@ -43814,6 +43831,7 @@ const useUIStateController = (
43814
43831
  return {
43815
43832
  controller,
43816
43833
  parentUiStateSignalHolder,
43834
+ emptyUIStateSignal,
43817
43835
  ...liveValues(),
43818
43836
  };
43819
43837
  },
@@ -43935,6 +43953,7 @@ const useUIStateController = (
43935
43953
  );
43936
43954
  scope.parentUiStateSignalHolder.value =
43937
43955
  parentUIStateController?.uiStateSignal ?? null;
43956
+ scope.emptyUIStateSignal.value = emptyUIState;
43938
43957
 
43939
43958
  const { controller } = scope;
43940
43959
  const controllerRef = controller.ref;
@@ -44145,14 +44164,11 @@ const keptAndNamedChildUIStates = (children, fallbackState, kept) => {
44145
44164
  const readNamedChildUIStates = (children, { warnNameless }) => {
44146
44165
  const values = {};
44147
44166
  for (const child of children) {
44148
- const { name, emptyUIState } = child;
44167
+ const { name } = child;
44149
44168
  // A control holding nothing writes its own empty, not a hole: the key is in
44150
44169
  // the object either way, and what is read from it keeps the shape the
44151
44170
  // reader was promised (see resolveEmptyUIState).
44152
- const uiState =
44153
- child.uiState === undefined && emptyUIState !== undefined
44154
- ? emptyUIState
44155
- : child.uiState;
44171
+ const uiState = uiStateOrEmpty(child.uiState, child.emptyUIState);
44156
44172
  if (!name) {
44157
44173
  if (isNamelessGrouping(child, uiState)) {
44158
44174
  Object.assign(
@@ -45499,11 +45515,10 @@ const useUIFacadeStateController = (props, realUIStateController) => {
45499
45515
  // whose last item was unselected holds [], the same thing clearing it
45500
45516
  // leaves — not a value that changes type on its owner the moment it
45501
45517
  // empties.
45502
- const { emptyUIState } = s.realUIStateController;
45503
- const uiStateToAdopt =
45504
- child.uiState === undefined && emptyUIState !== undefined
45505
- ? emptyUIState
45506
- : child.uiState;
45518
+ const uiStateToAdopt = uiStateOrEmpty(
45519
+ child.uiState,
45520
+ s.realUIStateController.emptyUIState,
45521
+ );
45507
45522
  s.realUIStateController.setUIState(uiStateToAdopt, propagateUpEvent);
45508
45523
  updatingRef.current = false;
45509
45524
  },
@@ -45821,8 +45836,9 @@ const resolveEmptyUIState = (props, controlType) => {
45821
45836
  if (controlType === "input" && props.type === "checkbox") {
45822
45837
  // A checkbox is a member of a set, the way HTML has it: checked it sends
45823
45838
  // its value ("on" by default), unchecked it sends nothing at all. Only one
45824
- // holding `true` is a yes/no, and a yes/no nobody said yes to is `false`.
45825
- return props.value === true ? false : undefined;
45839
+ // declared `boolean` is a yes/no, and a yes/no nobody said yes to is
45840
+ // `false`.
45841
+ return props.boolean ? false : undefined;
45826
45842
  }
45827
45843
  const stateShape = props["navi-state-shape"];
45828
45844
  if (stateShape === "array") {
@@ -45834,6 +45850,15 @@ const resolveEmptyUIState = (props, controlType) => {
45834
45850
  return undefined;
45835
45851
  };
45836
45852
 
45853
+ // What a control holding `uiState` is worth: its own empty when it holds
45854
+ // nothing, so the reader gets the shape it was promised.
45855
+ const uiStateOrEmpty = (uiState, emptyUIState) => {
45856
+ if (uiState === undefined && emptyUIState !== undefined) {
45857
+ return emptyUIState;
45858
+ }
45859
+ return uiState;
45860
+ };
45861
+
45837
45862
  // What a cleared control shows: its own empty, kept in the shape it was holding.
45838
45863
  const resolveClearedUIState = (controller) => {
45839
45864
  const { controlType, props } = controller;
@@ -46069,7 +46094,7 @@ const useControlProps = (props, {
46069
46094
  // of a form would re-render at each change of any of its fields.
46070
46095
  // eslint-disable-next-line no-unused-expressions
46071
46096
  uiStateController.ownUIStateSignal.value;
46072
- const boundAction = useAction(props.action, uiStateController.uiStateSignal);
46097
+ const boundAction = useAction(props.action, uiStateController.valueSignal);
46073
46098
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
46074
46099
  uiStateController,
46075
46100
  boundAction,
@@ -58190,7 +58215,23 @@ const css$M = /* css */`@layer navi {
58190
58215
  const InputCheckbox = props => {
58191
58216
  const defaultRef = useRef();
58192
58217
  props.ref = props.ref || defaultRef;
58193
- props.value = props.value === undefined ? "on" : props.value;
58218
+ const valueWarnedRef = useRef(false);
58219
+ if (!valueWarnedRef.current) {
58220
+ // `true` is what `boolean` writes into `value` below, and Preact hands the
58221
+ // same props object back on a re-render the component triggers itself.
58222
+ if (props.boolean && props.value !== undefined && props.value !== true) {
58223
+ valueWarnedRef.current = true;
58224
+ console.warn(`[checkbox] "boolean" is true or false, its "value" is ignored: remove value={${JSON.stringify(props.value)}}.`);
58225
+ } else if (!props.boolean && props.value === true) {
58226
+ valueWarnedRef.current = true;
58227
+ console.warn(`[checkbox] value={true} is a value like any other: unchecked is undefined. For a yes/no (true or false), use the "boolean" prop.`);
58228
+ }
58229
+ }
58230
+ if (props.boolean) {
58231
+ props.value = true;
58232
+ } else if (props.value === undefined) {
58233
+ props.value = "on";
58234
+ }
58194
58235
  if (props.headless) {
58195
58236
  return jsx(InputCheckboxHeadless, {
58196
58237
  ...props,
@@ -72814,15 +72855,16 @@ document.addEventListener(
72814
72855
  );
72815
72856
 
72816
72857
  /*
72817
- * Whether writing a scroll position now would cost the user the scroll in
72818
- * progress. A write is an instant scroll, and the browser abandons for it any
72819
- * scroll it is playing on its own, short of where that one was going: a key
72820
- * (Home, PageDown, Space), a smooth `scrollTo` or `scrollIntoView`, the status
72821
- * bar tap on iOS. A scroll following a hand is not abandoned: the wheel's next
72822
- * turn carries on, and on most engines a finger keeps dragging from wherever
72823
- * the write left it, and the fling it leaves behind once it lifts carries on
72824
- * from there too (Chrome under DevTools' touch emulation: flings written to at
72825
- * every move of a list's window went as far as flings never written to).
72858
+ * Moves a scroll by what the content moved under the user, unless the write
72859
+ * would cost them the scroll in progress. A write is an instant scroll, and
72860
+ * the browser abandons for it any scroll it is playing on its own, short of
72861
+ * where that one was going: a key (Home, PageDown, Space), a smooth `scrollTo`
72862
+ * or `scrollIntoView`, the status bar tap on iOS. A scroll following a hand is
72863
+ * not abandoned: the wheel's next turn carries on, and on most engines a
72864
+ * finger keeps dragging from wherever the write left it, and the fling it
72865
+ * leaves behind once it lifts carries on from there too (Chrome under
72866
+ * DevTools' touch emulation: flings written to at every move of a list's
72867
+ * window went as far as flings never written to).
72826
72868
  *
72827
72869
  * A fling must not be taken for a scroll the browser plays on its own. A list
72828
72870
  * then leaves uncorrected, for the whole fling, the room its fillers lose above
@@ -72849,6 +72891,18 @@ document.addEventListener(
72849
72891
  * while the finger is still on one of them, and the touchend of a removed
72850
72892
  * element never reaches the document.
72851
72893
  *
72894
+ * Chrome ends a scroll at every write, a finger's fling included: "scrollend"
72895
+ * a frame after the write, and the fling goes on a frame later as a scroll of
72896
+ * its own, as far as it was going (a bare page written to every 120ms of a
72897
+ * fling: 9 "scrollend" for 8 writes, the fling as long). Taken for a new
72898
+ * scroll, it is nobody's once it starts far enough from the finger's last
72899
+ * move, and the list's corrections are refused for the rest of the fling: in
72900
+ * wematch, cards drawn above the screen pushing it down at every window slide.
72901
+ * So a scroll starting right after the "scrollend" of a scroll written to here
72902
+ * goes on as that scroll: same start, same hand. Only a written one: a scroll
72903
+ * starting right after one that ended on its own is a new one, a key or a
72904
+ * smooth `scrollTo` must not pass for the fling before it.
72905
+ *
72852
72906
  * In a browser without "scrollend", no write is said to interrupt anything: a
72853
72907
  * scroll would never be seen ending.
72854
72908
  */
@@ -72858,8 +72912,8 @@ document.addEventListener(
72858
72912
  // hand on a scroll that came from elsewhere.
72859
72913
  const WHEEL_REACH = 100;
72860
72914
  // The fling Chrome plays as a scroll of its own starts a frame or two after the
72861
- // drag's last move (DevTools' touch emulation, 4× CPU: 12ms after the drag's
72862
- // "scrollend").
72915
+ // "scrollend" of the drag, or of a write (DevTools' touch emulation, 4× CPU:
72916
+ // 12ms after the drag's, 24 to 40ms after a write's in wematch).
72863
72917
  const FLING_REACH = 150;
72864
72918
  // iOS's touch handling, the one engine whose finger drops writes (see above).
72865
72919
  const FINGER_KEEPS_WRITES = !window.CSS.supports(
@@ -72868,14 +72922,41 @@ const FINGER_KEEPS_WRITES = !window.CSS.supports(
72868
72922
  );
72869
72923
 
72870
72924
  const flightMap = new WeakMap();
72925
+ // Scrollers written to since their last "scrollend": that "scrollend" may be
72926
+ // the write's.
72927
+ const writtenScrollerSet = new WeakSet();
72928
+ // The flight of a written scroller, from its "scrollend" until a scroll goes on
72929
+ // with it.
72930
+ const writtenFlightEndMap = new WeakMap();
72871
72931
  let fingersDown = 0;
72872
72932
  let wheelTimeStamp = -Infinity;
72873
72933
  let fingerScrollTimeStamp = -Infinity;
72874
72934
 
72875
72935
  /**
72936
+ * Answers whether it wrote.
72937
+ *
72876
72938
  * @param {Element} scrollerEl
72939
+ * @param {number} delta
72940
+ * @param {{ horizontal?: boolean }} [options]
72877
72941
  * @returns {boolean}
72878
72942
  */
72943
+ const scrollByUnlessInterrupting = (
72944
+ scrollerEl,
72945
+ delta,
72946
+ { horizontal } = {},
72947
+ ) => {
72948
+ if (wouldInterruptScroll(scrollerEl)) {
72949
+ return false;
72950
+ }
72951
+ writtenScrollerSet.add(scrollerEl);
72952
+ if (horizontal) {
72953
+ scrollerEl.scrollLeft += delta;
72954
+ } else {
72955
+ scrollerEl.scrollTop += delta;
72956
+ }
72957
+ return true;
72958
+ };
72959
+
72879
72960
  const wouldInterruptScroll = (scrollerEl) => {
72880
72961
  const flight = flightMap.get(scrollerEl);
72881
72962
  if (!flight) {
@@ -72918,6 +72999,15 @@ if ("onscrollend" in window) {
72918
72999
  if (flightMap.has(scroller)) {
72919
73000
  return;
72920
73001
  }
73002
+ const writtenFlightEnd = writtenFlightEndMap.get(scroller);
73003
+ writtenFlightEndMap.delete(scroller);
73004
+ if (
73005
+ writtenFlightEnd &&
73006
+ e.timeStamp - writtenFlightEnd.timeStamp < FLING_REACH
73007
+ ) {
73008
+ flightMap.set(scroller, writtenFlightEnd.flight);
73009
+ return;
73010
+ }
72921
73011
  flightMap.set(scroller, { timeStamp: e.timeStamp });
72922
73012
  },
72923
73013
  { capture: true, passive: true },
@@ -72925,7 +73015,13 @@ if ("onscrollend" in window) {
72925
73015
  document.addEventListener(
72926
73016
  "scrollend",
72927
73017
  (e) => {
72928
- flightMap.delete(asScroller(e.target));
73018
+ const scroller = asScroller(e.target);
73019
+ const flight = flightMap.get(scroller);
73020
+ flightMap.delete(scroller);
73021
+ if (flight && writtenScrollerSet.has(scroller)) {
73022
+ writtenFlightEndMap.set(scroller, { flight, timeStamp: e.timeStamp });
73023
+ }
73024
+ writtenScrollerSet.delete(scroller);
72929
73025
  },
72930
73026
  { capture: true, passive: true },
72931
73027
  );
@@ -73647,13 +73743,24 @@ const createListItems = () => {
73647
73743
  }
73648
73744
  return null;
73649
73745
  },
73650
- // The room the item at that index took when it was last drawn, or
73651
- // undefined: never drawn, not held, or not an item of a run.
73746
+ // The room the item at that index takes: the room it took when it was
73747
+ // last drawn, else the room its run is given for it (see List.Items'
73748
+ // itemSize), else undefined.
73652
73749
  roomAt: (index) => {
73653
73750
  for (const locator of locatorByOwner.values()) {
73654
- const id = locator.idAt(index);
73655
- if (id !== undefined) {
73656
- return roomById.get(id);
73751
+ const room = locator.roomAt(index);
73752
+ if (room !== undefined) {
73753
+ return room;
73754
+ }
73755
+ }
73756
+ return undefined;
73757
+ },
73758
+ // The room the run of that index is given for it, whatever was measured.
73759
+ givenRoomAt: (index) => {
73760
+ for (const locator of locatorByOwner.values()) {
73761
+ const room = locator.givenRoomAt(index);
73762
+ if (room !== undefined) {
73763
+ return room;
73657
73764
  }
73658
73765
  }
73659
73766
  return undefined;
@@ -75142,6 +75249,23 @@ const css$x = /* css */`@property --list-group-label-height {
75142
75249
  &[data-hidden-while-empty]:not(:has([navi-list-item-real])) {
75143
75250
  display: none;
75144
75251
  }
75252
+
75253
+ &[data-continued] {
75254
+ margin-top: calc(-1 * var(--x-list-gap, 0px));
75255
+ display: grid;
75256
+
75257
+ & > .navi_list_item_group_label, & > .navi_list_item_group_list {
75258
+ grid-area: 1 / 1;
75259
+ }
75260
+
75261
+ & > .navi_list_item_group_label {
75262
+ align-self: start;
75263
+ }
75264
+ }
75265
+
75266
+ &[data-continued="hole"] > .navi_list_item_group_label {
75267
+ visibility: hidden;
75268
+ }
75145
75269
  }
75146
75270
 
75147
75271
  .navi_list_container[data-horizontal] > .navi_list_scroll_container > .navi_list > .navi_list_item_group {
@@ -75962,6 +76086,7 @@ const useListScrollSync = ({
75962
76086
  // that changed since is measured again when drawn again, and the anchoring
75963
76087
  // holds the view across the difference. Items laid side by side in a grid
75964
76088
  // share a line, which the fillers hold at the one size.
76089
+ const givenRoomWarnedRef = useRef(false);
75965
76090
  const rememberItemRooms = () => {
75966
76091
  if (!ref.current || itemsPerLineRef.current !== 1) {
75967
76092
  return;
@@ -75970,7 +76095,22 @@ const useListScrollSync = ({
75970
76095
  if (!listEl) {
75971
76096
  return;
75972
76097
  }
75973
- readItemRooms(listEl, horizontal, listItems.roomById);
76098
+ readItemRooms(listEl, horizontal, (item, room, complete) => {
76099
+ listItems.roomById.set(item.id, room);
76100
+ if (!complete || givenRoomWarnedRef.current) {
76101
+ return;
76102
+ }
76103
+ const givenRoom = listItems.givenRoomAt(item.index);
76104
+ if (givenRoom === undefined) {
76105
+ return;
76106
+ }
76107
+ const difference = room - givenRoom;
76108
+ if (difference > -0.5 && difference < 0.5) {
76109
+ return;
76110
+ }
76111
+ givenRoomWarnedRef.current = true;
76112
+ console.warn(`List.Items itemSize gives ${givenRoom}px to the item at index ${item.index}, which took ${Math.round(room * 10) / 10}px from the end of the item before it to its own end. The items not drawn are held at the room itemSize gives, so the screen moves by the difference when they are drawn above it — and nothing corrects it during a fling on iOS.`);
76113
+ });
75974
76114
  };
75975
76115
  const updateRenderWindow = (newStart, newEnd, reason) => {
75976
76116
  const {
@@ -76732,18 +76872,14 @@ const useListScrollSync = ({
76732
76872
  if (delta > -0.5 && delta < 0.5) {
76733
76873
  return;
76734
76874
  }
76735
- if (wouldInterruptScroll(scrollerEl)) {
76736
- // As the anchoring's write would (see holdAnchorStill). The document's
76737
- // box is the scroller of a list scrolling the page, and it resizes with
76738
- // every item drawn taller than its filler: while the page scrolls, that
76739
- // is at every window move.
76740
- return;
76741
- }
76742
- anchorRef.current = null;
76743
- if (horizontal) {
76744
- scrollerEl.scrollLeft += delta;
76745
- } else {
76746
- scrollerEl.scrollTop += delta;
76875
+ // Not into a scroll it would cut short, as the anchoring's write (see
76876
+ // holdAnchorStill). The document's box is the scroller of a list
76877
+ // scrolling the page, and it resizes with every item drawn taller than
76878
+ // its filler: while the page scrolls, that is at every window move.
76879
+ if (scrollByUnlessInterrupting(scrollerEl, delta, {
76880
+ horizontal
76881
+ })) {
76882
+ anchorRef.current = null;
76747
76883
  }
76748
76884
  };
76749
76885
  const observer = new ResizeObserver(entries => {
@@ -76836,19 +76972,15 @@ const useListScrollSync = ({
76836
76972
  return;
76837
76973
  }
76838
76974
  anchorRef.current = null;
76839
- if (wouldInterruptScroll(scrollerEl)) {
76840
- // Nobody is looking at items standing still: they are passing by, on
76841
- // their way somewhere the browser takes them (a key, a smooth scrollTo,
76842
- // the status bar, a fling on iOS). The write would stop that scroll short
76843
- // of where it was going. Left alone, the items drawn above push the rest
76844
- // by `drift` while all of it moves, and the scroll lands.
76845
- return;
76846
- }
76847
- scrolledByListRef.current = true;
76848
- if (horizontal) {
76849
- scrollerEl.scrollLeft += drift;
76850
- } else {
76851
- scrollerEl.scrollTop += drift;
76975
+ // Not written into a scroll nobody is looking at: its items are passing
76976
+ // by, on their way somewhere the browser takes them (a key, a smooth
76977
+ // scrollTo, the status bar, a fling on iOS). The write would stop that
76978
+ // scroll short of where it was going. Left alone, the items drawn above
76979
+ // push the rest by `drift` while all of it moves, and the scroll lands.
76980
+ if (scrollByUnlessInterrupting(scrollerEl, drift, {
76981
+ horizontal
76982
+ })) {
76983
+ scrolledByListRef.current = true;
76852
76984
  }
76853
76985
  };
76854
76986
  useLayoutEffect(holdAnchorStill);
@@ -77983,18 +78115,17 @@ const readWindowGeometry = (scrollerEl, listEl, horizontal) => {
77983
78115
  };
77984
78116
  };
77985
78117
  // The room each item drawn takes along the scroll axis (see
77986
- // rememberItemRooms): up to the next item when that one is drawn too, so that
77987
- // what stands between them — a separator, the label of a group the next one
77988
- // opens — is counted; its own box otherwise. A stand-in is not measured: it
77989
- // takes the room it is given, not the room of its item.
77990
- const readItemRooms = (listEl, horizontal, roomById) => {
78118
+ // rememberItemRooms): from where the item before it ends to where it ends, so
78119
+ // that what stands between the two — a separator, the label of the group it
78120
+ // opens — is counted with the item it comes with. A group the window cuts
78121
+ // draws its label without room (see the data-continued rule above): that
78122
+ // label's room is held by the item that opens the group, in the filler. The
78123
+ // first item drawn is measured from the end of what stands before it in the
78124
+ // list (a filler, a header); a stand-in is not measured, it takes the room it
78125
+ // is given and not the room of its item, but an item after it ends where it
78126
+ // ends. `complete` says whether the room was read up to an end before it.
78127
+ const readItemRooms = (listEl, horizontal, onRoom) => {
77991
78128
  let previous = null;
77992
- const keepRoom = (item, next) => {
77993
- if (item.id === null) {
77994
- return;
77995
- }
77996
- roomById.set(item.id, next && next.index === item.index + 1 ? next.from - item.from : item.to - item.from);
77997
- };
77998
78129
  for (const itemEl of listEl.querySelectorAll(`[${LIST_ITEM_INDEX_ATTRIBUTE}]`)) {
77999
78130
  if (!isOwnItem(itemEl, listEl)) {
78000
78131
  continue;
@@ -78009,14 +78140,41 @@ const readItemRooms = (listEl, horizontal, roomById) => {
78009
78140
  from: horizontal ? rect.left : rect.top,
78010
78141
  to: horizontal ? rect.right : rect.bottom
78011
78142
  };
78012
- if (previous) {
78013
- keepRoom(previous, item);
78143
+ if (item.id !== null) {
78144
+ const endBefore = previous && previous.index === item.index - 1 ? previous.to : readEndBefore(itemEl, listEl, horizontal);
78145
+ if (endBefore === null) {
78146
+ onRoom(item, item.to - item.from, false);
78147
+ } else {
78148
+ onRoom(item, item.to - endBefore, true);
78149
+ }
78014
78150
  }
78015
78151
  previous = item;
78016
78152
  }
78017
- if (previous) {
78018
- keepRoom(previous, null);
78153
+ };
78154
+ // Where what stands before an item in the list ends: the element before the
78155
+ // item, or before its group when it is the first of it — or the start of the
78156
+ // list's content when nothing does. Null when the element before is not laid
78157
+ // out.
78158
+ const readEndBefore = (itemEl, listEl, horizontal) => {
78159
+ let slotEl = itemEl;
78160
+ const groupListEl = itemEl.parentElement;
78161
+ if (groupListEl && groupListEl.classList.contains("navi_list_item_group_list") && groupListEl.firstElementChild === itemEl) {
78162
+ slotEl = groupListEl.closest(".navi_list_item_group") || itemEl;
78163
+ }
78164
+ const beforeEl = slotEl.previousElementSibling;
78165
+ if (beforeEl) {
78166
+ const rect = beforeEl.getBoundingClientRect();
78167
+ if (rect.width === 0 && rect.height === 0) {
78168
+ return null;
78169
+ }
78170
+ return horizontal ? rect.right : rect.bottom;
78171
+ }
78172
+ if (slotEl.parentElement !== listEl) {
78173
+ return null;
78019
78174
  }
78175
+ const listRect = listEl.getBoundingClientRect();
78176
+ const listStyle = window.getComputedStyle(listEl);
78177
+ return horizontal ? listRect.left + parseFloat(listStyle.borderLeftWidth) + parseFloat(listStyle.paddingLeft) : listRect.top + parseFloat(listStyle.borderTopWidth) + parseFloat(listStyle.paddingTop);
78020
78178
  };
78021
78179
  // The first item the screen shows from `position` on, and the item after the
78022
78180
  // last one it shows up to `position` (exclusive, like the window's end). An
@@ -78566,6 +78724,8 @@ const UnorderedList = ({
78566
78724
  grid: trackColumns ? true : undefined,
78567
78725
  gridTemplateColumns: trackColumns,
78568
78726
  ...rest,
78727
+ // The gap a group the window cuts takes back (see data-continued).
78728
+ style: withListGap(rest.style, spacing),
78569
78729
  spacing: spacing,
78570
78730
  baseClassName: "navi_list",
78571
78731
  children: [!suppressFallback && searchFallbackShown && jsx(SearchFallback, {
@@ -78598,6 +78758,21 @@ const UnorderedList = ({
78598
78758
  })]
78599
78759
  });
78600
78760
  };
78761
+ // The list's spacing as a variable, next to the style it is given (an object
78762
+ // or a css string, as Box takes either).
78763
+ const withListGap = (style, spacing) => {
78764
+ if (spacing === undefined) {
78765
+ return style;
78766
+ }
78767
+ const gap = stringifySpacingStyle(spacing, "gap");
78768
+ if (typeof style === "string") {
78769
+ return `${style};--x-list-gap:${gap}`;
78770
+ }
78771
+ return {
78772
+ ...style,
78773
+ "--x-list-gap": gap
78774
+ };
78775
+ };
78601
78776
 
78602
78777
  // The "no match" message. Whether it shows is decided by ListUI (see its
78603
78778
  // searchFallbackShown): a search left nothing to display, and the caller did
@@ -78635,19 +78810,20 @@ const Fallback = ({
78635
78810
  // every item it draws must not be redrawn — every item of it — because the size
78636
78811
  // settled after the first commit.
78637
78812
  // It holds lines: the items side by side on one take its room together, and a
78638
- // last line left short takes it all the same. An item the list drew before
78639
- // takes the room it took then instead (see rememberItemRooms).
78813
+ // last line left short takes it all the same. An item whose room is known takes
78814
+ // that room instead: the one it took when the list drew it before (see
78815
+ // rememberItemRooms), or the one its run is given (see List.Items' itemSize).
78640
78816
  const VirtualFiller = ({
78641
78817
  edge,
78642
78818
  itemCount,
78643
78819
  itemsPerLine,
78644
- itemsRemembered,
78645
- roomRemembered,
78820
+ itemsKnown,
78821
+ roomKnown,
78646
78822
  findChunks
78647
78823
  }) => {
78648
78824
  const listItems = useContext(ListItemsContext);
78649
78825
  const lineSize = listItems.virtualItemSizeSignal.value;
78650
- const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize - itemsRemembered * lineSize / itemsPerLine + roomRemembered;
78826
+ const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize - itemsKnown * lineSize / itemsPerLine + roomKnown;
78651
78827
  // A filler resizing moves what stands below it — the items on screen, when it
78652
78828
  // holds the room of items above them — and it resizes in a commit of its own
78653
78829
  // when the item size settles after the list has rendered: the list puts its
@@ -79364,6 +79540,7 @@ const VISIBILITY_HIDDEN_STYLE = {
79364
79540
  * pageSize?: number,
79365
79541
  * memoryBudget?: number,
79366
79542
  * renderSkeleton?: false | ((index: number) => import("ignore:preact").ComponentChildren),
79543
+ * itemSize?: (index: number) => number,
79367
79544
  * renderError?: (failure: {error: any, retry: () => void, start: number, end: number}) => import("ignore:preact").ComponentChildren,
79368
79545
  * onRequestStateChange?: (state: {busy: boolean, refreshing: boolean, range: {start: number, end: number}|null}) => void,
79369
79546
  * }>}
@@ -79396,6 +79573,10 @@ const VISIBILITY_HIDDEN_STYLE = {
79396
79573
  * `<List.Group>` whose label (`renderGroupLabel`) stays on screen for as long
79397
79574
  * as one of them is. The groups are found in the data as it arrives, which is
79398
79575
  * the only way a list that discovers its items page by page can have any.
79576
+ * Style the label, not the group element: a group the render window cuts is
79577
+ * laid out by the list, its label over its first items, so that the label
79578
+ * and the gap before it — held in the room of the item opening the group —
79579
+ * take no room twice.
79399
79580
  * @param {(item: any, index: number) => any} [props.renderGroupLabel]
79400
79581
  * The label of the group an item opens, given that item.
79401
79582
  * @param {(item: any, index: number) => object} [props.groupLabelProps]
@@ -79414,9 +79595,26 @@ const VISIBILITY_HIDDEN_STYLE = {
79414
79595
  * further out. `Infinity` keeps every item the run ever received; `0` keeps
79415
79596
  * only the ones around the window.
79416
79597
  * @param {false|(index: number) => any} [props.renderSkeleton]
79417
- * What to draw for an item the run does not hold. Defaults to List's own
79598
+ * What to draw for an item the run does not hold, at the room that item will
79599
+ * take: a screen of skeletons alone has no item for the list to hold still,
79600
+ * and when the page lands, what is below a skeleton of the wrong size moves
79601
+ * by the difference (see docs/scroll.md). Defaults to List's own
79418
79602
  * `renderSkeleton`, then to a bare `<List.Item skeleton>`; `false` leaves the
79419
79603
  * item empty (its room is still held, or the list would jump as it loads).
79604
+ * @param {(index: number) => number} [props.itemSize]
79605
+ * The room the item at an index takes along the scroll axis, in px, known
79606
+ * before the item arrives: from where the item before it ends to where it
79607
+ * ends — what stands between the two is its own (the gap, a separator, the
79608
+ * label of the group it opens; the first item of the list has no gap before
79609
+ * it). The fillers then hold every item the list has not drawn at its own
79610
+ * room instead of List's `virtualItemSize`, and an item drawn above the
79611
+ * screen takes exactly the room held for it: nothing on screen moves. It is
79612
+ * the only way the screen holds still during a fling on iOS, where writing
79613
+ * the scroll to correct it would stop the fling. The same index
79614
+ * `renderSkeleton` is given — the two draw on the same knowledge. A room
79615
+ * measured on an item drawn wins over it, and in development one that
79616
+ * differs from it is reported. Items side by side in a grid (`columns`)
79617
+ * share a line, which keeps `virtualItemSize`.
79420
79618
  * @param {(failure: {error: any, retry: () => void, start: number, end: number}) => any} [props.renderError]
79421
79619
  * What to draw where items were asked for and never came: given the `error`,
79422
79620
  * a `retry` to call, and the `start`/`end` of the range that failed — the
@@ -79489,6 +79687,7 @@ const useRunItems = (store, {
79489
79687
  renderItem,
79490
79688
  findText,
79491
79689
  pageSize,
79690
+ itemSize,
79492
79691
  groupBy,
79493
79692
  renderGroupLabel,
79494
79693
  groupLabelProps,
@@ -79572,6 +79771,22 @@ const useRunItems = (store, {
79572
79771
  const item = getItemAt(itemIndex);
79573
79772
  return item === undefined ? undefined : idOf(item, itemIndex);
79574
79773
  };
79774
+ // The room the caller says an item takes (`itemSize`), drawn or not.
79775
+ const givenRoomOf = itemIndex => {
79776
+ if (!itemSize || itemsPerLine !== 1 || itemIndex < runStart || itemIndex >= runEnd) {
79777
+ return undefined;
79778
+ }
79779
+ const room = itemSize(itemIndex);
79780
+ return room > 0 ? room : undefined;
79781
+ };
79782
+ // The room an item took when the list last drew it (see rememberItemRooms),
79783
+ // else the room the caller gives it, else undefined: a filler holds an item
79784
+ // it knows nothing about at the item size.
79785
+ const roomOf = itemIndex => {
79786
+ const id = idAt(itemIndex);
79787
+ const remembered = id === undefined ? undefined : listItems.roomById.get(id);
79788
+ return remembered === undefined ? givenRoomOf(itemIndex) : remembered;
79789
+ };
79575
79790
  listItems.setItemLocator(ownerId, {
79576
79791
  indexOf: id => {
79577
79792
  let found = null;
@@ -79583,32 +79798,27 @@ const useRunItems = (store, {
79583
79798
  });
79584
79799
  return found;
79585
79800
  },
79586
- idAt
79801
+ roomAt: roomOf,
79802
+ givenRoomAt: givenRoomOf
79587
79803
  });
79588
- // The room an item took when the list last drew it (see rememberItemRooms),
79589
- // or undefined: a filler holds an item it never drew at the item size.
79590
- const roomOf = itemIndex => {
79591
- const id = idAt(itemIndex);
79592
- return id === undefined ? undefined : listItems.roomById.get(id);
79593
- };
79594
- const readRemembered = (from, to) => {
79595
- const remembered = {
79804
+ const readKnownRooms = (from, to) => {
79805
+ const known = {
79596
79806
  room: 0,
79597
79807
  count: 0
79598
79808
  };
79599
- if (listItems.roomById.size === 0) {
79600
- return remembered;
79809
+ if (listItems.roomById.size === 0 && !itemSize) {
79810
+ return known;
79601
79811
  }
79602
79812
  let itemIndex = from;
79603
79813
  while (itemIndex < to) {
79604
79814
  const room = roomOf(itemIndex);
79605
79815
  if (room !== undefined) {
79606
- remembered.room += room;
79607
- remembered.count++;
79816
+ known.room += room;
79817
+ known.count++;
79608
79818
  }
79609
79819
  itemIndex++;
79610
79820
  }
79611
- return remembered;
79821
+ return known;
79612
79822
  };
79613
79823
  useLayoutEffect(() => {
79614
79824
  return () => {
@@ -79729,6 +79939,7 @@ const useRunItems = (store, {
79729
79939
  nodes.push(jsx(ListItemGroup, {
79730
79940
  label: group.label,
79731
79941
  labelProps: group.labelProps,
79942
+ continued: group.continued,
79732
79943
  children: group.children
79733
79944
  }, groupCount === 1 ? `${ownerId}_group_${group.key}` : `${ownerId}_group_${group.key}_${groupCount}`));
79734
79945
  group = null;
@@ -79766,10 +79977,38 @@ const useRunItems = (store, {
79766
79977
  }
79767
79978
  return groupBy(item, itemIndex);
79768
79979
  };
79980
+ // Where a group opened at that item began, when not there (see
79981
+ // data-continued): "above" the window, or in the "hole" of items not held
79982
+ // drawn right before it. Items not held belong to the group they lead to as
79983
+ // often as not — a page lands every hundred items, a group starts a few
79984
+ // times in a thread — so a group the window starts in is taken to go on
79985
+ // above it unless the item before, held, says otherwise.
79986
+ // Along y only, never built along x: there a group stands its label over
79987
+ // its items, so the label takes no room on the axis (unless it is wider than
79988
+ // the items drawn) and only the gap would be taken back — `margin-left`,
79989
+ // without the overlay of the data-continued rule. Nothing measured it yet.
79990
+ let afterHole = false;
79991
+ const whereGroupContinues = (groupKey, itemIndex) => {
79992
+ if (listItems.horizontal) {
79993
+ return undefined;
79994
+ }
79995
+ if (afterHole) {
79996
+ return "hole";
79997
+ }
79998
+ if (itemIndex !== windowFrom || windowFrom <= runStart) {
79999
+ return undefined;
80000
+ }
80001
+ const itemBefore = getItemAt(windowFrom - 1);
80002
+ if (itemBefore === undefined || groupBy(itemBefore, windowFrom - 1) === groupKey) {
80003
+ return "above";
80004
+ }
80005
+ return undefined;
80006
+ };
79769
80007
  const pushItem = (itemNode, item, itemIndex, groupKey) => {
79770
80008
  if (groupKey === undefined) {
79771
80009
  closeGroup();
79772
80010
  nodes.push(itemNode);
80011
+ afterHole = item === undefined;
79773
80012
  return;
79774
80013
  }
79775
80014
  if (!group || group.key !== groupKey) {
@@ -79778,9 +80017,11 @@ const useRunItems = (store, {
79778
80017
  key: groupKey,
79779
80018
  label: renderGroupLabel ? renderGroupLabel(item, itemIndex) : groupKey,
79780
80019
  labelProps: groupLabelProps ? groupLabelProps(item, itemIndex) : undefined,
80020
+ continued: whereGroupContinues(groupKey, itemIndex),
79781
80021
  children: []
79782
80022
  };
79783
80023
  }
80024
+ afterHole = false;
79784
80025
  group.children.push(itemNode);
79785
80026
  };
79786
80027
  // The text of the items a filler stands for, cut in chunks aligned on the
@@ -79823,13 +80064,13 @@ const useRunItems = (store, {
79823
80064
  // one run, and what sits before or after it (a header, items given one by
79824
80065
  // one) is not virtualized at all.
79825
80066
  if (windowFrom > runStart) {
79826
- const remembered = readRemembered(runStart, windowFrom);
80067
+ const known = readKnownRooms(runStart, windowFrom);
79827
80068
  nodes.push(jsx(VirtualFiller, {
79828
80069
  edge: "before",
79829
80070
  itemCount: windowFrom - runStart,
79830
80071
  itemsPerLine: itemsPerLine,
79831
- itemsRemembered: remembered.count,
79832
- roomRemembered: remembered.room,
80072
+ itemsKnown: known.count,
80073
+ roomKnown: known.room,
79833
80074
  findChunks: getFindChunks(runStart, windowFrom)
79834
80075
  }, "navi-list-filler-before"));
79835
80076
  }
@@ -79856,6 +80097,7 @@ const useRunItems = (store, {
79856
80097
  retry: store.retry
79857
80098
  })
79858
80099
  }, `${ownerId}_failure_${failureFrom}`));
80100
+ afterHole = false;
79859
80101
  itemIndex = failureTo + 1;
79860
80102
  continue;
79861
80103
  }
@@ -79931,13 +80173,13 @@ const useRunItems = (store, {
79931
80173
  }
79932
80174
  closeGroup();
79933
80175
  if (runEnd > windowTo) {
79934
- const remembered = readRemembered(windowTo, runEnd);
80176
+ const known = readKnownRooms(windowTo, runEnd);
79935
80177
  nodes.push(jsx(VirtualFiller, {
79936
80178
  edge: "after",
79937
80179
  itemCount: runEnd - windowTo,
79938
80180
  itemsPerLine: itemsPerLine,
79939
- itemsRemembered: remembered.count,
79940
- roomRemembered: remembered.room,
80181
+ itemsKnown: known.count,
80182
+ roomKnown: known.room,
79941
80183
  findChunks: getFindChunks(windowTo, runEnd)
79942
80184
  }, "navi-list-filler-after"));
79943
80185
  }
@@ -80591,12 +80833,17 @@ const useItemStore = ({
80591
80833
  * hiddenWhileEmpty — the group leaves the flow (`display: none`) while it
80592
80834
  * holds no real item — a search that emptied it, items not
80593
80835
  * arrived yet
80836
+ * continued — set by a run of items, not by callers: the group began
80837
+ * before the first item drawn ("above" the render window,
80838
+ * or in a "hole" of items not held), and what stands
80839
+ * before that item takes no room (see data-continued)
80594
80840
  * ...rest — forwarded to the outer <li role="presentation">
80595
80841
  */
80596
80842
  const ListItemGroup = ({
80597
80843
  label,
80598
80844
  labelProps,
80599
80845
  hiddenWhileEmpty,
80846
+ continued,
80600
80847
  children,
80601
80848
  ...rest
80602
80849
  }) => {
@@ -80639,6 +80886,7 @@ const ListItemGroup = ({
80639
80886
  baseClassName: "navi_list_item_group",
80640
80887
  role: "presentation",
80641
80888
  "data-hidden-while-empty": hiddenWhileEmpty ? "" : undefined,
80889
+ "data-continued": continued,
80642
80890
  children: [jsx("span", {
80643
80891
  ...labelRest,
80644
80892
  ref: labelRef,
@@ -80885,7 +81133,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
80885
81133
  * the fillers hold for it outside the render window, what a scroll position
80886
81134
  * inside them is read with, and the least an item on its way takes (see
80887
81135
  * `renderSkeleton`). An item the list has drawn is held at the room it took
80888
- * then. Left out, it is the average of the items measured so far — once when
81136
+ * then, and an item whose run says its room (`<List.Items itemSize>`) at
81137
+ * that room. Left out, it is the average of the items measured so far — once when
80889
81138
  * the list mounts, again when a popup around it opens, and after each commit
80890
81139
  * while items are held off screen. Given, it is the worst case: the size
80891
81140
  * every item has, or, in a list whose items differ, the smallest an item can