@jsenv/navi 0.29.360 → 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.
@@ -4474,9 +4474,12 @@ const findControlHost = (el) => {
4474
4474
  * `findControlHost` above answers for a control's own DOM (itself, or the native
4475
4475
  * element it wraps). This one is for something that is not part of a control but
4476
4476
  * has to reach one — an interaction declared on a box (see
4477
- * interaction/interactions.js), which may be the control, may hold it, or may sit
4478
- * inside it. Nearest wins in that order, and the answer is null when there is no
4479
- * control anywhere: not every box lives in one.
4477
+ * interaction/interactions.js), which may be the control, may sit inside it, or
4478
+ * may be a wrapper around exactly one. Nearest wins in that order, and the answer
4479
+ * is null when there is no control the box belongs to: not every box lives in
4480
+ * one, and a box laying out several controls (a row of badges, a toolbar) is
4481
+ * held by none of them — its interactions are its own, and a press on its empty
4482
+ * part is not a press on the first control it happens to contain.
4480
4483
  */
4481
4484
  const findNearestControlHost = (el) => {
4482
4485
  // Itself, then upwards — the box is inside a button, or is one.
@@ -4484,8 +4487,22 @@ const findNearestControlHost = (el) => {
4484
4487
  if (selfOrAncestor) {
4485
4488
  return selfOrAncestor;
4486
4489
  }
4487
- // …then downwards: the box holds the control rather than being held by it.
4488
- return el.querySelector("[navi-control-host]");
4490
+ // …then downwards, only when the box wraps a single control. Counted by
4491
+ // control, not by host: a picker holds the hosts of its popup content, and a
4492
+ // box around that one picker is still around one control.
4493
+ let single = null;
4494
+ for (const host of el.querySelectorAll("[navi-control-host]")) {
4495
+ const controlRoot = host.closest("[navi-control]") || host;
4496
+ const controlAbove = controlRoot.parentElement.closest("[navi-control]");
4497
+ if (controlAbove && el.contains(controlAbove)) {
4498
+ continue;
4499
+ }
4500
+ if (single && single !== controlRoot) {
4501
+ return null;
4502
+ }
4503
+ single = controlRoot;
4504
+ }
4505
+ return single ? findControlHost(single) : null;
4489
4506
  };
4490
4507
  const isControlRoot = (el) => {
4491
4508
  return el.hasAttribute("navi-control");
@@ -10600,9 +10617,10 @@ const interactionsDisputeThePress = (interactions) => {
10600
10617
  *
10601
10618
  * The control is not passed in: it is found from the element, which is what lets
10602
10619
  * `interactions` live on a Box rather than on the control itself. A Box that IS a
10603
- * control (a Button) is its own; a Box around one or inside one reaches it; a Box
10604
- * with no control anywhere near it can still answer with a callback of the
10605
- * caller's, and only "request_action" has nothing to ask.
10620
+ * control (a Button) is its own; a Box inside one, or wrapping exactly one,
10621
+ * reaches it; a Box with no control anywhere near it or laying out several,
10622
+ * which belongs to none of them still answers with a callback of the caller's,
10623
+ * and only "request_action" has nothing to ask.
10606
10624
  *
10607
10625
  * Set up once per element rather than on every render, which is what lets a
10608
10626
  * detector be a plain `setup`/teardown pair. So the interactions themselves are
@@ -26960,6 +26978,18 @@ const takeoverRoutingRenderingHold = () => {
26960
26978
  * to a reader. Kept in the session too, so a reload lands where the browser
26961
26979
  * would have landed — the flag above is a promise to do the whole job.
26962
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
+ *
26963
26993
  * What is NOT covered, and cannot be from here: a page whose height depends on
26964
26994
  * something still loading. Its content is not there at the moment it is put
26965
26995
  * back, so a position beyond what has arrived is clamped as before. Only the
@@ -26977,7 +27007,23 @@ const takeoverRoutingRenderingHold = () => {
26977
27007
  const STORAGE_KEY = "navi_scroll_positions";
26978
27008
 
26979
27009
  const positionByUrl = new Map();
26980
- 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);
26981
27027
  let stored;
26982
27028
  try {
26983
27029
  stored = window.sessionStorage.getItem(STORAGE_KEY);
@@ -26990,18 +27036,31 @@ const readStoredPositions = () => {
26990
27036
  return;
26991
27037
  }
26992
27038
  try {
26993
- 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 || {})) {
26994
27041
  positionByUrl.set(url, position);
26995
27042
  }
27043
+ for (const [url, positionByName] of Object.entries(scrollers || {})) {
27044
+ scrollerPositionsByUrl.set(url, new Map(Object.entries(positionByName)));
27045
+ }
26996
27046
  } catch {
26997
27047
  // Something else wrote there, or it was truncated.
26998
27048
  }
26999
27049
  };
27000
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
+ }
27001
27057
  try {
27002
27058
  window.sessionStorage.setItem(
27003
27059
  STORAGE_KEY,
27004
- JSON.stringify(Object.fromEntries(positionByUrl)),
27060
+ JSON.stringify({
27061
+ document: Object.fromEntries(positionByUrl),
27062
+ scrollers,
27063
+ }),
27005
27064
  );
27006
27065
  } catch {
27007
27066
  // Full, or refused: the session is the only thing lost.
@@ -27041,7 +27100,7 @@ const installScrollRestoration = () => {
27041
27100
  return;
27042
27101
  }
27043
27102
  window.history.scrollRestoration = "manual";
27044
- readStoredPositions();
27103
+ loadStore();
27045
27104
  // Read as it happens rather than when leaving: a traverse changes the url
27046
27105
  // before anything here is told, so a position read then would be read for
27047
27106
  // the wrong page.
@@ -27058,7 +27117,6 @@ const installScrollRestoration = () => {
27058
27117
  },
27059
27118
  { passive: true },
27060
27119
  );
27061
- window.addEventListener("pagehide", storePositions);
27062
27120
  // What a reload asks for, now that the browser has been told not to do it.
27063
27121
  // Once, and at the first render of a route: the position is only meaningful
27064
27122
  // once there is a page under it.
@@ -27101,18 +27159,35 @@ const restoreScrollPosition = (url) => {
27101
27159
  // The document, because the document is the scrollport in the common case. An
27102
27160
  // app that scrolls an element of its own scrolls it itself.
27103
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 }) => {
27104
27168
  const urlObject = new URL(url, window.location.href);
27105
27169
  // A fragment names where to land, and the browser is the one that finds it.
27106
27170
  if (urlObject.hash) {
27107
- return;
27171
+ return false;
27108
27172
  }
27109
27173
  if (
27110
27174
  from !== undefined &&
27111
27175
  new URL(from, window.location.href).pathname === urlObject.pathname
27112
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 })) {
27113
27188
  return;
27114
27189
  }
27115
- window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27190
+ scrollerPositionsByUrl.delete(new URL(url, window.location.href).href);
27116
27191
  };
27117
27192
 
27118
27193
  // An arrival at a page whose scrollport is already showing another one: the
@@ -27131,6 +27206,49 @@ const scrollTo = ({ x, y }) => {
27131
27206
  window.scrollTo({ top: y, left: x, behavior: "instant" });
27132
27207
  };
27133
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
+
27134
27252
  /**
27135
27253
  * A navigation is ABOUT to be applied — said before its very first write.
27136
27254
  *
@@ -27493,6 +27611,13 @@ const setupBrowserIntegrationViaHistory = ({
27493
27611
  return undefined;
27494
27612
  }
27495
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
+ }
27496
27621
  if (abortController) {
27497
27622
  abortController.abort(`navigating to ${url}`);
27498
27623
  }
@@ -67187,10 +67312,11 @@ const ListUI = props => {
67187
67312
  onListVisibleItemsChange,
67188
67313
  virtualItemSize,
67189
67314
  scrolled,
67190
- defaultScrolled = "start",
67315
+ defaultScrolled: defaultScrolledProp = "start",
67191
67316
  onScrolledChange,
67192
67317
  scroller = "self",
67193
67318
  hoverWhileScrolling = false,
67319
+ scrollResetOnNavigation = false,
67194
67320
  lockSize,
67195
67321
  columns,
67196
67322
  itemColumns,
@@ -67209,6 +67335,20 @@ const ListUI = props => {
67209
67335
  listRows,
67210
67336
  ...rest
67211
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;
67212
67352
  const scrollBoxPaddingProps = {};
67213
67353
  for (const name of LIST_PADDING_PROP_SET) {
67214
67354
  if (name in rest) {
@@ -67303,6 +67443,8 @@ const ListUI = props => {
67303
67443
  scrolled,
67304
67444
  defaultScrolled,
67305
67445
  onScrolledChange,
67446
+ rememberScroll,
67447
+ listId: rest.id,
67306
67448
  scroller,
67307
67449
  searchText,
67308
67450
  horizontal
@@ -67335,7 +67477,7 @@ const ListUI = props => {
67335
67477
  // locator). Both answer here, so a row is reachable whether or not the
67336
67478
  // window happens to frame it.
67337
67479
  const getItemById = itemId => {
67338
- const itemDrawn = listRows.itemsSignal.peek().find(item => item.id === itemId);
67480
+ const itemDrawn = listRows.itemsSignal.peek().find(item => item.itemId === itemId);
67339
67481
  if (itemDrawn) {
67340
67482
  return itemDrawn;
67341
67483
  }
@@ -67345,6 +67487,7 @@ const ListUI = props => {
67345
67487
  }
67346
67488
  return {
67347
67489
  id: itemId,
67490
+ itemId,
67348
67491
  index: rowIndex
67349
67492
  };
67350
67493
  };
@@ -67627,6 +67770,8 @@ const useListScrollSync = ({
67627
67770
  scrolled,
67628
67771
  defaultScrolled,
67629
67772
  onScrolledChange,
67773
+ rememberScroll,
67774
+ listId,
67630
67775
  scroller,
67631
67776
  searchText,
67632
67777
  horizontal
@@ -67839,15 +67984,18 @@ const useListScrollSync = ({
67839
67984
  `"${event.type}" on ${getElementSignature(event.target)} (${reason})`;
67840
67985
  // When we display the list we prefer to have selected item at the center
67841
67986
  // otherwise, usually when focused by arrow nav, we want to keep it into view close to the nearest edge
67842
- const block = blockRequested || (event.type === "navi_displayed" ? "center" : "nearest");
67843
- `${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" })`;
67844
67989
  // The list is going somewhere on purpose, so there is no view to hold
67845
67990
  // still any more: an anchor captured before this drop it, or it would
67846
67991
  // put the list back where it was the moment the rows move under it.
67847
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.
67848
67995
  scrollIntoViewScoped(itemEl, {
67849
67996
  container: getScroller(),
67850
- block
67997
+ block: align,
67998
+ inline: align
67851
67999
  });
67852
68000
  const listEl = getListEl();
67853
68001
  dispatchPublicCustomEvent(listEl, "navi_scroll", {
@@ -67859,7 +68007,7 @@ const useListScrollSync = ({
67859
68007
  // Whether the row is drawn is asked of the dom, not of the render window:
67860
68008
  // the window says what a run draws, and a list whose rows are declared one
67861
68009
  // by one has them all in the dom whatever the window says.
67862
- const itemEl = findRowElement(getListEl(), item.id);
68010
+ const itemEl = findRowElement(getListEl(), item.itemId);
67863
68011
  if (itemEl) {
67864
68012
  scrollItemIntoView(itemEl);
67865
68013
  return;
@@ -67867,7 +68015,7 @@ const useListScrollSync = ({
67867
68015
  // Not in DOM — shift the render window. The item will read
67868
68016
  // pendingScrollRef on mount and scroll into view.
67869
68017
  pendingScrollRef.current = {
67870
- id: item.id,
68018
+ id: item.itemId,
67871
68019
  resolve: itemEl => {
67872
68020
  pendingScrollRef.current = null;
67873
68021
  scrollItemIntoView(itemEl);
@@ -68218,6 +68366,8 @@ const useListScrollSync = ({
68218
68366
  // one was looking at is then somewhere else.
68219
68367
  const onScrolledChangeRef = useRef(null);
68220
68368
  onScrolledChangeRef.current = onScrolledChange;
68369
+ const rememberScrollRef = useRef(false);
68370
+ rememberScrollRef.current = rememberScroll;
68221
68371
  // Where the list was at the last thing that moved it. Kept whether anyone
68222
68372
  // asked for it or not: it is what a resize needs to put things back.
68223
68373
  const positionRef = useRef(null);
@@ -68235,16 +68385,32 @@ const useListScrollSync = ({
68235
68385
  return;
68236
68386
  }
68237
68387
  positionRef.current = position;
68238
- if (!onScrolledChangeRef.current) {
68388
+ const remember = rememberScrollRef.current;
68389
+ const onScrolledChange = onScrolledChangeRef.current;
68390
+ if (!remember && !onScrolledChange) {
68239
68391
  return;
68240
68392
  }
68241
68393
  const rowEl = findRowElement(getListEl(), position.id);
68242
- onScrolledChangeRef.current({
68394
+ const scrolledNow = {
68243
68395
  id: position.id,
68244
68396
  index: position.index,
68245
68397
  offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal)
68246
- });
68398
+ };
68399
+ if (remember) {
68400
+ rememberScrollerPosition(listId, scrolledNow);
68401
+ }
68402
+ if (onScrolledChange) {
68403
+ onScrolledChange(scrolledNow);
68404
+ }
68247
68405
  };
68406
+ // Leaving: with its page, or alone (see forgetScrollerUnlessPageLeft).
68407
+ useLayoutEffect(() => {
68408
+ return () => {
68409
+ if (rememberScrollRef.current) {
68410
+ forgetScrollerUnlessPageLeft(listId);
68411
+ }
68412
+ };
68413
+ }, []);
68248
68414
 
68249
68415
  // A list that gets narrower rewraps every row it holds, so everything below
68250
68416
  // moves and the reader loses their place — the very thing scrolling a long
@@ -68317,7 +68483,7 @@ const useListScrollSync = ({
68317
68483
  return;
68318
68484
  }
68319
68485
  const items = listRows.visibleItemsSignal.peek();
68320
- const itemNow = items.find(i => i.id === anchor.id);
68486
+ const itemNow = items.find(i => i.itemId === anchor.id);
68321
68487
  if (!itemNow) {
68322
68488
  anchorRef.current = null;
68323
68489
  return;
@@ -68920,17 +69086,22 @@ const resolveScrollInset = (value, viewportSize) => {
68920
69086
  return number;
68921
69087
  };
68922
69088
 
68923
- // The row with that id, IN THIS LIST. Not document.getElementById: an id is
68924
- // only ever unique within a list — two lists on the same page can be showing
68925
- // the same collection and a list acting on a row that belongs to another one
68926
- // is a spectacular kind of wrong (it scrolls to hold still something it is not
68927
- // 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).
68928
69095
  const findRowElement = (listEl, id) => {
68929
- return listEl.querySelector(`[id="${CSS.escape(id)}"]`);
69096
+ return listEl.querySelector(`[navi-list-item-real="${CSS.escape(id)}"]`);
68930
69097
  };
69098
+ const getRowName = rowEl => rowEl.getAttribute("navi-list-item-real");
68931
69099
 
68932
69100
  // The row the user is looking at, and where it sits: what must not move when
68933
- // 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.
68934
69105
  const captureScrollAnchor = ({
68935
69106
  scrollerEl,
68936
69107
  listEl,
@@ -68942,30 +69113,31 @@ const captureScrollAnchor = ({
68942
69113
  }
68943
69114
  const viewportRect = getScrollerViewportRect(scrollerEl);
68944
69115
  const listRect = listEl.getBoundingClientRect();
68945
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
68946
- if (!scanRange) {
69116
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
69117
+ if (!range) {
68947
69118
  return null;
68948
69119
  }
69120
+ const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
69121
+ const {
69122
+ rowEls,
69123
+ index
69124
+ } = findRowsFrom(listEl, range.from, horizontal);
68949
69125
  let fallbackAnchor = null;
68950
- for (let pos = scanRange.from + 1; pos < scanRange.to; pos += 8) {
68951
- const x = horizontal ? pos : scanRange.crossPos;
68952
- const y = horizontal ? scanRange.crossPos : pos;
68953
- const el = document.elementFromPoint(x, y);
68954
- if (!el || !listEl.contains(el)) {
68955
- continue;
68956
- }
68957
- const itemEl = el.closest(REAL_LIST_ITEM_SELECTOR);
68958
- if (!itemEl) {
68959
- 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;
68960
69132
  }
68961
- const item = items.find(i => i.id === itemEl.id);
69133
+ const rowName = getRowName(rowEl);
69134
+ const item = items.find(i => i.itemId === rowName);
68962
69135
  if (!item) {
68963
69136
  continue;
68964
69137
  }
68965
- const itemRect = itemEl.getBoundingClientRect();
68966
- const offset = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
69138
+ const offset = rowStart - viewportFrom;
68967
69139
  const anchor = {
68968
- id: item.id,
69140
+ id: item.itemId,
68969
69141
  index: item.index,
68970
69142
  offset
68971
69143
  };
@@ -68985,43 +69157,61 @@ const captureScrollAnchor = ({
68985
69157
  // The part of the list that is on screen, along the scrolling axis. Both edges
68986
69158
  // matter: the scroller may be larger than the list (scroller="parent") as well
68987
69159
  // as smaller (the list scrolls inside its own box).
68988
- const getListVisibleScanRange = (viewportRect, listRect, horizontal) => {
68989
- // The screen has a say too: what is asked here is answered by
68990
- // elementFromPoint, which only knows about points that are actually on it. A
68991
- // list whose scroll box hangs below the fold of the page would otherwise be
68992
- // probed where nothing can be hit — and would silently stop keeping its rows
68993
- // still, which is exactly when it matters.
68994
- const screenTo = horizontal ? document.documentElement.clientWidth : document.documentElement.clientHeight;
69160
+ const getListVisibleRange = (viewportRect, listRect, horizontal) => {
68995
69161
  const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
68996
69162
  const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
68997
69163
  const listFrom = horizontal ? listRect.left : listRect.top;
68998
69164
  const listTo = horizontal ? listRect.right : listRect.bottom;
68999
- let from = listFrom > viewportFrom ? listFrom : viewportFrom;
69000
- let to = listTo < viewportTo ? listTo : viewportTo;
69001
- if (from < 0) {
69002
- from = 0;
69003
- }
69004
- if (to > screenTo) {
69005
- to = screenTo;
69006
- }
69165
+ const from = listFrom > viewportFrom ? listFrom : viewportFrom;
69166
+ const to = listTo < viewportTo ? listTo : viewportTo;
69007
69167
  if (to - from < 2) {
69008
69168
  return null;
69009
69169
  }
69010
- // Where to put the probe on the other axis: inside the list, inside the
69011
- // viewport.
69012
- const crossFrom = horizontal ? listRect.top : listRect.left;
69013
- const crossViewportFrom = horizontal ? viewportRect.top : viewportRect.left;
69014
- const crossPos = (crossFrom > crossViewportFrom ? crossFrom : crossViewportFrom) + 1;
69015
69170
  return {
69016
69171
  from,
69017
- to,
69018
- crossPos
69172
+ to
69019
69173
  };
69020
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
+ };
69021
69210
 
69022
- // Which row of the collection sits at the current scroll position. Uses DOM
69023
- // hit-testing when a real row is there to be hit, and the row size when what is
69024
- // 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.
69025
69215
  // Returns { index, item, reason } or null if nothing can be determined.
69026
69216
  const getScrollInfo = ({
69027
69217
  scrollValues,
@@ -69035,34 +69225,31 @@ const getScrollInfo = ({
69035
69225
  const items = listRows.itemsSignal.peek();
69036
69226
  const viewportRect = getScrollerViewportRect(scrollerEl);
69037
69227
  const listRect = listEl.getBoundingClientRect();
69038
- let hitEl = null;
69039
- let hitFiller = null;
69040
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
69041
- if (!scanRange) {
69228
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
69229
+ if (!range) {
69042
69230
  return null;
69043
69231
  }
69044
- // Start scanning from the center of the visible part of the list along the
69045
- // main axis. The render window places half its budget before and half after
69046
- // the hit index. Anchoring to the center maximises how many rendered items
69047
- // fall within the visible area.
69048
- const scanStart = (scanRange.from + scanRange.to) / 2;
69049
- const scanEnd = scanRange.to;
69050
- for (let pos = scanStart; pos < scanEnd; pos += 4) {
69051
- const x = horizontal ? pos : scanRange.crossPos;
69052
- const y = horizontal ? scanRange.crossPos : pos;
69053
- const el = document.elementFromPoint(x, y);
69054
- if (!el || !listEl.contains(el)) {
69055
- continue;
69056
- }
69057
- const realItem = el.closest(REAL_LIST_ITEM_SELECTOR);
69058
- if (realItem) {
69059
- hitEl = realItem;
69060
- break;
69061
- }
69062
- const filler = el.closest("[navi-virtual-filler]");
69063
- if (filler) {
69064
- hitFiller = filler;
69065
- 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
+ }
69066
69253
  }
69067
69254
  }
69068
69255
  // Shared by the "hit a filler" and "hit nothing at all" cases below: both
@@ -69093,8 +69280,8 @@ const getScrollInfo = ({
69093
69280
  return estimateFromScrollPos("hit filler");
69094
69281
  }
69095
69282
  if (hitEl) {
69096
- const hitId = hitEl.id;
69097
- const item = items.find(i => i.id === hitId);
69283
+ const hitName = getRowName(hitEl);
69284
+ const item = items.find(i => i.itemId === hitName);
69098
69285
  if (!item) {
69099
69286
  return null;
69100
69287
  }
@@ -69104,13 +69291,11 @@ const getScrollInfo = ({
69104
69291
  reason: `hit item at ${item.index} (${item.value})`
69105
69292
  };
69106
69293
  }
69107
- // Neither a real item nor a filler was hit within listEl e.g. part of
69108
- // the scan range fell outside the page's actually reachable viewport
69109
- // (docked devtools shrinks it, for one). Keeping the stale renderWindow
69110
- // here means the DOM never gets asked to catch up with a scrollTop that may
69111
- // have jumped far away the user ends up staring at filler space. Same
69112
- // estimate as the hitFiller case is a safe fallback: it only needs the
69113
- // 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.
69114
69299
  const estimated = estimateFromScrollPos("no hit");
69115
69300
  if (estimated) {
69116
69301
  return estimated;
@@ -69545,6 +69730,12 @@ const ListItemUI = props => {
69545
69730
  // gave the row its place and decided it is inside the render window.
69546
69731
  const row = useContext(ListRowContext);
69547
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;
69548
69739
  // There is no standalone match/matchScore/highlight prop — participation
69549
69740
  // in a matching system (search, filter…) only goes through `matchInfo`
69550
69741
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
@@ -69625,6 +69816,7 @@ const ListItemReal = props => {
69625
69816
  const {
69626
69817
  ref,
69627
69818
  id,
69819
+ itemId,
69628
69820
  hidden,
69629
69821
  muted,
69630
69822
  loading,
@@ -69644,7 +69836,7 @@ const ListItemReal = props => {
69644
69836
  // see the state change, which only the caller can arrange).
69645
69837
  const pendingScrollRef = useContext(PendingScrollRefContext);
69646
69838
  const pendingScroll = pendingScrollRef.current;
69647
- const needScrollOnMount = pendingScroll && pendingScroll.id === id;
69839
+ const needScrollOnMount = pendingScroll && pendingScroll.id === itemId;
69648
69840
  useLayoutEffect(() => {
69649
69841
  if (!needScrollOnMount) {
69650
69842
  return;
@@ -69743,7 +69935,7 @@ const ListItemReal = props => {
69743
69935
  baseClassName: "navi_list_item",
69744
69936
  styleCSSVars: LIST_ITEM_STYLE_CSS_VARS,
69745
69937
  id: id,
69746
- "navi-list-item-real": "",
69938
+ "navi-list-item-real": itemId,
69747
69939
  ...rest,
69748
69940
  ...itemColumnsOverrideProps,
69749
69941
  index: undefined,
@@ -71115,6 +71307,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71115
71307
  * onScrolledChange?: (scrolled: {id: string, index: number, offset: number}) => void,
71116
71308
  * scroller?: "self" | "parent" | "document" | Element | {current: Element},
71117
71309
  * hoverWhileScrolling?: boolean,
71310
+ * scrollResetOnNavigation?: boolean,
71118
71311
  * fallback?: import("ignore:preact").ComponentChildren,
71119
71312
  * searchFallback?: import("ignore:preact").ComponentChildren,
71120
71313
  * searchText?: string,
@@ -71206,7 +71399,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71206
71399
  * list is already known to be empty: the empty `fallback` shows right away
71207
71400
  * rather than an empty frame, so nothing moves when the response arrives.
71208
71401
  * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
71209
- * 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
71210
71404
  * thread read backwards — the last rows are the ones to show, and the ones
71211
71405
  * asked for first. A number opens on that row of the collection. `{id,
71212
71406
  * offset}` — what `onScrolledChange` hands out — opens on a NAMED row,
@@ -71289,6 +71483,15 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71289
71483
  * Pass `true` for a list whose rows must stay live under the pointer while
71290
71484
  * it scrolls. The trade of the default is the mirror one: right after a
71291
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`.
71292
71495
  * @param {boolean} [props.deselectable]
71293
71496
  * A single-select list allowed to hold nothing: the selected row, pressed
71294
71497
  * again, lets go. Without it the list is a radio group — a choice, once