@jsenv/navi 0.29.361 → 0.29.362

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.
@@ -26978,6 +26978,18 @@ const takeoverRoutingRenderingHold = () => {
26978
26978
  * to a reader. Kept in the session too, so a reload lands where the browser
26979
26979
  * would have landed — the flag above is a promise to do the whole job.
26980
26980
  *
26981
+ * The document is not the only scrollport of a page. A list scrolling itself
26982
+ * (a `<List expandY>` under a search field that stays put) is left and come
26983
+ * back to the same way, and the browser never knew it was a scrollport at
26984
+ * all: those say where they are by name (rememberScrollerPosition), under the
26985
+ * same URL and for the same session, and ask it back when they mount again
26986
+ * (recallScrollerPosition). What a push means for them is what it means for
26987
+ * the document — an arrival opens at the top (see startAtTop): the page
26988
+ * arrived at has its named positions dropped before its lists render, so a
26989
+ * list recalls only on the way back. A scroller that goes while its page
26990
+ * stays — a popup closing over the same address — has nothing to come back
26991
+ * to, and says so as it leaves (forgetScrollerUnlessPageLeft).
26992
+ *
26981
26993
  * What is NOT covered, and cannot be from here: a page whose height depends on
26982
26994
  * something still loading. Its content is not there at the moment it is put
26983
26995
  * back, so a position beyond what has arrived is clamped as before. Only the
@@ -26995,7 +27007,23 @@ const takeoverRoutingRenderingHold = () => {
26995
27007
  const STORAGE_KEY = "navi_scroll_positions";
26996
27008
 
26997
27009
  const positionByUrl = new Map();
26998
- const readStoredPositions = () => {
27010
+ // url -> (scroller name -> position). The position is whatever the scroller
27011
+ // handed in: what it can put itself back on, in its own terms.
27012
+ const scrollerPositionsByUrl = new Map();
27013
+ // The url each named scroller last spoke under: what tells a scroller leaving
27014
+ // a page that stays from one leaving with its page.
27015
+ const urlByScrollerName = new Map();
27016
+
27017
+ // Read once, the first time anyone needs the positions: the document's
27018
+ // restoration is installed by the routing, and a list remembering itself may
27019
+ // mount in an app that never routes.
27020
+ let storeLoaded = false;
27021
+ const loadStore = () => {
27022
+ if (storeLoaded) {
27023
+ return;
27024
+ }
27025
+ storeLoaded = true;
27026
+ window.addEventListener("pagehide", storePositions);
26999
27027
  let stored;
27000
27028
  try {
27001
27029
  stored = window.sessionStorage.getItem(STORAGE_KEY);
@@ -27008,18 +27036,31 @@ const readStoredPositions = () => {
27008
27036
  return;
27009
27037
  }
27010
27038
  try {
27011
- for (const [url, position] of Object.entries(JSON.parse(stored))) {
27039
+ const { document: documentPositions, scrollers } = JSON.parse(stored);
27040
+ for (const [url, position] of Object.entries(documentPositions || {})) {
27012
27041
  positionByUrl.set(url, position);
27013
27042
  }
27043
+ for (const [url, positionByName] of Object.entries(scrollers || {})) {
27044
+ scrollerPositionsByUrl.set(url, new Map(Object.entries(positionByName)));
27045
+ }
27014
27046
  } catch {
27015
27047
  // Something else wrote there, or it was truncated.
27016
27048
  }
27017
27049
  };
27018
27050
  const storePositions = () => {
27051
+ const scrollers = {};
27052
+ for (const [url, positionByName] of scrollerPositionsByUrl) {
27053
+ if (positionByName.size > 0) {
27054
+ scrollers[url] = Object.fromEntries(positionByName);
27055
+ }
27056
+ }
27019
27057
  try {
27020
27058
  window.sessionStorage.setItem(
27021
27059
  STORAGE_KEY,
27022
- JSON.stringify(Object.fromEntries(positionByUrl)),
27060
+ JSON.stringify({
27061
+ document: Object.fromEntries(positionByUrl),
27062
+ scrollers,
27063
+ }),
27023
27064
  );
27024
27065
  } catch {
27025
27066
  // Full, or refused: the session is the only thing lost.
@@ -27059,7 +27100,7 @@ const installScrollRestoration = () => {
27059
27100
  return;
27060
27101
  }
27061
27102
  window.history.scrollRestoration = "manual";
27062
- readStoredPositions();
27103
+ loadStore();
27063
27104
  // Read as it happens rather than when leaving: a traverse changes the url
27064
27105
  // before anything here is told, so a position read then would be read for
27065
27106
  // the wrong page.
@@ -27076,7 +27117,6 @@ const installScrollRestoration = () => {
27076
27117
  },
27077
27118
  { passive: true },
27078
27119
  );
27079
- window.addEventListener("pagehide", storePositions);
27080
27120
  // What a reload asks for, now that the browser has been told not to do it.
27081
27121
  // Once, and at the first render of a route: the position is only meaningful
27082
27122
  // once there is a page under it.
@@ -27119,18 +27159,35 @@ const restoreScrollPosition = (url) => {
27119
27159
  // The document, because the document is the scrollport in the common case. An
27120
27160
  // app that scrolls an element of its own scrolls it itself.
27121
27161
  const startAtTop = (url, { from } = {}) => {
27162
+ if (!isArrival(url, { from })) {
27163
+ return;
27164
+ }
27165
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27166
+ };
27167
+ const isArrival = (url, { from }) => {
27122
27168
  const urlObject = new URL(url, window.location.href);
27123
27169
  // A fragment names where to land, and the browser is the one that finds it.
27124
27170
  if (urlObject.hash) {
27125
- return;
27171
+ return false;
27126
27172
  }
27127
27173
  if (
27128
27174
  from !== undefined &&
27129
27175
  new URL(from, window.location.href).pathname === urlObject.pathname
27130
27176
  ) {
27177
+ return false;
27178
+ }
27179
+ return true;
27180
+ };
27181
+
27182
+ // The same arrival, for the page's own scrollers. The document is scrolled to
27183
+ // its top once the page is there; a list opens where it decides to in its
27184
+ // first render, so what it must not find is dropped before the routing
27185
+ // renders anything.
27186
+ const forgetScrollersOnArrival = (url, { from } = {}) => {
27187
+ if (!isArrival(url, { from })) {
27131
27188
  return;
27132
27189
  }
27133
- window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27190
+ scrollerPositionsByUrl.delete(new URL(url, window.location.href).href);
27134
27191
  };
27135
27192
 
27136
27193
  // An arrival at a page whose scrollport is already showing another one: the
@@ -27149,6 +27206,49 @@ const scrollTo = ({ x, y }) => {
27149
27206
  window.scrollTo({ top: y, left: x, behavior: "instant" });
27150
27207
  };
27151
27208
 
27209
+ const rememberScrollerPosition = (name, position) => {
27210
+ loadStore();
27211
+ const url = window.location.href;
27212
+ let positionByName = scrollerPositionsByUrl.get(url);
27213
+ if (!positionByName) {
27214
+ positionByName = new Map();
27215
+ scrollerPositionsByUrl.set(url, positionByName);
27216
+ }
27217
+ positionByName.set(name, position);
27218
+ urlByScrollerName.set(name, url);
27219
+ };
27220
+
27221
+ const recallScrollerPosition = (name) => {
27222
+ loadStore();
27223
+ const positionByName = scrollerPositionsByUrl.get(window.location.href);
27224
+ if (!positionByName) {
27225
+ return undefined;
27226
+ }
27227
+ return positionByName.get(name);
27228
+ };
27229
+
27230
+ // Said by a scroller as it unmounts. Its page is being left when the url is
27231
+ // already another one — the history is written before the page it names is
27232
+ // taken down — and then its position is kept for the way back. The url still
27233
+ // being the one it spoke under means the page stays and the scroller alone
27234
+ // goes (a popup closing, a section folding): there is no coming back to a
27235
+ // place that was not left, and a position kept would greet the next mount
27236
+ // under this address as a return.
27237
+ const forgetScrollerUnlessPageLeft = (name) => {
27238
+ const url = urlByScrollerName.get(name);
27239
+ if (url === undefined) {
27240
+ return;
27241
+ }
27242
+ urlByScrollerName.delete(name);
27243
+ if (url !== window.location.href) {
27244
+ return;
27245
+ }
27246
+ const positionByName = scrollerPositionsByUrl.get(url);
27247
+ if (positionByName) {
27248
+ positionByName.delete(name);
27249
+ }
27250
+ };
27251
+
27152
27252
  /**
27153
27253
  * A navigation is ABOUT to be applied — said before its very first write.
27154
27254
  *
@@ -27511,6 +27611,13 @@ const setupBrowserIntegrationViaHistory = ({
27511
27611
  return undefined;
27512
27612
  }
27513
27613
 
27614
+ // The page's own scrollers are told of an arrival before the routing
27615
+ // renders anything: a list arriving decides where it opens in its first
27616
+ // render (see scroll_restoration.js). The document itself is moved once
27617
+ // the page is there, below.
27618
+ if (navigationType === "push") {
27619
+ forgetScrollersOnArrival(url, { from: urlLeft });
27620
+ }
27514
27621
  if (abortController) {
27515
27622
  abortController.abort(`navigating to ${url}`);
27516
27623
  }
@@ -67205,10 +67312,11 @@ const ListUI = props => {
67205
67312
  onListVisibleItemsChange,
67206
67313
  virtualItemSize,
67207
67314
  scrolled,
67208
- defaultScrolled = "start",
67315
+ defaultScrolled: defaultScrolledProp = "start",
67209
67316
  onScrolledChange,
67210
67317
  scroller = "self",
67211
67318
  hoverWhileScrolling = false,
67319
+ scrollResetOnNavigation = false,
67212
67320
  lockSize,
67213
67321
  columns,
67214
67322
  itemColumns,
@@ -67227,6 +67335,20 @@ const ListUI = props => {
67227
67335
  listRows,
67228
67336
  ...rest
67229
67337
  } = props;
67338
+ // Remembered by name, and a name made up at render (see ListFirstResolver)
67339
+ // names no list a later mount would recognize.
67340
+ const rememberScroll = !scrollResetOnNavigation && !isLikelyPreactGeneratedId(rest.id);
67341
+ // Where the list was when its screen was left, when this is the way back
67342
+ // (see scroll_restoration.js). Read once: `defaultScrolled` is held by
67343
+ // reference, and a place read again each render would be a list moved each
67344
+ // render. A list held by its caller (`scrolled`) is where the caller says.
67345
+ const [scrolledRemembered] = useState(() => {
67346
+ if (!rememberScroll || scrolled !== undefined && scrolled !== null) {
67347
+ return undefined;
67348
+ }
67349
+ return recallScrollerPosition(rest.id);
67350
+ });
67351
+ const defaultScrolled = scrolledRemembered || defaultScrolledProp;
67230
67352
  const scrollBoxPaddingProps = {};
67231
67353
  for (const name of LIST_PADDING_PROP_SET) {
67232
67354
  if (name in rest) {
@@ -67321,6 +67443,8 @@ const ListUI = props => {
67321
67443
  scrolled,
67322
67444
  defaultScrolled,
67323
67445
  onScrolledChange,
67446
+ rememberScroll,
67447
+ listId: rest.id,
67324
67448
  scroller,
67325
67449
  searchText,
67326
67450
  horizontal
@@ -67353,7 +67477,7 @@ const ListUI = props => {
67353
67477
  // locator). Both answer here, so a row is reachable whether or not the
67354
67478
  // window happens to frame it.
67355
67479
  const getItemById = itemId => {
67356
- const itemDrawn = listRows.itemsSignal.peek().find(item => item.id === itemId);
67480
+ const itemDrawn = listRows.itemsSignal.peek().find(item => item.itemId === itemId);
67357
67481
  if (itemDrawn) {
67358
67482
  return itemDrawn;
67359
67483
  }
@@ -67363,6 +67487,7 @@ const ListUI = props => {
67363
67487
  }
67364
67488
  return {
67365
67489
  id: itemId,
67490
+ itemId,
67366
67491
  index: rowIndex
67367
67492
  };
67368
67493
  };
@@ -67645,6 +67770,8 @@ const useListScrollSync = ({
67645
67770
  scrolled,
67646
67771
  defaultScrolled,
67647
67772
  onScrolledChange,
67773
+ rememberScroll,
67774
+ listId,
67648
67775
  scroller,
67649
67776
  searchText,
67650
67777
  horizontal
@@ -67857,15 +67984,18 @@ const useListScrollSync = ({
67857
67984
  `"${event.type}" on ${getElementSignature(event.target)} (${reason})`;
67858
67985
  // When we display the list we prefer to have selected item at the center
67859
67986
  // otherwise, usually when focused by arrow nav, we want to keep it into view close to the nearest edge
67860
- const block = blockRequested || (event.type === "navi_displayed" ? "center" : "nearest");
67861
- `${getElementSignature(itemEl)}.scrollIntoView({ block: "${block}", container: "nearest" })`;
67987
+ const align = blockRequested || (event.type === "navi_displayed" ? "center" : "nearest");
67988
+ `${getElementSignature(itemEl)}.scrollIntoView({ block: "${align}", inline: "${align}", container: "nearest" })`;
67862
67989
  // The list is going somewhere on purpose, so there is no view to hold
67863
67990
  // still any more: an anchor captured before this drop it, or it would
67864
67991
  // put the list back where it was the moment the rows move under it.
67865
67992
  anchorRef.current = null;
67993
+ // One alignment, said on both axes: the axis the list scrolls on is the
67994
+ // one that reads it, and the other has nothing to move.
67866
67995
  scrollIntoViewScoped(itemEl, {
67867
67996
  container: getScroller(),
67868
- block
67997
+ block: align,
67998
+ inline: align
67869
67999
  });
67870
68000
  const listEl = getListEl();
67871
68001
  dispatchPublicCustomEvent(listEl, "navi_scroll", {
@@ -67877,7 +68007,7 @@ const useListScrollSync = ({
67877
68007
  // Whether the row is drawn is asked of the dom, not of the render window:
67878
68008
  // the window says what a run draws, and a list whose rows are declared one
67879
68009
  // by one has them all in the dom whatever the window says.
67880
- const itemEl = findRowElement(getListEl(), item.id);
68010
+ const itemEl = findRowElement(getListEl(), item.itemId);
67881
68011
  if (itemEl) {
67882
68012
  scrollItemIntoView(itemEl);
67883
68013
  return;
@@ -67885,7 +68015,7 @@ const useListScrollSync = ({
67885
68015
  // Not in DOM — shift the render window. The item will read
67886
68016
  // pendingScrollRef on mount and scroll into view.
67887
68017
  pendingScrollRef.current = {
67888
- id: item.id,
68018
+ id: item.itemId,
67889
68019
  resolve: itemEl => {
67890
68020
  pendingScrollRef.current = null;
67891
68021
  scrollItemIntoView(itemEl);
@@ -68236,6 +68366,8 @@ const useListScrollSync = ({
68236
68366
  // one was looking at is then somewhere else.
68237
68367
  const onScrolledChangeRef = useRef(null);
68238
68368
  onScrolledChangeRef.current = onScrolledChange;
68369
+ const rememberScrollRef = useRef(false);
68370
+ rememberScrollRef.current = rememberScroll;
68239
68371
  // Where the list was at the last thing that moved it. Kept whether anyone
68240
68372
  // asked for it or not: it is what a resize needs to put things back.
68241
68373
  const positionRef = useRef(null);
@@ -68253,16 +68385,32 @@ const useListScrollSync = ({
68253
68385
  return;
68254
68386
  }
68255
68387
  positionRef.current = position;
68256
- if (!onScrolledChangeRef.current) {
68388
+ const remember = rememberScrollRef.current;
68389
+ const onScrolledChange = onScrolledChangeRef.current;
68390
+ if (!remember && !onScrolledChange) {
68257
68391
  return;
68258
68392
  }
68259
68393
  const rowEl = findRowElement(getListEl(), position.id);
68260
- onScrolledChangeRef.current({
68394
+ const scrolledNow = {
68261
68395
  id: position.id,
68262
68396
  index: position.index,
68263
68397
  offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal)
68264
- });
68398
+ };
68399
+ if (remember) {
68400
+ rememberScrollerPosition(listId, scrolledNow);
68401
+ }
68402
+ if (onScrolledChange) {
68403
+ onScrolledChange(scrolledNow);
68404
+ }
68265
68405
  };
68406
+ // Leaving: with its page, or alone (see forgetScrollerUnlessPageLeft).
68407
+ useLayoutEffect(() => {
68408
+ return () => {
68409
+ if (rememberScrollRef.current) {
68410
+ forgetScrollerUnlessPageLeft(listId);
68411
+ }
68412
+ };
68413
+ }, []);
68266
68414
 
68267
68415
  // A list that gets narrower rewraps every row it holds, so everything below
68268
68416
  // moves and the reader loses their place — the very thing scrolling a long
@@ -68335,7 +68483,7 @@ const useListScrollSync = ({
68335
68483
  return;
68336
68484
  }
68337
68485
  const items = listRows.visibleItemsSignal.peek();
68338
- const itemNow = items.find(i => i.id === anchor.id);
68486
+ const itemNow = items.find(i => i.itemId === anchor.id);
68339
68487
  if (!itemNow) {
68340
68488
  anchorRef.current = null;
68341
68489
  return;
@@ -68938,17 +69086,22 @@ const resolveScrollInset = (value, viewportSize) => {
68938
69086
  return number;
68939
69087
  };
68940
69088
 
68941
- // The row with that id, IN THIS LIST. Not document.getElementById: an id is
68942
- // only ever unique within a list — two lists on the same page can be showing
68943
- // the same collection and a list acting on a row that belongs to another one
68944
- // is a spectacular kind of wrong (it scrolls to hold still something it is not
68945
- // even showing).
69089
+ // The row of that name, IN THIS LIST by the name the list knows it under
69090
+ // (see ListItemUI), not the element's id. Not document.getElementById either:
69091
+ // a name is only ever unique within a list two lists on the same page can be
69092
+ // showing the same collection and a list acting on a row that belongs to
69093
+ // another one is a spectacular kind of wrong (it scrolls to hold still
69094
+ // something it is not even showing).
68946
69095
  const findRowElement = (listEl, id) => {
68947
- return listEl.querySelector(`[id="${CSS.escape(id)}"]`);
69096
+ return listEl.querySelector(`[navi-list-item-real="${CSS.escape(id)}"]`);
68948
69097
  };
69098
+ const getRowName = rowEl => rowEl.getAttribute("navi-list-item-real");
68949
69099
 
68950
69100
  // The row the user is looking at, and where it sits: what must not move when
68951
- // the list is rebuilt around it.
69101
+ // the list is rebuilt around it. Read off the rows' own boxes, not by
69102
+ // hit-testing the screen: a scrolling list takes its rows out of hit-testing
69103
+ // (see the navi-scrolling rule in the css above), and the scroll event is
69104
+ // precisely when this is asked.
68952
69105
  const captureScrollAnchor = ({
68953
69106
  scrollerEl,
68954
69107
  listEl,
@@ -68960,30 +69113,31 @@ const captureScrollAnchor = ({
68960
69113
  }
68961
69114
  const viewportRect = getScrollerViewportRect(scrollerEl);
68962
69115
  const listRect = listEl.getBoundingClientRect();
68963
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
68964
- if (!scanRange) {
69116
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
69117
+ if (!range) {
68965
69118
  return null;
68966
69119
  }
69120
+ const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
69121
+ const {
69122
+ rowEls,
69123
+ index
69124
+ } = findRowsFrom(listEl, range.from, horizontal);
68967
69125
  let fallbackAnchor = null;
68968
- for (let pos = scanRange.from + 1; pos < scanRange.to; pos += 8) {
68969
- const x = horizontal ? pos : scanRange.crossPos;
68970
- const y = horizontal ? scanRange.crossPos : pos;
68971
- const el = document.elementFromPoint(x, y);
68972
- if (!el || !listEl.contains(el)) {
68973
- continue;
68974
- }
68975
- const itemEl = el.closest(REAL_LIST_ITEM_SELECTOR);
68976
- if (!itemEl) {
68977
- continue;
69126
+ for (let i = index; i < rowEls.length; i++) {
69127
+ const rowEl = rowEls[i];
69128
+ const rowRect = rowEl.getBoundingClientRect();
69129
+ const rowStart = horizontal ? rowRect.left : rowRect.top;
69130
+ if (rowStart >= range.to) {
69131
+ break;
68978
69132
  }
68979
- const item = items.find(i => i.id === itemEl.id);
69133
+ const rowName = getRowName(rowEl);
69134
+ const item = items.find(i => i.itemId === rowName);
68980
69135
  if (!item) {
68981
69136
  continue;
68982
69137
  }
68983
- const itemRect = itemEl.getBoundingClientRect();
68984
- const offset = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
69138
+ const offset = rowStart - viewportFrom;
68985
69139
  const anchor = {
68986
- id: item.id,
69140
+ id: item.itemId,
68987
69141
  index: item.index,
68988
69142
  offset
68989
69143
  };
@@ -69003,43 +69157,61 @@ const captureScrollAnchor = ({
69003
69157
  // The part of the list that is on screen, along the scrolling axis. Both edges
69004
69158
  // matter: the scroller may be larger than the list (scroller="parent") as well
69005
69159
  // as smaller (the list scrolls inside its own box).
69006
- const getListVisibleScanRange = (viewportRect, listRect, horizontal) => {
69007
- // The screen has a say too: what is asked here is answered by
69008
- // elementFromPoint, which only knows about points that are actually on it. A
69009
- // list whose scroll box hangs below the fold of the page would otherwise be
69010
- // probed where nothing can be hit — and would silently stop keeping its rows
69011
- // still, which is exactly when it matters.
69012
- const screenTo = horizontal ? document.documentElement.clientWidth : document.documentElement.clientHeight;
69160
+ const getListVisibleRange = (viewportRect, listRect, horizontal) => {
69013
69161
  const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
69014
69162
  const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
69015
69163
  const listFrom = horizontal ? listRect.left : listRect.top;
69016
69164
  const listTo = horizontal ? listRect.right : listRect.bottom;
69017
- let from = listFrom > viewportFrom ? listFrom : viewportFrom;
69018
- let to = listTo < viewportTo ? listTo : viewportTo;
69019
- if (from < 0) {
69020
- from = 0;
69021
- }
69022
- if (to > screenTo) {
69023
- to = screenTo;
69024
- }
69165
+ const from = listFrom > viewportFrom ? listFrom : viewportFrom;
69166
+ const to = listTo < viewportTo ? listTo : viewportTo;
69025
69167
  if (to - from < 2) {
69026
69168
  return null;
69027
69169
  }
69028
- // Where to put the probe on the other axis: inside the list, inside the
69029
- // viewport.
69030
- const crossFrom = horizontal ? listRect.top : listRect.left;
69031
- const crossViewportFrom = horizontal ? viewportRect.top : viewportRect.left;
69032
- const crossPos = (crossFrom > crossViewportFrom ? crossFrom : crossViewportFrom) + 1;
69033
69170
  return {
69034
69171
  from,
69035
- to,
69036
- crossPos
69172
+ to
69037
69173
  };
69038
69174
  };
69175
+ // The real rows of the list, and the first of them reaching past `from` along
69176
+ // the scroll axis. A binary search over their boxes: the rows stand in
69177
+ // document order along that axis, so their far edges only grow.
69178
+ const findRowsFrom = (listEl, from, horizontal) => {
69179
+ const rowEls = listEl.querySelectorAll(REAL_LIST_ITEM_SELECTOR);
69180
+ let low = 0;
69181
+ let high = rowEls.length;
69182
+ while (low < high) {
69183
+ const mid = low + high >> 1;
69184
+ const rect = rowEls[mid].getBoundingClientRect();
69185
+ const end = horizontal ? rect.right : rect.bottom;
69186
+ if (end > from) {
69187
+ high = mid;
69188
+ } else {
69189
+ low = mid + 1;
69190
+ }
69191
+ }
69192
+ return {
69193
+ rowEls,
69194
+ index: low
69195
+ };
69196
+ };
69197
+ // Whether a filler (the room held for rows outside the window) is what stands
69198
+ // at that position along the scroll axis.
69199
+ const isFillerAt = (listEl, position, horizontal) => {
69200
+ for (const fillerEl of listEl.querySelectorAll("[navi-virtual-filler]")) {
69201
+ const rect = fillerEl.getBoundingClientRect();
69202
+ const from = horizontal ? rect.left : rect.top;
69203
+ const to = horizontal ? rect.right : rect.bottom;
69204
+ if (position >= from && position < to) {
69205
+ return true;
69206
+ }
69207
+ }
69208
+ return false;
69209
+ };
69039
69210
 
69040
- // Which row of the collection sits at the current scroll position. Uses DOM
69041
- // hit-testing when a real row is there to be hit, and the row size when what is
69042
- // on screen is only reserved room.
69211
+ // Which row of the collection sits at the current scroll position. Read off
69212
+ // the rows' boxes when a real row is there (see captureScrollAnchor for why
69213
+ // not hit-testing), and from the row size when what is on screen is only
69214
+ // reserved room.
69043
69215
  // Returns { index, item, reason } or null if nothing can be determined.
69044
69216
  const getScrollInfo = ({
69045
69217
  scrollValues,
@@ -69053,34 +69225,31 @@ const getScrollInfo = ({
69053
69225
  const items = listRows.itemsSignal.peek();
69054
69226
  const viewportRect = getScrollerViewportRect(scrollerEl);
69055
69227
  const listRect = listEl.getBoundingClientRect();
69056
- let hitEl = null;
69057
- let hitFiller = null;
69058
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
69059
- if (!scanRange) {
69228
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
69229
+ if (!range) {
69060
69230
  return null;
69061
69231
  }
69062
- // Start scanning from the center of the visible part of the list along the
69063
- // main axis. The render window places half its budget before and half after
69064
- // the hit index. Anchoring to the center maximises how many rendered items
69065
- // fall within the visible area.
69066
- const scanStart = (scanRange.from + scanRange.to) / 2;
69067
- const scanEnd = scanRange.to;
69068
- for (let pos = scanStart; pos < scanEnd; pos += 4) {
69069
- const x = horizontal ? pos : scanRange.crossPos;
69070
- const y = horizontal ? scanRange.crossPos : pos;
69071
- const el = document.elementFromPoint(x, y);
69072
- if (!el || !listEl.contains(el)) {
69073
- continue;
69074
- }
69075
- const realItem = el.closest(REAL_LIST_ITEM_SELECTOR);
69076
- if (realItem) {
69077
- hitEl = realItem;
69078
- break;
69079
- }
69080
- const filler = el.closest("[navi-virtual-filler]");
69081
- if (filler) {
69082
- hitFiller = filler;
69083
- break;
69232
+ // Read from the center of the visible part of the list along the main axis.
69233
+ // The render window places half its budget before and half after the hit
69234
+ // index. Anchoring to the center maximises how many rendered items fall
69235
+ // within the visible area.
69236
+ const scanStart = (range.from + range.to) / 2;
69237
+ let hitEl = null;
69238
+ const hitFiller = isFillerAt(listEl, scanStart, horizontal);
69239
+ if (!hitFiller) {
69240
+ // The first real row from the center down, the way a probe walking down
69241
+ // from it would meet one — past a separator or a group label in between.
69242
+ const {
69243
+ rowEls,
69244
+ index
69245
+ } = findRowsFrom(listEl, scanStart, horizontal);
69246
+ const rowEl = rowEls[index];
69247
+ if (rowEl) {
69248
+ const rowRect = rowEl.getBoundingClientRect();
69249
+ const rowStart = horizontal ? rowRect.left : rowRect.top;
69250
+ if (rowStart < range.to) {
69251
+ hitEl = rowEl;
69252
+ }
69084
69253
  }
69085
69254
  }
69086
69255
  // Shared by the "hit a filler" and "hit nothing at all" cases below: both
@@ -69111,8 +69280,8 @@ const getScrollInfo = ({
69111
69280
  return estimateFromScrollPos("hit filler");
69112
69281
  }
69113
69282
  if (hitEl) {
69114
- const hitId = hitEl.id;
69115
- const item = items.find(i => i.id === hitId);
69283
+ const hitName = getRowName(hitEl);
69284
+ const item = items.find(i => i.itemId === hitName);
69116
69285
  if (!item) {
69117
69286
  return null;
69118
69287
  }
@@ -69122,13 +69291,11 @@ const getScrollInfo = ({
69122
69291
  reason: `hit item at ${item.index} (${item.value})`
69123
69292
  };
69124
69293
  }
69125
- // Neither a real item nor a filler was hit within listEl e.g. part of
69126
- // the scan range fell outside the page's actually reachable viewport
69127
- // (docked devtools shrinks it, for one). Keeping the stale renderWindow
69128
- // here means the DOM never gets asked to catch up with a scrollTop that may
69129
- // have jumped far away the user ends up staring at filler space. Same
69130
- // estimate as the hitFiller case is a safe fallback: it only needs the
69131
- // scroll position, not a successful hit-test.
69294
+ // No real row stands between the center and the end of what is visible.
69295
+ // Keeping the stale renderWindow here means the DOM never gets asked to
69296
+ // catch up with a scrollTop that may have jumped far away — the user ends
69297
+ // up staring at blank space. Same estimate as the hitFiller case is a safe
69298
+ // fallback: it only needs the scroll position.
69132
69299
  const estimated = estimateFromScrollPos("no hit");
69133
69300
  if (estimated) {
69134
69301
  return estimated;
@@ -69563,6 +69730,12 @@ const ListItemUI = props => {
69563
69730
  // gave the row its place and decided it is inside the render window.
69564
69731
  const row = useContext(ListRowContext);
69565
69732
  const slotId = useContext(ListSlotContext);
69733
+ // What the row is called in the list — the run's name for a row it draws,
69734
+ // whatever `id` the caller put on the element (a run row may need a DOM id of
69735
+ // its own, to keep clear of another element's). A position names a row by
69736
+ // this (see captureScrollAnchor), and a row asked for by name is found by
69737
+ // this (locateRow, findRowElement); the DOM id is the caller's.
69738
+ props.itemId = row ? row.id : props.id;
69566
69739
  // There is no standalone match/matchScore/highlight prop — participation
69567
69740
  // in a matching system (search, filter…) only goes through `matchInfo`
69568
69741
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
@@ -69643,6 +69816,7 @@ const ListItemReal = props => {
69643
69816
  const {
69644
69817
  ref,
69645
69818
  id,
69819
+ itemId,
69646
69820
  hidden,
69647
69821
  muted,
69648
69822
  loading,
@@ -69662,7 +69836,7 @@ const ListItemReal = props => {
69662
69836
  // see the state change, which only the caller can arrange).
69663
69837
  const pendingScrollRef = useContext(PendingScrollRefContext);
69664
69838
  const pendingScroll = pendingScrollRef.current;
69665
- const needScrollOnMount = pendingScroll && pendingScroll.id === id;
69839
+ const needScrollOnMount = pendingScroll && pendingScroll.id === itemId;
69666
69840
  useLayoutEffect(() => {
69667
69841
  if (!needScrollOnMount) {
69668
69842
  return;
@@ -69761,7 +69935,7 @@ const ListItemReal = props => {
69761
69935
  baseClassName: "navi_list_item",
69762
69936
  styleCSSVars: LIST_ITEM_STYLE_CSS_VARS,
69763
69937
  id: id,
69764
- "navi-list-item-real": "",
69938
+ "navi-list-item-real": itemId,
69765
69939
  ...rest,
69766
69940
  ...itemColumnsOverrideProps,
69767
69941
  index: undefined,
@@ -71133,6 +71307,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71133
71307
  * onScrolledChange?: (scrolled: {id: string, index: number, offset: number}) => void,
71134
71308
  * scroller?: "self" | "parent" | "document" | Element | {current: Element},
71135
71309
  * hoverWhileScrolling?: boolean,
71310
+ * scrollResetOnNavigation?: boolean,
71136
71311
  * fallback?: import("ignore:preact").ComponentChildren,
71137
71312
  * searchFallback?: import("ignore:preact").ComponentChildren,
71138
71313
  * searchText?: string,
@@ -71224,7 +71399,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71224
71399
  * list is already known to be empty: the empty `fallback` shows right away
71225
71400
  * rather than an empty frame, so nothing moves when the response arrives.
71226
71401
  * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
71227
- * Where the list opens, after which the user owns the scroll. `"end"` is a
71402
+ * Where the list opens, after which the user owns the scroll unless it is
71403
+ * being come back to (see `scrollResetOnNavigation`). `"end"` is a
71228
71404
  * thread read backwards — the last rows are the ones to show, and the ones
71229
71405
  * asked for first. A number opens on that row of the collection. `{id,
71230
71406
  * offset}` — what `onScrolledChange` hands out — opens on a NAMED row,
@@ -71307,6 +71483,15 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71307
71483
  * Pass `true` for a list whose rows must stay live under the pointer while
71308
71484
  * it scrolls. The trade of the default is the mirror one: right after a
71309
71485
  * scroll, the row under the pointer lights up only once the pointer moves.
71486
+ * @param {boolean} [props.scrollResetOnNavigation=false]
71487
+ * A list that opens the same way every time. Without it the list comes back
71488
+ * where it was when its screen is left and come back to — the way the page
71489
+ * does, and for a list that scrolls itself the page's own restoration cannot
71490
+ * see. The position is kept under the list's `id` and the page's url, for
71491
+ * the session (a reload comes back too); a list without an `id` of its own
71492
+ * has nothing to be remembered by. A fresh arrival at the page opens at
71493
+ * `defaultScrolled` either way, and so does a list the caller holds through
71494
+ * `scrolled`.
71310
71495
  * @param {boolean} [props.deselectable]
71311
71496
  * A single-select list allowed to hold nothing: the selected row, pressed
71312
71497
  * again, lets go. Without it the list is a radio group — a choice, once