@jsenv/navi 0.29.361 → 0.29.363

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.
@@ -8914,6 +8914,17 @@ const naviI18nFromValidityMessage = ({ key, params }) => {
8914
8914
  return naviI18n(`constraint.${key}`, params);
8915
8915
  };
8916
8916
 
8917
+ // The characters a ui state holds, for the rules that read a value as text
8918
+ // (length, pattern, number, email…). `undefined` and `null` both hold none:
8919
+ // `String(null)` would hand those rules the four letters "null" — a number
8920
+ // field cleared with `value={null}` then reported "must be a number".
8921
+ const uiStateAsText = (uiState) => {
8922
+ if (uiState === undefined || uiState === null) {
8923
+ return "";
8924
+ }
8925
+ return String(uiState);
8926
+ };
8927
+
8917
8928
  /**
8918
8929
  * https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Constraint_validation
8919
8930
  */
@@ -8980,8 +8991,7 @@ const REQUIRED_CONSTRAINT = {
8980
8991
  return naviI18n("constraint.required.checkbox");
8981
8992
  }
8982
8993
 
8983
- const valueAsString =
8984
- field.uiState === undefined ? "" : String(field.uiState);
8994
+ const valueAsString = uiStateAsText(field.uiState);
8985
8995
  if (valueAsString) {
8986
8996
  return null;
8987
8997
  }
@@ -9032,8 +9042,7 @@ const PATTERN_CONSTRAINT = {
9032
9042
  if (!pattern) {
9033
9043
  return null;
9034
9044
  }
9035
- const valueAsString =
9036
- field.uiState === undefined ? "" : String(field.uiState);
9045
+ const valueAsString = uiStateAsText(field.uiState);
9037
9046
  if (!valueAsString) {
9038
9047
  return null;
9039
9048
  }
@@ -9068,8 +9077,7 @@ const TYPE_EMAIL_CONSTRAINT = {
9068
9077
  if (type !== "email") {
9069
9078
  return null;
9070
9079
  }
9071
- const valueAsString =
9072
- field.uiState === undefined ? "" : String(field.uiState);
9080
+ const valueAsString = uiStateAsText(field.uiState);
9073
9081
  if (!valueAsString) {
9074
9082
  return null;
9075
9083
  }
@@ -9102,8 +9110,7 @@ const MIN_LENGTH_CONSTRAINT = {
9102
9110
  if (minLength === undefined) {
9103
9111
  return null;
9104
9112
  }
9105
- const valueAsString =
9106
- field.uiState === undefined ? "" : String(field.uiState);
9113
+ const valueAsString = uiStateAsText(field.uiState);
9107
9114
  if (!valueAsString && !field.controlHostProps.required) {
9108
9115
  return null;
9109
9116
  }
@@ -9198,8 +9205,7 @@ const MAX_LENGTH_CONSTRAINT = {
9198
9205
  if (maxLength === undefined) {
9199
9206
  return null;
9200
9207
  }
9201
- const valueAsString =
9202
- field.uiState === undefined ? "" : String(field.uiState);
9208
+ const valueAsString = uiStateAsText(field.uiState);
9203
9209
  if (!valueAsString) {
9204
9210
  return null;
9205
9211
  }
@@ -9240,8 +9246,7 @@ const TYPE_NUMBER_CONSTRAINT = {
9240
9246
  if (!isNumberInput(type, naviType)) {
9241
9247
  return null;
9242
9248
  }
9243
- const valueAsString =
9244
- field.uiState === undefined ? "" : String(field.uiState);
9249
+ const valueAsString = uiStateAsText(field.uiState);
9245
9250
  if (!valueAsString) {
9246
9251
  return null;
9247
9252
  }
@@ -9306,8 +9311,7 @@ const MIN_CONSTRAINT = {
9306
9311
  }
9307
9312
  const type = field.controlHostProps.type;
9308
9313
  const naviInputType = field.controlHostProps["navi-input-type"];
9309
- const valueAsString =
9310
- field.uiState === undefined ? "" : String(field.uiState);
9314
+ const valueAsString = uiStateAsText(field.uiState);
9311
9315
  if (!valueAsString) {
9312
9316
  return null;
9313
9317
  }
@@ -9406,8 +9410,7 @@ const MAX_CONSTRAINT = {
9406
9410
  }
9407
9411
  const type = field.controlHostProps.type;
9408
9412
  const naviInputType = field.controlHostProps["navi-input-type"];
9409
- const valueAsString =
9410
- field.uiState === undefined ? "" : String(field.uiState);
9413
+ const valueAsString = uiStateAsText(field.uiState);
9411
9414
  if (!valueAsString) {
9412
9415
  return null;
9413
9416
  }
@@ -9540,8 +9543,7 @@ const STEP_CONSTRAINT = {
9540
9543
  return null;
9541
9544
  }
9542
9545
  const stepString = String(stepRaw);
9543
- const valueAsString =
9544
- field.uiState === undefined ? "" : String(field.uiState);
9546
+ const valueAsString = uiStateAsText(field.uiState);
9545
9547
  if (!valueAsString) {
9546
9548
  return null;
9547
9549
  }
@@ -12341,8 +12343,7 @@ const DISPLAYABLE_CONSTRAINT = {
12341
12343
  if (!isConstraintAttributeOn(displayable)) {
12342
12344
  return null;
12343
12345
  }
12344
- const valueAsString =
12345
- field.uiState === undefined ? "" : String(field.uiState);
12346
+ const valueAsString = uiStateAsText(field.uiState);
12346
12347
  const maxStackedMarksAttribute =
12347
12348
  field.controlHostProps["data-max-stacked-marks"];
12348
12349
  const result = DISPLAYABLE_RULE.applyOn(true, valueAsString, {
@@ -12383,8 +12384,7 @@ const MAX_LINE_BREAKS_CONSTRAINT = {
12383
12384
  if (isNaN(maxLineBreaks)) {
12384
12385
  return null;
12385
12386
  }
12386
- const valueAsString =
12387
- field.uiState === undefined ? "" : String(field.uiState);
12387
+ const valueAsString = uiStateAsText(field.uiState);
12388
12388
  const result = MAX_LINE_BREAKS_RULE.applyOn(maxLineBreaks, valueAsString);
12389
12389
  if (!result) {
12390
12390
  return null;
@@ -12413,8 +12413,7 @@ const NO_EMOJI_CONSTRAINT = {
12413
12413
  if (!isConstraintAttributeOn(noEmoji)) {
12414
12414
  return null;
12415
12415
  }
12416
- const valueAsString =
12417
- field.uiState === undefined ? "" : String(field.uiState);
12416
+ const valueAsString = uiStateAsText(field.uiState);
12418
12417
  const result = NO_EMOJI_RULE.applyOn(true, valueAsString);
12419
12418
  if (!result) {
12420
12419
  return null;
@@ -12428,8 +12427,7 @@ const MIN_LOWER_LETTER_CONSTRAINT = {
12428
12427
  name: "min_lower_letter",
12429
12428
  messageAttribute: "data-min-lower-letter-message",
12430
12429
  check: (field) => {
12431
- const valueAsString =
12432
- field.uiState === undefined ? "" : String(field.uiState);
12430
+ const valueAsString = uiStateAsText(field.uiState);
12433
12431
  const required = field.controlHostProps.required;
12434
12432
  if (!valueAsString && !required) {
12435
12433
  return "";
@@ -12474,8 +12472,7 @@ const MIN_UPPER_LETTER_CONSTRAINT = {
12474
12472
  name: "min_upper_letter",
12475
12473
  messageAttribute: "data-min-upper-letter-message",
12476
12474
  check: (field) => {
12477
- const valueAsString =
12478
- field.uiState === undefined ? "" : String(field.uiState);
12475
+ const valueAsString = uiStateAsText(field.uiState);
12479
12476
  const required = field.controlHostProps.required;
12480
12477
  if (!valueAsString && !required) {
12481
12478
  return null;
@@ -12511,8 +12508,7 @@ const MIN_DIGIT_CONSTRAINT = {
12511
12508
  name: "min_digit",
12512
12509
  messageAttribute: "data-min-digit-message",
12513
12510
  check: (field) => {
12514
- const valueAsString =
12515
- field.uiState === undefined ? "" : String(field.uiState);
12511
+ const valueAsString = uiStateAsText(field.uiState);
12516
12512
  const required = field.controlHostProps.required;
12517
12513
  if (!valueAsString && !required) {
12518
12514
  return null;
@@ -12548,8 +12544,7 @@ const MIN_SPECIAL_CHAR_CONSTRAINT = {
12548
12544
  name: "min_special_char",
12549
12545
  messageAttribute: "data-min-special-char-message",
12550
12546
  check: (field) => {
12551
- const valueAsString =
12552
- field.uiState === undefined ? "" : String(field.uiState);
12547
+ const valueAsString = uiStateAsText(field.uiState);
12553
12548
  const required = field.controlHostProps.required;
12554
12549
  if (!valueAsString && !required) {
12555
12550
  return null;
@@ -12666,8 +12661,7 @@ const SAME_AS_CONSTRAINT = {
12666
12661
  // Reference field is empty — nothing to compare against yet.
12667
12662
  return null;
12668
12663
  }
12669
- const valueAsString =
12670
- field.uiState === undefined ? "" : String(field.uiState);
12664
+ const valueAsString = uiStateAsText(field.uiState);
12671
12665
  if (valueAsString === otherFieldValue) {
12672
12666
  return null;
12673
12667
  }
@@ -12708,8 +12702,7 @@ CONSTRAINT_ATTRIBUTE_SET.add("data-same-as");
12708
12702
 
12709
12703
 
12710
12704
  const applyRule = (field) => {
12711
- const valueAsString =
12712
- field.uiState === undefined ? "" : String(field.uiState);
12705
+ const valueAsString = uiStateAsText(field.uiState);
12713
12706
  return SINGLE_SPACE_RULE.applyOn(true, valueAsString);
12714
12707
  };
12715
12708
 
@@ -26978,6 +26971,18 @@ const takeoverRoutingRenderingHold = () => {
26978
26971
  * to a reader. Kept in the session too, so a reload lands where the browser
26979
26972
  * would have landed — the flag above is a promise to do the whole job.
26980
26973
  *
26974
+ * The document is not the only scrollport of a page. A list scrolling itself
26975
+ * (a `<List expandY>` under a search field that stays put) is left and come
26976
+ * back to the same way, and the browser never knew it was a scrollport at
26977
+ * all: those say where they are by name (rememberScrollerPosition), under the
26978
+ * same URL and for the same session, and ask it back when they mount again
26979
+ * (recallScrollerPosition). What a push means for them is what it means for
26980
+ * the document — an arrival opens at the top (see startAtTop): the page
26981
+ * arrived at has its named positions dropped before its lists render, so a
26982
+ * list recalls only on the way back. A scroller that goes while its page
26983
+ * stays — a popup closing over the same address — has nothing to come back
26984
+ * to, and says so as it leaves (forgetScrollerUnlessPageLeft).
26985
+ *
26981
26986
  * What is NOT covered, and cannot be from here: a page whose height depends on
26982
26987
  * something still loading. Its content is not there at the moment it is put
26983
26988
  * back, so a position beyond what has arrived is clamped as before. Only the
@@ -26995,7 +27000,23 @@ const takeoverRoutingRenderingHold = () => {
26995
27000
  const STORAGE_KEY = "navi_scroll_positions";
26996
27001
 
26997
27002
  const positionByUrl = new Map();
26998
- const readStoredPositions = () => {
27003
+ // url -> (scroller name -> position). The position is whatever the scroller
27004
+ // handed in: what it can put itself back on, in its own terms.
27005
+ const scrollerPositionsByUrl = new Map();
27006
+ // The url each named scroller last spoke under: what tells a scroller leaving
27007
+ // a page that stays from one leaving with its page.
27008
+ const urlByScrollerName = new Map();
27009
+
27010
+ // Read once, the first time anyone needs the positions: the document's
27011
+ // restoration is installed by the routing, and a list remembering itself may
27012
+ // mount in an app that never routes.
27013
+ let storeLoaded = false;
27014
+ const loadStore = () => {
27015
+ if (storeLoaded) {
27016
+ return;
27017
+ }
27018
+ storeLoaded = true;
27019
+ window.addEventListener("pagehide", storePositions);
26999
27020
  let stored;
27000
27021
  try {
27001
27022
  stored = window.sessionStorage.getItem(STORAGE_KEY);
@@ -27008,18 +27029,31 @@ const readStoredPositions = () => {
27008
27029
  return;
27009
27030
  }
27010
27031
  try {
27011
- for (const [url, position] of Object.entries(JSON.parse(stored))) {
27032
+ const { document: documentPositions, scrollers } = JSON.parse(stored);
27033
+ for (const [url, position] of Object.entries(documentPositions || {})) {
27012
27034
  positionByUrl.set(url, position);
27013
27035
  }
27036
+ for (const [url, positionByName] of Object.entries(scrollers || {})) {
27037
+ scrollerPositionsByUrl.set(url, new Map(Object.entries(positionByName)));
27038
+ }
27014
27039
  } catch {
27015
27040
  // Something else wrote there, or it was truncated.
27016
27041
  }
27017
27042
  };
27018
27043
  const storePositions = () => {
27044
+ const scrollers = {};
27045
+ for (const [url, positionByName] of scrollerPositionsByUrl) {
27046
+ if (positionByName.size > 0) {
27047
+ scrollers[url] = Object.fromEntries(positionByName);
27048
+ }
27049
+ }
27019
27050
  try {
27020
27051
  window.sessionStorage.setItem(
27021
27052
  STORAGE_KEY,
27022
- JSON.stringify(Object.fromEntries(positionByUrl)),
27053
+ JSON.stringify({
27054
+ document: Object.fromEntries(positionByUrl),
27055
+ scrollers,
27056
+ }),
27023
27057
  );
27024
27058
  } catch {
27025
27059
  // Full, or refused: the session is the only thing lost.
@@ -27059,7 +27093,7 @@ const installScrollRestoration = () => {
27059
27093
  return;
27060
27094
  }
27061
27095
  window.history.scrollRestoration = "manual";
27062
- readStoredPositions();
27096
+ loadStore();
27063
27097
  // Read as it happens rather than when leaving: a traverse changes the url
27064
27098
  // before anything here is told, so a position read then would be read for
27065
27099
  // the wrong page.
@@ -27076,7 +27110,6 @@ const installScrollRestoration = () => {
27076
27110
  },
27077
27111
  { passive: true },
27078
27112
  );
27079
- window.addEventListener("pagehide", storePositions);
27080
27113
  // What a reload asks for, now that the browser has been told not to do it.
27081
27114
  // Once, and at the first render of a route: the position is only meaningful
27082
27115
  // once there is a page under it.
@@ -27119,18 +27152,35 @@ const restoreScrollPosition = (url) => {
27119
27152
  // The document, because the document is the scrollport in the common case. An
27120
27153
  // app that scrolls an element of its own scrolls it itself.
27121
27154
  const startAtTop = (url, { from } = {}) => {
27155
+ if (!isArrival(url, { from })) {
27156
+ return;
27157
+ }
27158
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27159
+ };
27160
+ const isArrival = (url, { from }) => {
27122
27161
  const urlObject = new URL(url, window.location.href);
27123
27162
  // A fragment names where to land, and the browser is the one that finds it.
27124
27163
  if (urlObject.hash) {
27125
- return;
27164
+ return false;
27126
27165
  }
27127
27166
  if (
27128
27167
  from !== undefined &&
27129
27168
  new URL(from, window.location.href).pathname === urlObject.pathname
27130
27169
  ) {
27170
+ return false;
27171
+ }
27172
+ return true;
27173
+ };
27174
+
27175
+ // The same arrival, for the page's own scrollers. The document is scrolled to
27176
+ // its top once the page is there; a list opens where it decides to in its
27177
+ // first render, so what it must not find is dropped before the routing
27178
+ // renders anything.
27179
+ const forgetScrollersOnArrival = (url, { from } = {}) => {
27180
+ if (!isArrival(url, { from })) {
27131
27181
  return;
27132
27182
  }
27133
- window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27183
+ scrollerPositionsByUrl.delete(new URL(url, window.location.href).href);
27134
27184
  };
27135
27185
 
27136
27186
  // An arrival at a page whose scrollport is already showing another one: the
@@ -27149,6 +27199,49 @@ const scrollTo = ({ x, y }) => {
27149
27199
  window.scrollTo({ top: y, left: x, behavior: "instant" });
27150
27200
  };
27151
27201
 
27202
+ const rememberScrollerPosition = (name, position) => {
27203
+ loadStore();
27204
+ const url = window.location.href;
27205
+ let positionByName = scrollerPositionsByUrl.get(url);
27206
+ if (!positionByName) {
27207
+ positionByName = new Map();
27208
+ scrollerPositionsByUrl.set(url, positionByName);
27209
+ }
27210
+ positionByName.set(name, position);
27211
+ urlByScrollerName.set(name, url);
27212
+ };
27213
+
27214
+ const recallScrollerPosition = (name) => {
27215
+ loadStore();
27216
+ const positionByName = scrollerPositionsByUrl.get(window.location.href);
27217
+ if (!positionByName) {
27218
+ return undefined;
27219
+ }
27220
+ return positionByName.get(name);
27221
+ };
27222
+
27223
+ // Said by a scroller as it unmounts. Its page is being left when the url is
27224
+ // already another one — the history is written before the page it names is
27225
+ // taken down — and then its position is kept for the way back. The url still
27226
+ // being the one it spoke under means the page stays and the scroller alone
27227
+ // goes (a popup closing, a section folding): there is no coming back to a
27228
+ // place that was not left, and a position kept would greet the next mount
27229
+ // under this address as a return.
27230
+ const forgetScrollerUnlessPageLeft = (name) => {
27231
+ const url = urlByScrollerName.get(name);
27232
+ if (url === undefined) {
27233
+ return;
27234
+ }
27235
+ urlByScrollerName.delete(name);
27236
+ if (url !== window.location.href) {
27237
+ return;
27238
+ }
27239
+ const positionByName = scrollerPositionsByUrl.get(url);
27240
+ if (positionByName) {
27241
+ positionByName.delete(name);
27242
+ }
27243
+ };
27244
+
27152
27245
  /**
27153
27246
  * A navigation is ABOUT to be applied — said before its very first write.
27154
27247
  *
@@ -27511,6 +27604,13 @@ const setupBrowserIntegrationViaHistory = ({
27511
27604
  return undefined;
27512
27605
  }
27513
27606
 
27607
+ // The page's own scrollers are told of an arrival before the routing
27608
+ // renders anything: a list arriving decides where it opens in its first
27609
+ // render (see scroll_restoration.js). The document itself is moved once
27610
+ // the page is there, below.
27611
+ if (navigationType === "push") {
27612
+ forgetScrollersOnArrival(url, { from: urlLeft });
27613
+ }
27514
27614
  if (abortController) {
27515
27615
  abortController.abort(`navigating to ${url}`);
27516
27616
  }
@@ -36428,9 +36528,9 @@ const getInvalidCharsMessage = (
36428
36528
  uiState,
36429
36529
  { charClass, messageKey, uiStateNow },
36430
36530
  ) => {
36431
- const str = uiState === undefined ? "" : String(uiState);
36531
+ const str = uiStateAsText(uiState);
36432
36532
  if (compileCharClassAnchored(charClass).test(str)) return null;
36433
- const strNow = uiStateNow === undefined ? "" : String(uiStateNow);
36533
+ const strNow = uiStateAsText(uiStateNow);
36434
36534
  if (
36435
36535
  countCharsOutsideClass(str, charClass) <=
36436
36536
  countCharsOutsideClass(strNow, charClass)
@@ -36443,9 +36543,9 @@ const getInvalidCharsMessage = (
36443
36543
  // Paste / set: truncate what the gesture adds beyond the limit.
36444
36544
  const getLengthOverflowResult = (uiState, { maxLength, uiStateNow }) => {
36445
36545
  if (maxLength === undefined) return null;
36446
- const str = uiState === undefined ? "" : String(uiState);
36546
+ const str = uiStateAsText(uiState);
36447
36547
  if (str.length <= maxLength) return null;
36448
- const strNow = uiStateNow === undefined ? "" : String(uiStateNow);
36548
+ const strNow = uiStateAsText(uiStateNow);
36449
36549
  if (str.length <= strNow.length) return null;
36450
36550
  return {
36451
36551
  fixedValue: str.slice(0, maxLength),
@@ -63836,6 +63936,12 @@ const PickerCustom = props => {
63836
63936
  // PickerNative takes.
63837
63937
  pickerProps.resetOnError = true;
63838
63938
  }
63939
+ if (pickerProps.resetOnAbort === undefined) {
63940
+ // Same reasoning for an action the app abandons (an AbortError, e.g. a
63941
+ // verification step the user dismissed): the popup is already closed, so
63942
+ // the abandoned value rolls back instead of staying selected in the list.
63943
+ pickerProps.resetOnAbort = true;
63944
+ }
63839
63945
  // ref
63840
63946
  const popupRef = useRef(null);
63841
63947
  popupProps.ref = popupRef;
@@ -67205,10 +67311,11 @@ const ListUI = props => {
67205
67311
  onListVisibleItemsChange,
67206
67312
  virtualItemSize,
67207
67313
  scrolled,
67208
- defaultScrolled = "start",
67314
+ defaultScrolled: defaultScrolledProp = "start",
67209
67315
  onScrolledChange,
67210
67316
  scroller = "self",
67211
67317
  hoverWhileScrolling = false,
67318
+ scrollResetOnNavigation = false,
67212
67319
  lockSize,
67213
67320
  columns,
67214
67321
  itemColumns,
@@ -67227,6 +67334,20 @@ const ListUI = props => {
67227
67334
  listRows,
67228
67335
  ...rest
67229
67336
  } = props;
67337
+ // Remembered by name, and a name made up at render (see ListFirstResolver)
67338
+ // names no list a later mount would recognize.
67339
+ const rememberScroll = !scrollResetOnNavigation && !isLikelyPreactGeneratedId(rest.id);
67340
+ // Where the list was when its screen was left, when this is the way back
67341
+ // (see scroll_restoration.js). Read once: `defaultScrolled` is held by
67342
+ // reference, and a place read again each render would be a list moved each
67343
+ // render. A list held by its caller (`scrolled`) is where the caller says.
67344
+ const [scrolledRemembered] = useState(() => {
67345
+ if (!rememberScroll || scrolled !== undefined && scrolled !== null) {
67346
+ return undefined;
67347
+ }
67348
+ return recallScrollerPosition(rest.id);
67349
+ });
67350
+ const defaultScrolled = scrolledRemembered || defaultScrolledProp;
67230
67351
  const scrollBoxPaddingProps = {};
67231
67352
  for (const name of LIST_PADDING_PROP_SET) {
67232
67353
  if (name in rest) {
@@ -67321,6 +67442,8 @@ const ListUI = props => {
67321
67442
  scrolled,
67322
67443
  defaultScrolled,
67323
67444
  onScrolledChange,
67445
+ rememberScroll,
67446
+ listId: rest.id,
67324
67447
  scroller,
67325
67448
  searchText,
67326
67449
  horizontal
@@ -67353,7 +67476,7 @@ const ListUI = props => {
67353
67476
  // locator). Both answer here, so a row is reachable whether or not the
67354
67477
  // window happens to frame it.
67355
67478
  const getItemById = itemId => {
67356
- const itemDrawn = listRows.itemsSignal.peek().find(item => item.id === itemId);
67479
+ const itemDrawn = listRows.itemsSignal.peek().find(item => item.itemId === itemId);
67357
67480
  if (itemDrawn) {
67358
67481
  return itemDrawn;
67359
67482
  }
@@ -67363,6 +67486,7 @@ const ListUI = props => {
67363
67486
  }
67364
67487
  return {
67365
67488
  id: itemId,
67489
+ itemId,
67366
67490
  index: rowIndex
67367
67491
  };
67368
67492
  };
@@ -67645,6 +67769,8 @@ const useListScrollSync = ({
67645
67769
  scrolled,
67646
67770
  defaultScrolled,
67647
67771
  onScrolledChange,
67772
+ rememberScroll,
67773
+ listId,
67648
67774
  scroller,
67649
67775
  searchText,
67650
67776
  horizontal
@@ -67857,15 +67983,18 @@ const useListScrollSync = ({
67857
67983
  `"${event.type}" on ${getElementSignature(event.target)} (${reason})`;
67858
67984
  // When we display the list we prefer to have selected item at the center
67859
67985
  // 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" })`;
67986
+ const align = blockRequested || (event.type === "navi_displayed" ? "center" : "nearest");
67987
+ `${getElementSignature(itemEl)}.scrollIntoView({ block: "${align}", inline: "${align}", container: "nearest" })`;
67862
67988
  // The list is going somewhere on purpose, so there is no view to hold
67863
67989
  // still any more: an anchor captured before this drop it, or it would
67864
67990
  // put the list back where it was the moment the rows move under it.
67865
67991
  anchorRef.current = null;
67992
+ // One alignment, said on both axes: the axis the list scrolls on is the
67993
+ // one that reads it, and the other has nothing to move.
67866
67994
  scrollIntoViewScoped(itemEl, {
67867
67995
  container: getScroller(),
67868
- block
67996
+ block: align,
67997
+ inline: align
67869
67998
  });
67870
67999
  const listEl = getListEl();
67871
68000
  dispatchPublicCustomEvent(listEl, "navi_scroll", {
@@ -67877,7 +68006,7 @@ const useListScrollSync = ({
67877
68006
  // Whether the row is drawn is asked of the dom, not of the render window:
67878
68007
  // the window says what a run draws, and a list whose rows are declared one
67879
68008
  // by one has them all in the dom whatever the window says.
67880
- const itemEl = findRowElement(getListEl(), item.id);
68009
+ const itemEl = findRowElement(getListEl(), item.itemId);
67881
68010
  if (itemEl) {
67882
68011
  scrollItemIntoView(itemEl);
67883
68012
  return;
@@ -67885,7 +68014,7 @@ const useListScrollSync = ({
67885
68014
  // Not in DOM — shift the render window. The item will read
67886
68015
  // pendingScrollRef on mount and scroll into view.
67887
68016
  pendingScrollRef.current = {
67888
- id: item.id,
68017
+ id: item.itemId,
67889
68018
  resolve: itemEl => {
67890
68019
  pendingScrollRef.current = null;
67891
68020
  scrollItemIntoView(itemEl);
@@ -68236,6 +68365,8 @@ const useListScrollSync = ({
68236
68365
  // one was looking at is then somewhere else.
68237
68366
  const onScrolledChangeRef = useRef(null);
68238
68367
  onScrolledChangeRef.current = onScrolledChange;
68368
+ const rememberScrollRef = useRef(false);
68369
+ rememberScrollRef.current = rememberScroll;
68239
68370
  // Where the list was at the last thing that moved it. Kept whether anyone
68240
68371
  // asked for it or not: it is what a resize needs to put things back.
68241
68372
  const positionRef = useRef(null);
@@ -68253,16 +68384,32 @@ const useListScrollSync = ({
68253
68384
  return;
68254
68385
  }
68255
68386
  positionRef.current = position;
68256
- if (!onScrolledChangeRef.current) {
68387
+ const remember = rememberScrollRef.current;
68388
+ const onScrolledChange = onScrolledChangeRef.current;
68389
+ if (!remember && !onScrolledChange) {
68257
68390
  return;
68258
68391
  }
68259
68392
  const rowEl = findRowElement(getListEl(), position.id);
68260
- onScrolledChangeRef.current({
68393
+ const scrolledNow = {
68261
68394
  id: position.id,
68262
68395
  index: position.index,
68263
68396
  offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal)
68264
- });
68397
+ };
68398
+ if (remember) {
68399
+ rememberScrollerPosition(listId, scrolledNow);
68400
+ }
68401
+ if (onScrolledChange) {
68402
+ onScrolledChange(scrolledNow);
68403
+ }
68265
68404
  };
68405
+ // Leaving: with its page, or alone (see forgetScrollerUnlessPageLeft).
68406
+ useLayoutEffect(() => {
68407
+ return () => {
68408
+ if (rememberScrollRef.current) {
68409
+ forgetScrollerUnlessPageLeft(listId);
68410
+ }
68411
+ };
68412
+ }, []);
68266
68413
 
68267
68414
  // A list that gets narrower rewraps every row it holds, so everything below
68268
68415
  // moves and the reader loses their place — the very thing scrolling a long
@@ -68335,7 +68482,7 @@ const useListScrollSync = ({
68335
68482
  return;
68336
68483
  }
68337
68484
  const items = listRows.visibleItemsSignal.peek();
68338
- const itemNow = items.find(i => i.id === anchor.id);
68485
+ const itemNow = items.find(i => i.itemId === anchor.id);
68339
68486
  if (!itemNow) {
68340
68487
  anchorRef.current = null;
68341
68488
  return;
@@ -68938,17 +69085,22 @@ const resolveScrollInset = (value, viewportSize) => {
68938
69085
  return number;
68939
69086
  };
68940
69087
 
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).
69088
+ // The row of that name, IN THIS LIST by the name the list knows it under
69089
+ // (see ListItemUI), not the element's id. Not document.getElementById either:
69090
+ // a name is only ever unique within a list two lists on the same page can be
69091
+ // showing the same collection and a list acting on a row that belongs to
69092
+ // another one is a spectacular kind of wrong (it scrolls to hold still
69093
+ // something it is not even showing).
68946
69094
  const findRowElement = (listEl, id) => {
68947
- return listEl.querySelector(`[id="${CSS.escape(id)}"]`);
69095
+ return listEl.querySelector(`[navi-list-item-real="${CSS.escape(id)}"]`);
68948
69096
  };
69097
+ const getRowName = rowEl => rowEl.getAttribute("navi-list-item-real");
68949
69098
 
68950
69099
  // The row the user is looking at, and where it sits: what must not move when
68951
- // the list is rebuilt around it.
69100
+ // the list is rebuilt around it. Read off the rows' own boxes, not by
69101
+ // hit-testing the screen: a scrolling list takes its rows out of hit-testing
69102
+ // (see the navi-scrolling rule in the css above), and the scroll event is
69103
+ // precisely when this is asked.
68952
69104
  const captureScrollAnchor = ({
68953
69105
  scrollerEl,
68954
69106
  listEl,
@@ -68960,30 +69112,31 @@ const captureScrollAnchor = ({
68960
69112
  }
68961
69113
  const viewportRect = getScrollerViewportRect(scrollerEl);
68962
69114
  const listRect = listEl.getBoundingClientRect();
68963
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
68964
- if (!scanRange) {
69115
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
69116
+ if (!range) {
68965
69117
  return null;
68966
69118
  }
69119
+ const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
69120
+ const {
69121
+ rowEls,
69122
+ index
69123
+ } = findRowsFrom(listEl, range.from, horizontal);
68967
69124
  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;
69125
+ for (let i = index; i < rowEls.length; i++) {
69126
+ const rowEl = rowEls[i];
69127
+ const rowRect = rowEl.getBoundingClientRect();
69128
+ const rowStart = horizontal ? rowRect.left : rowRect.top;
69129
+ if (rowStart >= range.to) {
69130
+ break;
68978
69131
  }
68979
- const item = items.find(i => i.id === itemEl.id);
69132
+ const rowName = getRowName(rowEl);
69133
+ const item = items.find(i => i.itemId === rowName);
68980
69134
  if (!item) {
68981
69135
  continue;
68982
69136
  }
68983
- const itemRect = itemEl.getBoundingClientRect();
68984
- const offset = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
69137
+ const offset = rowStart - viewportFrom;
68985
69138
  const anchor = {
68986
- id: item.id,
69139
+ id: item.itemId,
68987
69140
  index: item.index,
68988
69141
  offset
68989
69142
  };
@@ -69003,43 +69156,61 @@ const captureScrollAnchor = ({
69003
69156
  // The part of the list that is on screen, along the scrolling axis. Both edges
69004
69157
  // matter: the scroller may be larger than the list (scroller="parent") as well
69005
69158
  // 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;
69159
+ const getListVisibleRange = (viewportRect, listRect, horizontal) => {
69013
69160
  const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
69014
69161
  const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
69015
69162
  const listFrom = horizontal ? listRect.left : listRect.top;
69016
69163
  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
- }
69164
+ const from = listFrom > viewportFrom ? listFrom : viewportFrom;
69165
+ const to = listTo < viewportTo ? listTo : viewportTo;
69025
69166
  if (to - from < 2) {
69026
69167
  return null;
69027
69168
  }
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
69169
  return {
69034
69170
  from,
69035
- to,
69036
- crossPos
69171
+ to
69037
69172
  };
69038
69173
  };
69174
+ // The real rows of the list, and the first of them reaching past `from` along
69175
+ // the scroll axis. A binary search over their boxes: the rows stand in
69176
+ // document order along that axis, so their far edges only grow.
69177
+ const findRowsFrom = (listEl, from, horizontal) => {
69178
+ const rowEls = listEl.querySelectorAll(REAL_LIST_ITEM_SELECTOR);
69179
+ let low = 0;
69180
+ let high = rowEls.length;
69181
+ while (low < high) {
69182
+ const mid = low + high >> 1;
69183
+ const rect = rowEls[mid].getBoundingClientRect();
69184
+ const end = horizontal ? rect.right : rect.bottom;
69185
+ if (end > from) {
69186
+ high = mid;
69187
+ } else {
69188
+ low = mid + 1;
69189
+ }
69190
+ }
69191
+ return {
69192
+ rowEls,
69193
+ index: low
69194
+ };
69195
+ };
69196
+ // Whether a filler (the room held for rows outside the window) is what stands
69197
+ // at that position along the scroll axis.
69198
+ const isFillerAt = (listEl, position, horizontal) => {
69199
+ for (const fillerEl of listEl.querySelectorAll("[navi-virtual-filler]")) {
69200
+ const rect = fillerEl.getBoundingClientRect();
69201
+ const from = horizontal ? rect.left : rect.top;
69202
+ const to = horizontal ? rect.right : rect.bottom;
69203
+ if (position >= from && position < to) {
69204
+ return true;
69205
+ }
69206
+ }
69207
+ return false;
69208
+ };
69039
69209
 
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.
69210
+ // Which row of the collection sits at the current scroll position. Read off
69211
+ // the rows' boxes when a real row is there (see captureScrollAnchor for why
69212
+ // not hit-testing), and from the row size when what is on screen is only
69213
+ // reserved room.
69043
69214
  // Returns { index, item, reason } or null if nothing can be determined.
69044
69215
  const getScrollInfo = ({
69045
69216
  scrollValues,
@@ -69053,34 +69224,31 @@ const getScrollInfo = ({
69053
69224
  const items = listRows.itemsSignal.peek();
69054
69225
  const viewportRect = getScrollerViewportRect(scrollerEl);
69055
69226
  const listRect = listEl.getBoundingClientRect();
69056
- let hitEl = null;
69057
- let hitFiller = null;
69058
- const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
69059
- if (!scanRange) {
69227
+ const range = getListVisibleRange(viewportRect, listRect, horizontal);
69228
+ if (!range) {
69060
69229
  return null;
69061
69230
  }
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;
69231
+ // Read from the center of the visible part of the list along the main axis.
69232
+ // The render window places half its budget before and half after the hit
69233
+ // index. Anchoring to the center maximises how many rendered items fall
69234
+ // within the visible area.
69235
+ const scanStart = (range.from + range.to) / 2;
69236
+ let hitEl = null;
69237
+ const hitFiller = isFillerAt(listEl, scanStart, horizontal);
69238
+ if (!hitFiller) {
69239
+ // The first real row from the center down, the way a probe walking down
69240
+ // from it would meet one — past a separator or a group label in between.
69241
+ const {
69242
+ rowEls,
69243
+ index
69244
+ } = findRowsFrom(listEl, scanStart, horizontal);
69245
+ const rowEl = rowEls[index];
69246
+ if (rowEl) {
69247
+ const rowRect = rowEl.getBoundingClientRect();
69248
+ const rowStart = horizontal ? rowRect.left : rowRect.top;
69249
+ if (rowStart < range.to) {
69250
+ hitEl = rowEl;
69251
+ }
69084
69252
  }
69085
69253
  }
69086
69254
  // Shared by the "hit a filler" and "hit nothing at all" cases below: both
@@ -69111,8 +69279,8 @@ const getScrollInfo = ({
69111
69279
  return estimateFromScrollPos("hit filler");
69112
69280
  }
69113
69281
  if (hitEl) {
69114
- const hitId = hitEl.id;
69115
- const item = items.find(i => i.id === hitId);
69282
+ const hitName = getRowName(hitEl);
69283
+ const item = items.find(i => i.itemId === hitName);
69116
69284
  if (!item) {
69117
69285
  return null;
69118
69286
  }
@@ -69122,13 +69290,11 @@ const getScrollInfo = ({
69122
69290
  reason: `hit item at ${item.index} (${item.value})`
69123
69291
  };
69124
69292
  }
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.
69293
+ // No real row stands between the center and the end of what is visible.
69294
+ // Keeping the stale renderWindow here means the DOM never gets asked to
69295
+ // catch up with a scrollTop that may have jumped far away — the user ends
69296
+ // up staring at blank space. Same estimate as the hitFiller case is a safe
69297
+ // fallback: it only needs the scroll position.
69132
69298
  const estimated = estimateFromScrollPos("no hit");
69133
69299
  if (estimated) {
69134
69300
  return estimated;
@@ -69563,6 +69729,12 @@ const ListItemUI = props => {
69563
69729
  // gave the row its place and decided it is inside the render window.
69564
69730
  const row = useContext(ListRowContext);
69565
69731
  const slotId = useContext(ListSlotContext);
69732
+ // What the row is called in the list — the run's name for a row it draws,
69733
+ // whatever `id` the caller put on the element (a run row may need a DOM id of
69734
+ // its own, to keep clear of another element's). A position names a row by
69735
+ // this (see captureScrollAnchor), and a row asked for by name is found by
69736
+ // this (locateRow, findRowElement); the DOM id is the caller's.
69737
+ props.itemId = row ? row.id : props.id;
69566
69738
  // There is no standalone match/matchScore/highlight prop — participation
69567
69739
  // in a matching system (search, filter…) only goes through `matchInfo`
69568
69740
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
@@ -69643,6 +69815,7 @@ const ListItemReal = props => {
69643
69815
  const {
69644
69816
  ref,
69645
69817
  id,
69818
+ itemId,
69646
69819
  hidden,
69647
69820
  muted,
69648
69821
  loading,
@@ -69662,7 +69835,7 @@ const ListItemReal = props => {
69662
69835
  // see the state change, which only the caller can arrange).
69663
69836
  const pendingScrollRef = useContext(PendingScrollRefContext);
69664
69837
  const pendingScroll = pendingScrollRef.current;
69665
- const needScrollOnMount = pendingScroll && pendingScroll.id === id;
69838
+ const needScrollOnMount = pendingScroll && pendingScroll.id === itemId;
69666
69839
  useLayoutEffect(() => {
69667
69840
  if (!needScrollOnMount) {
69668
69841
  return;
@@ -69761,7 +69934,7 @@ const ListItemReal = props => {
69761
69934
  baseClassName: "navi_list_item",
69762
69935
  styleCSSVars: LIST_ITEM_STYLE_CSS_VARS,
69763
69936
  id: id,
69764
- "navi-list-item-real": "",
69937
+ "navi-list-item-real": itemId,
69765
69938
  ...rest,
69766
69939
  ...itemColumnsOverrideProps,
69767
69940
  index: undefined,
@@ -71133,6 +71306,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71133
71306
  * onScrolledChange?: (scrolled: {id: string, index: number, offset: number}) => void,
71134
71307
  * scroller?: "self" | "parent" | "document" | Element | {current: Element},
71135
71308
  * hoverWhileScrolling?: boolean,
71309
+ * scrollResetOnNavigation?: boolean,
71136
71310
  * fallback?: import("ignore:preact").ComponentChildren,
71137
71311
  * searchFallback?: import("ignore:preact").ComponentChildren,
71138
71312
  * searchText?: string,
@@ -71224,7 +71398,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71224
71398
  * list is already known to be empty: the empty `fallback` shows right away
71225
71399
  * rather than an empty frame, so nothing moves when the response arrives.
71226
71400
  * @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
71401
+ * Where the list opens, after which the user owns the scroll unless it is
71402
+ * being come back to (see `scrollResetOnNavigation`). `"end"` is a
71228
71403
  * thread read backwards — the last rows are the ones to show, and the ones
71229
71404
  * asked for first. A number opens on that row of the collection. `{id,
71230
71405
  * offset}` — what `onScrolledChange` hands out — opens on a NAMED row,
@@ -71307,6 +71482,15 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71307
71482
  * Pass `true` for a list whose rows must stay live under the pointer while
71308
71483
  * it scrolls. The trade of the default is the mirror one: right after a
71309
71484
  * scroll, the row under the pointer lights up only once the pointer moves.
71485
+ * @param {boolean} [props.scrollResetOnNavigation=false]
71486
+ * A list that opens the same way every time. Without it the list comes back
71487
+ * where it was when its screen is left and come back to — the way the page
71488
+ * does, and for a list that scrolls itself the page's own restoration cannot
71489
+ * see. The position is kept under the list's `id` and the page's url, for
71490
+ * the session (a reload comes back too); a list without an `id` of its own
71491
+ * has nothing to be remembered by. A fresh arrival at the page opens at
71492
+ * `defaultScrolled` either way, and so does a list the caller holds through
71493
+ * `scrolled`.
71310
71494
  * @param {boolean} [props.deselectable]
71311
71495
  * A single-select list allowed to hold nothing: the selected row, pressed
71312
71496
  * again, lets go. Without it the list is a radio group — a choice, once