@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.
@@ -27733,6 +27733,18 @@ const takeoverRoutingRenderingHold = () => {
27733
27733
  * to a reader. Kept in the session too, so a reload lands where the browser
27734
27734
  * would have landed — the flag above is a promise to do the whole job.
27735
27735
  *
27736
+ * The document is not the only scrollport of a page. A list scrolling itself
27737
+ * (a `<List expandY>` under a search field that stays put) is left and come
27738
+ * back to the same way, and the browser never knew it was a scrollport at
27739
+ * all: those say where they are by name (rememberScrollerPosition), under the
27740
+ * same URL and for the same session, and ask it back when they mount again
27741
+ * (recallScrollerPosition). What a push means for them is what it means for
27742
+ * the document — an arrival opens at the top (see startAtTop): the page
27743
+ * arrived at has its named positions dropped before its lists render, so a
27744
+ * list recalls only on the way back. A scroller that goes while its page
27745
+ * stays — a popup closing over the same address — has nothing to come back
27746
+ * to, and says so as it leaves (forgetScrollerUnlessPageLeft).
27747
+ *
27736
27748
  * What is NOT covered, and cannot be from here: a page whose height depends on
27737
27749
  * something still loading. Its content is not there at the moment it is put
27738
27750
  * back, so a position beyond what has arrived is clamped as before. Only the
@@ -27750,7 +27762,23 @@ const takeoverRoutingRenderingHold = () => {
27750
27762
  const STORAGE_KEY = "navi_scroll_positions";
27751
27763
 
27752
27764
  const positionByUrl = new Map();
27753
- const readStoredPositions = () => {
27765
+ // url -> (scroller name -> position). The position is whatever the scroller
27766
+ // handed in: what it can put itself back on, in its own terms.
27767
+ const scrollerPositionsByUrl = new Map();
27768
+ // The url each named scroller last spoke under: what tells a scroller leaving
27769
+ // a page that stays from one leaving with its page.
27770
+ const urlByScrollerName = new Map();
27771
+
27772
+ // Read once, the first time anyone needs the positions: the document's
27773
+ // restoration is installed by the routing, and a list remembering itself may
27774
+ // mount in an app that never routes.
27775
+ let storeLoaded = false;
27776
+ const loadStore = () => {
27777
+ if (storeLoaded) {
27778
+ return;
27779
+ }
27780
+ storeLoaded = true;
27781
+ window.addEventListener("pagehide", storePositions);
27754
27782
  let stored;
27755
27783
  try {
27756
27784
  stored = window.sessionStorage.getItem(STORAGE_KEY);
@@ -27763,18 +27791,31 @@ const readStoredPositions = () => {
27763
27791
  return;
27764
27792
  }
27765
27793
  try {
27766
- for (const [url, position] of Object.entries(JSON.parse(stored))) {
27794
+ const { document: documentPositions, scrollers } = JSON.parse(stored);
27795
+ for (const [url, position] of Object.entries(documentPositions || {})) {
27767
27796
  positionByUrl.set(url, position);
27768
27797
  }
27798
+ for (const [url, positionByName] of Object.entries(scrollers || {})) {
27799
+ scrollerPositionsByUrl.set(url, new Map(Object.entries(positionByName)));
27800
+ }
27769
27801
  } catch {
27770
27802
  // Something else wrote there, or it was truncated.
27771
27803
  }
27772
27804
  };
27773
27805
  const storePositions = () => {
27806
+ const scrollers = {};
27807
+ for (const [url, positionByName] of scrollerPositionsByUrl) {
27808
+ if (positionByName.size > 0) {
27809
+ scrollers[url] = Object.fromEntries(positionByName);
27810
+ }
27811
+ }
27774
27812
  try {
27775
27813
  window.sessionStorage.setItem(
27776
27814
  STORAGE_KEY,
27777
- JSON.stringify(Object.fromEntries(positionByUrl)),
27815
+ JSON.stringify({
27816
+ document: Object.fromEntries(positionByUrl),
27817
+ scrollers,
27818
+ }),
27778
27819
  );
27779
27820
  } catch {
27780
27821
  // Full, or refused: the session is the only thing lost.
@@ -27814,7 +27855,7 @@ const installScrollRestoration = () => {
27814
27855
  return;
27815
27856
  }
27816
27857
  window.history.scrollRestoration = "manual";
27817
- readStoredPositions();
27858
+ loadStore();
27818
27859
  // Read as it happens rather than when leaving: a traverse changes the url
27819
27860
  // before anything here is told, so a position read then would be read for
27820
27861
  // the wrong page.
@@ -27831,7 +27872,6 @@ const installScrollRestoration = () => {
27831
27872
  },
27832
27873
  { passive: true },
27833
27874
  );
27834
- window.addEventListener("pagehide", storePositions);
27835
27875
  // What a reload asks for, now that the browser has been told not to do it.
27836
27876
  // Once, and at the first render of a route: the position is only meaningful
27837
27877
  // once there is a page under it.
@@ -27874,18 +27914,35 @@ const restoreScrollPosition = (url) => {
27874
27914
  // The document, because the document is the scrollport in the common case. An
27875
27915
  // app that scrolls an element of its own scrolls it itself.
27876
27916
  const startAtTop = (url, { from } = {}) => {
27917
+ if (!isArrival(url, { from })) {
27918
+ return;
27919
+ }
27920
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27921
+ };
27922
+ const isArrival = (url, { from }) => {
27877
27923
  const urlObject = new URL(url, window.location.href);
27878
27924
  // A fragment names where to land, and the browser is the one that finds it.
27879
27925
  if (urlObject.hash) {
27880
- return;
27926
+ return false;
27881
27927
  }
27882
27928
  if (
27883
27929
  from !== undefined &&
27884
27930
  new URL(from, window.location.href).pathname === urlObject.pathname
27885
27931
  ) {
27932
+ return false;
27933
+ }
27934
+ return true;
27935
+ };
27936
+
27937
+ // The same arrival, for the page's own scrollers. The document is scrolled to
27938
+ // its top once the page is there; a list opens where it decides to in its
27939
+ // first render, so what it must not find is dropped before the routing
27940
+ // renders anything.
27941
+ const forgetScrollersOnArrival = (url, { from } = {}) => {
27942
+ if (!isArrival(url, { from })) {
27886
27943
  return;
27887
27944
  }
27888
- window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27945
+ scrollerPositionsByUrl.delete(new URL(url, window.location.href).href);
27889
27946
  };
27890
27947
 
27891
27948
  // An arrival at a page whose scrollport is already showing another one: the
@@ -27904,6 +27961,49 @@ const scrollTo = ({ x, y }) => {
27904
27961
  window.scrollTo({ top: y, left: x, behavior: "instant" });
27905
27962
  };
27906
27963
 
27964
+ const rememberScrollerPosition = (name, position) => {
27965
+ loadStore();
27966
+ const url = window.location.href;
27967
+ let positionByName = scrollerPositionsByUrl.get(url);
27968
+ if (!positionByName) {
27969
+ positionByName = new Map();
27970
+ scrollerPositionsByUrl.set(url, positionByName);
27971
+ }
27972
+ positionByName.set(name, position);
27973
+ urlByScrollerName.set(name, url);
27974
+ };
27975
+
27976
+ const recallScrollerPosition = (name) => {
27977
+ loadStore();
27978
+ const positionByName = scrollerPositionsByUrl.get(window.location.href);
27979
+ if (!positionByName) {
27980
+ return undefined;
27981
+ }
27982
+ return positionByName.get(name);
27983
+ };
27984
+
27985
+ // Said by a scroller as it unmounts. Its page is being left when the url is
27986
+ // already another one — the history is written before the page it names is
27987
+ // taken down — and then its position is kept for the way back. The url still
27988
+ // being the one it spoke under means the page stays and the scroller alone
27989
+ // goes (a popup closing, a section folding): there is no coming back to a
27990
+ // place that was not left, and a position kept would greet the next mount
27991
+ // under this address as a return.
27992
+ const forgetScrollerUnlessPageLeft = (name) => {
27993
+ const url = urlByScrollerName.get(name);
27994
+ if (url === undefined) {
27995
+ return;
27996
+ }
27997
+ urlByScrollerName.delete(name);
27998
+ if (url !== window.location.href) {
27999
+ return;
28000
+ }
28001
+ const positionByName = scrollerPositionsByUrl.get(url);
28002
+ if (positionByName) {
28003
+ positionByName.delete(name);
28004
+ }
28005
+ };
28006
+
27907
28007
  /**
27908
28008
  * A navigation is ABOUT to be applied — said before its very first write.
27909
28009
  *
@@ -28266,6 +28366,13 @@ const setupBrowserIntegrationViaHistory = ({
28266
28366
  return undefined;
28267
28367
  }
28268
28368
 
28369
+ // The page's own scrollers are told of an arrival before the routing
28370
+ // renders anything: a list arriving decides where it opens in its first
28371
+ // render (see scroll_restoration.js). The document itself is moved once
28372
+ // the page is there, below.
28373
+ if (navigationType === "push") {
28374
+ forgetScrollersOnArrival(url, { from: urlLeft });
28375
+ }
28269
28376
  if (abortController) {
28270
28377
  abortController.abort(`navigating to ${url}`);
28271
28378
  }
@@ -68513,10 +68620,11 @@ const ListUI = props => {
68513
68620
  onListVisibleItemsChange,
68514
68621
  virtualItemSize,
68515
68622
  scrolled,
68516
- defaultScrolled = "start",
68623
+ defaultScrolled: defaultScrolledProp = "start",
68517
68624
  onScrolledChange,
68518
68625
  scroller = "self",
68519
68626
  hoverWhileScrolling = false,
68627
+ scrollResetOnNavigation = false,
68520
68628
  lockSize,
68521
68629
  columns,
68522
68630
  itemColumns,
@@ -68535,6 +68643,20 @@ const ListUI = props => {
68535
68643
  listRows,
68536
68644
  ...rest
68537
68645
  } = props;
68646
+ // Remembered by name, and a name made up at render (see ListFirstResolver)
68647
+ // names no list a later mount would recognize.
68648
+ const rememberScroll = !scrollResetOnNavigation && !isLikelyPreactGeneratedId(rest.id);
68649
+ // Where the list was when its screen was left, when this is the way back
68650
+ // (see scroll_restoration.js). Read once: `defaultScrolled` is held by
68651
+ // reference, and a place read again each render would be a list moved each
68652
+ // render. A list held by its caller (`scrolled`) is where the caller says.
68653
+ const [scrolledRemembered] = useState(() => {
68654
+ if (!rememberScroll || scrolled !== undefined && scrolled !== null) {
68655
+ return undefined;
68656
+ }
68657
+ return recallScrollerPosition(rest.id);
68658
+ });
68659
+ const defaultScrolled = scrolledRemembered || defaultScrolledProp;
68538
68660
  const scrollBoxPaddingProps = {};
68539
68661
  for (const name of LIST_PADDING_PROP_SET) {
68540
68662
  if (name in rest) {
@@ -68629,6 +68751,8 @@ const ListUI = props => {
68629
68751
  scrolled,
68630
68752
  defaultScrolled,
68631
68753
  onScrolledChange,
68754
+ rememberScroll,
68755
+ listId: rest.id,
68632
68756
  scroller,
68633
68757
  searchText,
68634
68758
  horizontal
@@ -68661,7 +68785,7 @@ const ListUI = props => {
68661
68785
  // locator). Both answer here, so a row is reachable whether or not the
68662
68786
  // window happens to frame it.
68663
68787
  const getItemById = itemId => {
68664
- const itemDrawn = listRows.itemsSignal.peek().find(item => item.id === itemId);
68788
+ const itemDrawn = listRows.itemsSignal.peek().find(item => item.itemId === itemId);
68665
68789
  if (itemDrawn) {
68666
68790
  return itemDrawn;
68667
68791
  }
@@ -68671,6 +68795,7 @@ const ListUI = props => {
68671
68795
  }
68672
68796
  return {
68673
68797
  id: itemId,
68798
+ itemId,
68674
68799
  index: rowIndex
68675
68800
  };
68676
68801
  };
@@ -68953,6 +69078,8 @@ const useListScrollSync = ({
68953
69078
  scrolled,
68954
69079
  defaultScrolled,
68955
69080
  onScrolledChange,
69081
+ rememberScroll,
69082
+ listId,
68956
69083
  scroller,
68957
69084
  searchText,
68958
69085
  horizontal
@@ -69166,16 +69293,19 @@ const useListScrollSync = ({
69166
69293
  const trigger = `"${event.type}" on ${getElementSignature(event.target)} (${reason})`;
69167
69294
  // When we display the list we prefer to have selected item at the center
69168
69295
  // otherwise, usually when focused by arrow nav, we want to keep it into view close to the nearest edge
69169
- const block = blockRequested || (event.type === "navi_displayed" ? "center" : "nearest");
69170
- const scrollToItemCall = `${getElementSignature(itemEl)}.scrollIntoView({ block: "${block}", container: "nearest" })`;
69296
+ const align = blockRequested || (event.type === "navi_displayed" ? "center" : "nearest");
69297
+ const scrollToItemCall = `${getElementSignature(itemEl)}.scrollIntoView({ block: "${align}", inline: "${align}", container: "nearest" })`;
69171
69298
  debugScroll(`${trigger} -> ${scrollToItemCall}`);
69172
69299
  // The list is going somewhere on purpose, so there is no view to hold
69173
69300
  // still any more: an anchor captured before this drop it, or it would
69174
69301
  // put the list back where it was the moment the rows move under it.
69175
69302
  anchorRef.current = null;
69303
+ // One alignment, said on both axes: the axis the list scrolls on is the
69304
+ // one that reads it, and the other has nothing to move.
69176
69305
  scrollIntoViewScoped(itemEl, {
69177
69306
  container: getScroller(),
69178
- block
69307
+ block: align,
69308
+ inline: align
69179
69309
  });
69180
69310
  const listEl = getListEl();
69181
69311
  dispatchPublicCustomEvent(listEl, "navi_scroll", {
@@ -69187,7 +69317,7 @@ const useListScrollSync = ({
69187
69317
  // Whether the row is drawn is asked of the dom, not of the render window:
69188
69318
  // the window says what a run draws, and a list whose rows are declared one
69189
69319
  // by one has them all in the dom whatever the window says.
69190
- const itemEl = findRowElement(getListEl(), item.id);
69320
+ const itemEl = findRowElement(getListEl(), item.itemId);
69191
69321
  if (itemEl) {
69192
69322
  scrollItemIntoView(itemEl);
69193
69323
  return;
@@ -69195,7 +69325,7 @@ const useListScrollSync = ({
69195
69325
  // Not in DOM — shift the render window. The item will read
69196
69326
  // pendingScrollRef on mount and scroll into view.
69197
69327
  pendingScrollRef.current = {
69198
- id: item.id,
69328
+ id: item.itemId,
69199
69329
  resolve: itemEl => {
69200
69330
  pendingScrollRef.current = null;
69201
69331
  scrollItemIntoView(itemEl);
@@ -69547,6 +69677,8 @@ const useListScrollSync = ({
69547
69677
  // one was looking at is then somewhere else.
69548
69678
  const onScrolledChangeRef = useRef(null);
69549
69679
  onScrolledChangeRef.current = onScrolledChange;
69680
+ const rememberScrollRef = useRef(false);
69681
+ rememberScrollRef.current = rememberScroll;
69550
69682
  // Where the list was at the last thing that moved it. Kept whether anyone
69551
69683
  // asked for it or not: it is what a resize needs to put things back.
69552
69684
  const positionRef = useRef(null);
@@ -69564,16 +69696,32 @@ const useListScrollSync = ({
69564
69696
  return;
69565
69697
  }
69566
69698
  positionRef.current = position;
69567
- if (!onScrolledChangeRef.current) {
69699
+ const remember = rememberScrollRef.current;
69700
+ const onScrolledChange = onScrolledChangeRef.current;
69701
+ if (!remember && !onScrolledChange) {
69568
69702
  return;
69569
69703
  }
69570
69704
  const rowEl = findRowElement(getListEl(), position.id);
69571
- onScrolledChangeRef.current({
69705
+ const scrolledNow = {
69572
69706
  id: position.id,
69573
69707
  index: position.index,
69574
69708
  offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal)
69575
- });
69709
+ };
69710
+ if (remember) {
69711
+ rememberScrollerPosition(listId, scrolledNow);
69712
+ }
69713
+ if (onScrolledChange) {
69714
+ onScrolledChange(scrolledNow);
69715
+ }
69576
69716
  };
69717
+ // Leaving: with its page, or alone (see forgetScrollerUnlessPageLeft).
69718
+ useLayoutEffect(() => {
69719
+ return () => {
69720
+ if (rememberScrollRef.current) {
69721
+ forgetScrollerUnlessPageLeft(listId);
69722
+ }
69723
+ };
69724
+ }, []);
69577
69725
 
69578
69726
  // A list that gets narrower rewraps every row it holds, so everything below
69579
69727
  // moves and the reader loses their place — the very thing scrolling a long
@@ -69646,7 +69794,7 @@ const useListScrollSync = ({
69646
69794
  return;
69647
69795
  }
69648
69796
  const items = listRows.visibleItemsSignal.peek();
69649
- const itemNow = items.find(i => i.id === anchor.id);
69797
+ const itemNow = items.find(i => i.itemId === anchor.id);
69650
69798
  if (!itemNow) {
69651
69799
  anchorRef.current = null;
69652
69800
  return;
@@ -70345,17 +70493,22 @@ const resolveScrollInset = (value, viewportSize) => {
70345
70493
  return number;
70346
70494
  };
70347
70495
 
70348
- // The row with that id, IN THIS LIST. Not document.getElementById: an id is
70349
- // only ever unique within a list — two lists on the same page can be showing
70350
- // the same collection and a list acting on a row that belongs to another one
70351
- // is a spectacular kind of wrong (it scrolls to hold still something it is not
70352
- // even showing).
70496
+ // The row of that name, IN THIS LIST by the name the list knows it under
70497
+ // (see ListItemUI), not the element's id. Not document.getElementById either:
70498
+ // a name is only ever unique within a list two lists on the same page can be
70499
+ // showing the same collection and a list acting on a row that belongs to
70500
+ // another one is a spectacular kind of wrong (it scrolls to hold still
70501
+ // something it is not even showing).
70353
70502
  const findRowElement = (listEl, id) => {
70354
- return listEl.querySelector(`[id="${CSS.escape(id)}"]`);
70503
+ return listEl.querySelector(`[navi-list-item-real="${CSS.escape(id)}"]`);
70355
70504
  };
70505
+ const getRowName = rowEl => rowEl.getAttribute("navi-list-item-real");
70356
70506
 
70357
70507
  // The row the user is looking at, and where it sits: what must not move when
70358
- // the list is rebuilt around it.
70508
+ // the list is rebuilt around it. Read off the rows' own boxes, not by
70509
+ // hit-testing the screen: a scrolling list takes its rows out of hit-testing
70510
+ // (see the navi-scrolling rule in the css above), and the scroll event is
70511
+ // precisely when this is asked.
70359
70512
  const captureScrollAnchor = ({
70360
70513
  scrollerEl,
70361
70514
  listEl,
@@ -70367,30 +70520,31 @@ const captureScrollAnchor = ({
70367
70520
  }
70368
70521
  const viewportRect = getScrollerViewportRect(scrollerEl);
70369
70522
  const listRect = listEl.getBoundingClientRect();
70370
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
70371
- if (!scanRange) {
70523
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
70524
+ if (!range) {
70372
70525
  return null;
70373
70526
  }
70527
+ const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
70528
+ const {
70529
+ rowEls,
70530
+ index
70531
+ } = findRowsFrom(listEl, range.from, horizontal);
70374
70532
  let fallbackAnchor = null;
70375
- for (let pos = scanRange.from + 1; pos < scanRange.to; pos += 8) {
70376
- const x = horizontal ? pos : scanRange.crossPos;
70377
- const y = horizontal ? scanRange.crossPos : pos;
70378
- const el = document.elementFromPoint(x, y);
70379
- if (!el || !listEl.contains(el)) {
70380
- continue;
70381
- }
70382
- const itemEl = el.closest(REAL_LIST_ITEM_SELECTOR);
70383
- if (!itemEl) {
70384
- continue;
70533
+ for (let i = index; i < rowEls.length; i++) {
70534
+ const rowEl = rowEls[i];
70535
+ const rowRect = rowEl.getBoundingClientRect();
70536
+ const rowStart = horizontal ? rowRect.left : rowRect.top;
70537
+ if (rowStart >= range.to) {
70538
+ break;
70385
70539
  }
70386
- const item = items.find(i => i.id === itemEl.id);
70540
+ const rowName = getRowName(rowEl);
70541
+ const item = items.find(i => i.itemId === rowName);
70387
70542
  if (!item) {
70388
70543
  continue;
70389
70544
  }
70390
- const itemRect = itemEl.getBoundingClientRect();
70391
- const offset = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
70545
+ const offset = rowStart - viewportFrom;
70392
70546
  const anchor = {
70393
- id: item.id,
70547
+ id: item.itemId,
70394
70548
  index: item.index,
70395
70549
  offset
70396
70550
  };
@@ -70410,43 +70564,61 @@ const captureScrollAnchor = ({
70410
70564
  // The part of the list that is on screen, along the scrolling axis. Both edges
70411
70565
  // matter: the scroller may be larger than the list (scroller="parent") as well
70412
70566
  // as smaller (the list scrolls inside its own box).
70413
- const getListVisibleScanRange = (viewportRect, listRect, horizontal) => {
70414
- // The screen has a say too: what is asked here is answered by
70415
- // elementFromPoint, which only knows about points that are actually on it. A
70416
- // list whose scroll box hangs below the fold of the page would otherwise be
70417
- // probed where nothing can be hit — and would silently stop keeping its rows
70418
- // still, which is exactly when it matters.
70419
- const screenTo = horizontal ? document.documentElement.clientWidth : document.documentElement.clientHeight;
70567
+ const getListVisibleRange = (viewportRect, listRect, horizontal) => {
70420
70568
  const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
70421
70569
  const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
70422
70570
  const listFrom = horizontal ? listRect.left : listRect.top;
70423
70571
  const listTo = horizontal ? listRect.right : listRect.bottom;
70424
- let from = listFrom > viewportFrom ? listFrom : viewportFrom;
70425
- let to = listTo < viewportTo ? listTo : viewportTo;
70426
- if (from < 0) {
70427
- from = 0;
70428
- }
70429
- if (to > screenTo) {
70430
- to = screenTo;
70431
- }
70572
+ const from = listFrom > viewportFrom ? listFrom : viewportFrom;
70573
+ const to = listTo < viewportTo ? listTo : viewportTo;
70432
70574
  if (to - from < 2) {
70433
70575
  return null;
70434
70576
  }
70435
- // Where to put the probe on the other axis: inside the list, inside the
70436
- // viewport.
70437
- const crossFrom = horizontal ? listRect.top : listRect.left;
70438
- const crossViewportFrom = horizontal ? viewportRect.top : viewportRect.left;
70439
- const crossPos = (crossFrom > crossViewportFrom ? crossFrom : crossViewportFrom) + 1;
70440
70577
  return {
70441
70578
  from,
70442
- to,
70443
- crossPos
70579
+ to
70444
70580
  };
70445
70581
  };
70582
+ // The real rows of the list, and the first of them reaching past `from` along
70583
+ // the scroll axis. A binary search over their boxes: the rows stand in
70584
+ // document order along that axis, so their far edges only grow.
70585
+ const findRowsFrom = (listEl, from, horizontal) => {
70586
+ const rowEls = listEl.querySelectorAll(REAL_LIST_ITEM_SELECTOR);
70587
+ let low = 0;
70588
+ let high = rowEls.length;
70589
+ while (low < high) {
70590
+ const mid = low + high >> 1;
70591
+ const rect = rowEls[mid].getBoundingClientRect();
70592
+ const end = horizontal ? rect.right : rect.bottom;
70593
+ if (end > from) {
70594
+ high = mid;
70595
+ } else {
70596
+ low = mid + 1;
70597
+ }
70598
+ }
70599
+ return {
70600
+ rowEls,
70601
+ index: low
70602
+ };
70603
+ };
70604
+ // Whether a filler (the room held for rows outside the window) is what stands
70605
+ // at that position along the scroll axis.
70606
+ const isFillerAt = (listEl, position, horizontal) => {
70607
+ for (const fillerEl of listEl.querySelectorAll("[navi-virtual-filler]")) {
70608
+ const rect = fillerEl.getBoundingClientRect();
70609
+ const from = horizontal ? rect.left : rect.top;
70610
+ const to = horizontal ? rect.right : rect.bottom;
70611
+ if (position >= from && position < to) {
70612
+ return true;
70613
+ }
70614
+ }
70615
+ return false;
70616
+ };
70446
70617
 
70447
- // Which row of the collection sits at the current scroll position. Uses DOM
70448
- // hit-testing when a real row is there to be hit, and the row size when what is
70449
- // on screen is only reserved room.
70618
+ // Which row of the collection sits at the current scroll position. Read off
70619
+ // the rows' boxes when a real row is there (see captureScrollAnchor for why
70620
+ // not hit-testing), and from the row size when what is on screen is only
70621
+ // reserved room.
70450
70622
  // Returns { index, item, reason } or null if nothing can be determined.
70451
70623
  const getScrollInfo = ({
70452
70624
  scrollValues,
@@ -70460,34 +70632,31 @@ const getScrollInfo = ({
70460
70632
  const items = listRows.itemsSignal.peek();
70461
70633
  const viewportRect = getScrollerViewportRect(scrollerEl);
70462
70634
  const listRect = listEl.getBoundingClientRect();
70463
- let hitEl = null;
70464
- let hitFiller = null;
70465
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
70466
- if (!scanRange) {
70635
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
70636
+ if (!range) {
70467
70637
  return null;
70468
70638
  }
70469
- // Start scanning from the center of the visible part of the list along the
70470
- // main axis. The render window places half its budget before and half after
70471
- // the hit index. Anchoring to the center maximises how many rendered items
70472
- // fall within the visible area.
70473
- const scanStart = (scanRange.from + scanRange.to) / 2;
70474
- const scanEnd = scanRange.to;
70475
- for (let pos = scanStart; pos < scanEnd; pos += 4) {
70476
- const x = horizontal ? pos : scanRange.crossPos;
70477
- const y = horizontal ? scanRange.crossPos : pos;
70478
- const el = document.elementFromPoint(x, y);
70479
- if (!el || !listEl.contains(el)) {
70480
- continue;
70481
- }
70482
- const realItem = el.closest(REAL_LIST_ITEM_SELECTOR);
70483
- if (realItem) {
70484
- hitEl = realItem;
70485
- break;
70486
- }
70487
- const filler = el.closest("[navi-virtual-filler]");
70488
- if (filler) {
70489
- hitFiller = filler;
70490
- break;
70639
+ // Read from the center of the visible part of the list along the main axis.
70640
+ // The render window places half its budget before and half after the hit
70641
+ // index. Anchoring to the center maximises how many rendered items fall
70642
+ // within the visible area.
70643
+ const scanStart = (range.from + range.to) / 2;
70644
+ let hitEl = null;
70645
+ const hitFiller = isFillerAt(listEl, scanStart, horizontal);
70646
+ if (!hitFiller) {
70647
+ // The first real row from the center down, the way a probe walking down
70648
+ // from it would meet one — past a separator or a group label in between.
70649
+ const {
70650
+ rowEls,
70651
+ index
70652
+ } = findRowsFrom(listEl, scanStart, horizontal);
70653
+ const rowEl = rowEls[index];
70654
+ if (rowEl) {
70655
+ const rowRect = rowEl.getBoundingClientRect();
70656
+ const rowStart = horizontal ? rowRect.left : rowRect.top;
70657
+ if (rowStart < range.to) {
70658
+ hitEl = rowEl;
70659
+ }
70491
70660
  }
70492
70661
  }
70493
70662
  // Shared by the "hit a filler" and "hit nothing at all" cases below: both
@@ -70518,8 +70687,8 @@ const getScrollInfo = ({
70518
70687
  return estimateFromScrollPos("hit filler");
70519
70688
  }
70520
70689
  if (hitEl) {
70521
- const hitId = hitEl.id;
70522
- const item = items.find(i => i.id === hitId);
70690
+ const hitName = getRowName(hitEl);
70691
+ const item = items.find(i => i.itemId === hitName);
70523
70692
  if (!item) {
70524
70693
  return null;
70525
70694
  }
@@ -70529,13 +70698,11 @@ const getScrollInfo = ({
70529
70698
  reason: `hit item at ${item.index} (${item.value})`
70530
70699
  };
70531
70700
  }
70532
- // Neither a real item nor a filler was hit within listEl e.g. part of
70533
- // the scan range fell outside the page's actually reachable viewport
70534
- // (docked devtools shrinks it, for one). Keeping the stale renderWindow
70535
- // here means the DOM never gets asked to catch up with a scrollTop that may
70536
- // have jumped far away the user ends up staring at filler space. Same
70537
- // estimate as the hitFiller case is a safe fallback: it only needs the
70538
- // scroll position, not a successful hit-test.
70701
+ // No real row stands between the center and the end of what is visible.
70702
+ // Keeping the stale renderWindow here means the DOM never gets asked to
70703
+ // catch up with a scrollTop that may have jumped far away — the user ends
70704
+ // up staring at blank space. Same estimate as the hitFiller case is a safe
70705
+ // fallback: it only needs the scroll position.
70539
70706
  const estimated = estimateFromScrollPos("no hit");
70540
70707
  if (estimated) {
70541
70708
  return estimated;
@@ -70970,6 +71137,12 @@ const ListItemUI = props => {
70970
71137
  // gave the row its place and decided it is inside the render window.
70971
71138
  const row = useContext(ListRowContext);
70972
71139
  const slotId = useContext(ListSlotContext);
71140
+ // What the row is called in the list — the run's name for a row it draws,
71141
+ // whatever `id` the caller put on the element (a run row may need a DOM id of
71142
+ // its own, to keep clear of another element's). A position names a row by
71143
+ // this (see captureScrollAnchor), and a row asked for by name is found by
71144
+ // this (locateRow, findRowElement); the DOM id is the caller's.
71145
+ props.itemId = row ? row.id : props.id;
70973
71146
  // There is no standalone match/matchScore/highlight prop — participation
70974
71147
  // in a matching system (search, filter…) only goes through `matchInfo`
70975
71148
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
@@ -71050,6 +71223,7 @@ const ListItemReal = props => {
71050
71223
  const {
71051
71224
  ref,
71052
71225
  id,
71226
+ itemId,
71053
71227
  hidden,
71054
71228
  muted,
71055
71229
  loading,
@@ -71069,7 +71243,7 @@ const ListItemReal = props => {
71069
71243
  // see the state change, which only the caller can arrange).
71070
71244
  const pendingScrollRef = useContext(PendingScrollRefContext);
71071
71245
  const pendingScroll = pendingScrollRef.current;
71072
- const needScrollOnMount = pendingScroll && pendingScroll.id === id;
71246
+ const needScrollOnMount = pendingScroll && pendingScroll.id === itemId;
71073
71247
  useLayoutEffect(() => {
71074
71248
  if (!needScrollOnMount) {
71075
71249
  return;
@@ -71168,7 +71342,7 @@ const ListItemReal = props => {
71168
71342
  baseClassName: "navi_list_item",
71169
71343
  styleCSSVars: LIST_ITEM_STYLE_CSS_VARS,
71170
71344
  id: id,
71171
- "navi-list-item-real": "",
71345
+ "navi-list-item-real": itemId,
71172
71346
  ...rest,
71173
71347
  ...itemColumnsOverrideProps,
71174
71348
  index: undefined,
@@ -72549,6 +72723,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
72549
72723
  * onScrolledChange?: (scrolled: {id: string, index: number, offset: number}) => void,
72550
72724
  * scroller?: "self" | "parent" | "document" | Element | {current: Element},
72551
72725
  * hoverWhileScrolling?: boolean,
72726
+ * scrollResetOnNavigation?: boolean,
72552
72727
  * fallback?: import("ignore:preact").ComponentChildren,
72553
72728
  * searchFallback?: import("ignore:preact").ComponentChildren,
72554
72729
  * searchText?: string,
@@ -72640,7 +72815,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
72640
72815
  * list is already known to be empty: the empty `fallback` shows right away
72641
72816
  * rather than an empty frame, so nothing moves when the response arrives.
72642
72817
  * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
72643
- * Where the list opens, after which the user owns the scroll. `"end"` is a
72818
+ * Where the list opens, after which the user owns the scroll unless it is
72819
+ * being come back to (see `scrollResetOnNavigation`). `"end"` is a
72644
72820
  * thread read backwards — the last rows are the ones to show, and the ones
72645
72821
  * asked for first. A number opens on that row of the collection. `{id,
72646
72822
  * offset}` — what `onScrolledChange` hands out — opens on a NAMED row,
@@ -72723,6 +72899,15 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
72723
72899
  * Pass `true` for a list whose rows must stay live under the pointer while
72724
72900
  * it scrolls. The trade of the default is the mirror one: right after a
72725
72901
  * scroll, the row under the pointer lights up only once the pointer moves.
72902
+ * @param {boolean} [props.scrollResetOnNavigation=false]
72903
+ * A list that opens the same way every time. Without it the list comes back
72904
+ * where it was when its screen is left and come back to — the way the page
72905
+ * does, and for a list that scrolls itself the page's own restoration cannot
72906
+ * see. The position is kept under the list's `id` and the page's url, for
72907
+ * the session (a reload comes back too); a list without an `id` of its own
72908
+ * has nothing to be remembered by. A fresh arrival at the page opens at
72909
+ * `defaultScrolled` either way, and so does a list the caller holds through
72910
+ * `scrolled`.
72726
72911
  * @param {boolean} [props.deselectable]
72727
72912
  * A single-select list allowed to hold nothing: the selected row, pressed
72728
72913
  * again, lets go. Without it the list is a radio group — a choice, once