@jsenv/navi 0.29.417 → 0.29.419

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/dev/jsenv_navi.js +347 -83
  3. package/dist/dev/jsenv_navi.js.map +12 -12
  4. package/dist/jsenv_navi.js +345 -81
  5. package/dist/jsenv_navi.js.map +12 -12
  6. package/docs/AI_INSTRUCTIONS.md +177 -258
  7. package/docs/actions.md +164 -251
  8. package/docs/autofocus.md +66 -83
  9. package/docs/control_object.md +117 -214
  10. package/docs/control_value.md +121 -185
  11. package/docs/create_and_edit.md +159 -254
  12. package/docs/css_architecture.md +196 -260
  13. package/docs/data_states.md +41 -44
  14. package/docs/dialog_shape.md +91 -134
  15. package/docs/drag_interactions.md +291 -454
  16. package/docs/drag_to_travel.md +152 -748
  17. package/docs/dynamic_import.md +119 -136
  18. package/docs/error_handling.md +75 -127
  19. package/docs/field_validation.md +49 -89
  20. package/docs/form_changed.md +61 -78
  21. package/docs/group.md +141 -0
  22. package/docs/i18n.md +31 -76
  23. package/docs/interactions.md +170 -326
  24. package/docs/list_action.md +36 -51
  25. package/docs/list_refresh.md +73 -115
  26. package/docs/mobile_touch.md +56 -62
  27. package/docs/navigation.md +377 -723
  28. package/docs/network_policy.md +61 -75
  29. package/docs/pan_zoom.md +62 -103
  30. package/docs/popup_backdrop.md +128 -168
  31. package/docs/popup_lift.md +179 -268
  32. package/docs/popup_open.md +380 -651
  33. package/docs/resource.md +228 -408
  34. package/docs/route_transitions.md +333 -473
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -317
  37. package/docs/state_binding.md +40 -84
  38. package/docs/testid.md +18 -23
  39. package/docs/typography.md +111 -141
  40. package/docs/view_transitions.md +106 -104
  41. package/docs/z_index.md +61 -109
  42. package/package.json +2 -2
  43. package/docs/badge_list.md +0 -83
  44. package/docs/control_group.md +0 -168
  45. package/docs/mobile_layout_pitfalls.md +0 -145
@@ -11254,6 +11254,28 @@ const suspendScrollRecording = () => {
11254
11254
  };
11255
11255
  };
11256
11256
 
11257
+ // A list scrolling the document that opens on a row puts the document where
11258
+ // that row is, by measuring it (see placeWhereHeld in list.jsx). The offset kept
11259
+ // for the url is pixels of the page as it was drawn — and a list draws a window
11260
+ // of its rows and holds the room of the others with fillers of an estimated
11261
+ // height, so the same pixels now fall on other rows. Put back after the list
11262
+ // has placed itself, they would undo it, in the very picture a route transition
11263
+ // takes. So the list holds the document while it is placing itself, and the
11264
+ // url's offset is not put back meanwhile. Counted: two lists can share one
11265
+ // document.
11266
+ let documentHoldCount = 0;
11267
+ const holdDocumentScroll = () => {
11268
+ documentHoldCount++;
11269
+ let released = false;
11270
+ return () => {
11271
+ if (released) {
11272
+ return;
11273
+ }
11274
+ released = true;
11275
+ documentHoldCount--;
11276
+ };
11277
+ };
11278
+
11257
11279
  let installed = false;
11258
11280
  const installScrollRestoration = () => {
11259
11281
  if (installed) {
@@ -11288,6 +11310,9 @@ const installScrollRestoration = () => {
11288
11310
  if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11289
11311
  const stopListening = observeRouteRender(() => {
11290
11312
  stopListening();
11313
+ if (documentHoldCount) {
11314
+ return;
11315
+ }
11291
11316
  scrollTo(positionOnLoad);
11292
11317
  });
11293
11318
  }
@@ -11302,6 +11327,11 @@ const restoreScrollPosition = (url) => {
11302
11327
  if (!position) {
11303
11328
  return false;
11304
11329
  }
11330
+ if (documentHoldCount) {
11331
+ // There is a place to go back to, and the list holding the document is
11332
+ // putting it back (see holdDocumentScroll).
11333
+ return true;
11334
+ }
11305
11335
  scrollTo(position);
11306
11336
  return true;
11307
11337
  };
@@ -15033,7 +15063,11 @@ const createOpenController = (
15033
15063
  const applyChange = () => {
15034
15064
  // Recorded with the change itself: what the DOM shows is what a render
15035
15065
  // landing between the ask and the picture must draw (see `openedInDom`).
15036
- controller.openedInDom = opened;
15066
+ // An opening records it inside the change, once its content is built
15067
+ // (see open() below).
15068
+ if (!opened) {
15069
+ controller.openedInDom = false;
15070
+ }
15037
15071
  change();
15038
15072
  };
15039
15073
  const { transitionChange } = controller;
@@ -15313,15 +15347,17 @@ const createOpenController = (
15313
15347
  controller.mountContent?.();
15314
15348
  // Only now — after the content has been built, before openEffect
15315
15349
  // shows it. Dialog/Popover recompute aria-expanded and navi-hidden
15316
- // from this flag on every render, and mountContent above renders
15350
+ // from `openedInDom` on every render, and mountContent above renders
15317
15351
  // synchronously: flipping it any earlier commits an already-open DOM
15318
15352
  // (aria-expanded "true", navi-hidden gone) before openEffect has run
15319
15353
  // a single statement, so the "closed" frame it pins to transition
15320
15354
  // from is in fact the open one and the entrance animation has
15321
- // nothing to play. It also gives the content it just built the
15322
- // opening it is documented to observe — mounted while the popup
15323
- // reads as closed, told it opened right after (see
15324
- // popup_content_mount.js and use_displayed_layout_effect.js).
15355
+ // nothing to play — on every first opening that builds its content.
15356
+ // It also gives the content it just built the opening it is
15357
+ // documented to observe — mounted while the popup reads as closed,
15358
+ // told it opened right after (see popup_content_mount.js and
15359
+ // use_displayed_layout_effect.js).
15360
+ controller.openedInDom = true;
15325
15361
  controller.opened = true;
15326
15362
  // Which press it opened during, so the release of that press is not
15327
15363
  // read as somebody dismissing it (see openedDuringThisPress).
@@ -15730,7 +15766,14 @@ const useOpenPropsEffectOnOpenController = (
15730
15766
  lastRunOpenRef.current = open;
15731
15767
 
15732
15768
  if (isFirstRun) {
15733
- const mountOpenReason = open || defaultOpen;
15769
+ // "interaction" is said even over what the caller holds reading open: a
15770
+ // popup built by the request that opens it — a dialog standing in until
15771
+ // its signal said open, see DialogStandIn in dialog.jsx — mounts BECAUSE
15772
+ // of that opening.
15773
+ const mountOpenReason =
15774
+ open && defaultOpen === "interaction"
15775
+ ? "interaction"
15776
+ : open || defaultOpen;
15734
15777
  if (mountOpenReason) {
15735
15778
  // Whether this popup being open is something that just happened, or
15736
15779
  // something that was already true when the page appeared. "interaction"
@@ -28663,6 +28706,18 @@ const useAsyncData = (promiseOrAction, {
28663
28706
 
28664
28707
  // ─── useAction ────────────────────────────────────────────────────────────────
28665
28708
 
28709
+ // subscribe() calls back at once with the value the signal holds; what these
28710
+ // subscriptions are for is what changes after.
28711
+ const subscribeToChanges = (signal, onChange) => {
28712
+ let initial = true;
28713
+ return signal.subscribe(value => {
28714
+ if (initial) {
28715
+ initial = false;
28716
+ return;
28717
+ }
28718
+ onChange(value);
28719
+ });
28720
+ };
28666
28721
  const LoadingContext = createContext(null);
28667
28722
  const actionPendingPromiseWeakMap = new WeakMap();
28668
28723
  const dismissedActionWeakSet = new WeakSet();
@@ -28707,6 +28762,15 @@ const useActionAsyncData = (action, {
28707
28762
  const runningState = action.runningStateSignal.peek();
28708
28763
  const [, setTick] = useState(0);
28709
28764
  const routePage = useContext(RoutePageContext);
28765
+ // What this render read of the action, for the subscriptions below to tell
28766
+ // a change that came between the render and them from none at all.
28767
+ const readRef = useRef(null);
28768
+ readRef.current = {
28769
+ runningState,
28770
+ data: action.dataSignal.peek(),
28771
+ params: action.paramsSignal.peek(),
28772
+ settling: action.paramsSettlingSignal.peek()
28773
+ };
28710
28774
  useEffect(() => {
28711
28775
  let stopOwingRender = null;
28712
28776
  const rerender = () => {
@@ -28734,7 +28798,12 @@ const useActionAsyncData = (action, {
28734
28798
  }
28735
28799
  setTick(n => n + 1);
28736
28800
  };
28737
- const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
28801
+ // A run under way is a new try: an error dismissed before it is not the
28802
+ // one it may end on.
28803
+ if (action.runningStateSignal.peek() === RUNNING) {
28804
+ dismissedActionWeakSet.delete(action);
28805
+ }
28806
+ const unsubscribeFromRunningState = subscribeToChanges(action.runningStateSignal, state => {
28738
28807
  if (state === RUNNING) {
28739
28808
  dismissedActionWeakSet.delete(action);
28740
28809
  }
@@ -28745,38 +28814,24 @@ const useActionAsyncData = (action, {
28745
28814
  // (a PUT upserting an item that a GET_MANY list already holds) changes the
28746
28815
  // data while this action stays COMPLETED. Subscribing here re-renders
28747
28816
  // through the same controlled path as the run state, instead of `.value`.
28748
- let dataNotificationIsInitial = true;
28749
- const unsubscribeFromData = action.dataSignal.subscribe(() => {
28750
- if (dataNotificationIsInitial) {
28751
- // subscribe() calls back synchronously with the current value
28752
- dataNotificationIsInitial = false;
28753
- return;
28754
- }
28755
- rerender();
28756
- });
28817
+ const unsubscribeFromData = subscribeToChanges(action.dataSignal, rerender);
28757
28818
  // The params say WHICH question this is, and this hook reads them: to know
28758
28819
  // there is nothing to ask for, and to start the run it owns. A binding
28759
28820
  // retargeting from no question to a question — a filter chosen, a first
28760
28821
  // character typed — is announced by nothing above: a fresh target is IDLE
28761
28822
  // holding no data, the very state the hook already sees.
28762
- let paramsNotificationIsInitial = true;
28763
- const unsubscribeFromParams = action.paramsSignal.subscribe(() => {
28764
- if (paramsNotificationIsInitial) {
28765
- paramsNotificationIsInitial = false;
28766
- return;
28767
- }
28768
- rerender();
28769
- });
28823
+ const unsubscribeFromParams = subscribeToChanges(action.paramsSignal, rerender);
28770
28824
  // A debounced binding waits before it retargets, so nothing above changes
28771
28825
  // while the delay runs — but what is on screen is already out of date.
28772
- let settlingNotificationIsInitial = true;
28773
- const unsubscribeFromParamsSettling = action.paramsSettlingSignal.subscribe(() => {
28774
- if (settlingNotificationIsInitial) {
28775
- settlingNotificationIsInitial = false;
28776
- return;
28777
- }
28826
+ const unsubscribeFromParamsSettling = subscribeToChanges(action.paramsSettlingSignal, rerender);
28827
+ // Subscribed once the browser has painted, so the action may have moved
28828
+ // since the render: a run that very render started may be over already,
28829
+ // and nothing would say so again. What the render read, against what is
28830
+ // there now — and nothing moved, nothing to draw again.
28831
+ const read = readRef.current;
28832
+ if (action.runningStateSignal.peek() !== read.runningState || action.dataSignal.peek() !== read.data || action.paramsSignal.peek() !== read.params || action.paramsSettlingSignal.peek() !== read.settling) {
28778
28833
  rerender();
28779
- });
28834
+ }
28780
28835
  return () => {
28781
28836
  unsubscribeFromRunningState();
28782
28837
  unsubscribeFromData();
@@ -40354,6 +40409,17 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
40354
40409
  decisionDeferredToRef.current = popupMounting;
40355
40410
  return;
40356
40411
  }
40412
+ if (
40413
+ (el.tagName === "DIALOG" || el.hasAttribute("popover")) &&
40414
+ !isAncestorOpen(el)
40415
+ ) {
40416
+ // Itself a closed <dialog> or [popover]: display:none by the UA's own
40417
+ // rules, which the markup says without asking the layout — asked, every
40418
+ // closed popup mounting would force a style recalculation, and a list
40419
+ // whose rows each hold a few mounts hundreds of them. The observer
40420
+ // below fires as for any element the ancestor hides.
40421
+ return;
40422
+ }
40357
40423
  if (!isDisplayedDespiteClosedAncestor(el, ancestor)) {
40358
40424
  // Ancestor is closed and took this element off screen with it — skip
40359
40425
  // now; the observeAncestorOpenState call below will fire once it
@@ -52612,9 +52678,9 @@ const FixedBar = ({
52612
52678
  * A page that overflows horizontally is a bug wherever it happens, and on
52613
52679
  * Chrome Android it is a catastrophic one: the layout viewport inflates to the
52614
52680
  * content and `position: fixed` centering goes with it (see
52615
- * docs/mobile_layout_pitfalls.md). The remedy there is a wrapper in
52616
- * `overflow-x: clip`, and it works — at the price of making the cause
52617
- * invisible: nothing sticks out anymore, so nothing says a fixed width, a
52681
+ * docs/safe_area.md#a-document-wider-than-the-screen). The remedy there is a
52682
+ * wrapper in `overflow-x: clip`, and it works — at the price of making the
52683
+ * cause invisible: nothing sticks out anymore, so nothing says a fixed width, a
52618
52684
  * `min-width` or an unbreakable string is still oversized. This puts the
52619
52685
  * signal back, without giving up the net.
52620
52686
  *
@@ -65627,7 +65693,9 @@ const css$E = /* css */`
65627
65693
  * @param {"always"|"idle"|"from-first-open"|"while-opened"} [props.mount] - When
65628
65694
  * `children` are built and thrown away (see popup_content_mount.js).
65629
65695
  * `"from-first-open"` (the default) builds them on the first open and keeps
65630
- * them afterwards. `"always"` builds them right away, for content something
65696
+ * them afterwards — and the dialog itself is built then too: until its first
65697
+ * request (an open, a press on what opens it, its signal saying open) it is
65698
+ * a bare `<dialog>` holding its id (see docs/popup_open.md). `"always"` builds them right away, for content something
65631
65699
  * depends on while the popup is still closed: a value read off it, fields a
65632
65700
  * surrounding form collects on submit, a size measured from outside.
65633
65701
  * `"idle"` builds them in a browser idle moment after load — "always" minus
@@ -65648,11 +65716,122 @@ const Dialog = props => {
65648
65716
  ...props
65649
65717
  });
65650
65718
  }
65651
- return jsx(UncontrolledDialog, {
65719
+ return jsx(DialogStandIn, {
65652
65720
  ...props
65653
65721
  });
65654
65722
  };
65655
65723
 
65724
+ // A dialog nobody has asked for yet stands in as its bare <dialog>. That
65725
+ // element is all that reaches a closed dialog before its first opening: a
65726
+ // command finds it by id (`commandfor`) and asks it to open, a press on what
65727
+ // opens it announces itself on it (navi_open_press), --navi-toggle reads its
65728
+ // aria-expanded. The dialog proper — its open controller, its box, its focus
65729
+ // and pseudo-class wiring — is built at the first request: an open, a press
65730
+ // about to open it, its signal or its `open` saying open. A list whose rows
65731
+ // each hold a few dialogs mounts hundreds of them, and would pay for every one
65732
+ // at every mount, for openings that mostly never come. Built once, the dialog
65733
+ // stays: the next opening finds it there.
65734
+ const DialogStandIn = props => {
65735
+ const {
65736
+ ref,
65737
+ id,
65738
+ signal,
65739
+ value,
65740
+ open
65741
+ } = props;
65742
+ const ownRef = useRef();
65743
+ const dialogRef = ref || ownRef;
65744
+ // Why the dialog proper exists, once it does; never undone.
65745
+ const builtRef = useRef(null);
65746
+ const mountedRef = useRef(false);
65747
+ const isMount = !mountedRef.current;
65748
+ mountedRef.current = true;
65749
+ // Its own reading of the signal: the dialog of one card among many, told
65750
+ // apart by `value`, follows whether IT is the one open rather than every
65751
+ // write the others cause.
65752
+ const openedSignal = useMemo(() => signal ? computed(() => readOpened(signal.value, value)) : null, [signal, value]);
65753
+ const openedBySignal = openedSignal ? openedSignal.value : false;
65754
+ if (builtRef.current === null) {
65755
+ if (isMount && (needsDialogFromMount(props) || open || openedBySignal)) {
65756
+ builtRef.current = "mount";
65757
+ } else if (open || openedBySignal) {
65758
+ // Asked open while this stood in: the opening is happening now, and it
65759
+ // plays like any other (see defaultOpen "interaction").
65760
+ builtRef.current = "interaction";
65761
+ }
65762
+ }
65763
+ const [, setBuildCount] = useState(0);
65764
+ const warmRef = useRef(null);
65765
+ const build = reason => {
65766
+ if (builtRef.current !== null) {
65767
+ return;
65768
+ }
65769
+ builtRef.current = reason;
65770
+ flushSyncRendering(() => {
65771
+ setBuildCount(count => count + 1);
65772
+ });
65773
+ };
65774
+ const builtReason = builtRef.current;
65775
+ if (builtReason !== null) {
65776
+ return jsx(UncontrolledDialog, {
65777
+ ...props,
65778
+ ref: dialogRef,
65779
+ defaultOpen: builtReason === "interaction" ? "interaction" : props.defaultOpen
65780
+ });
65781
+ }
65782
+ return jsx("dialog", {
65783
+ ref: dialogRef,
65784
+ id: id,
65785
+ className: "navi_dialog"
65786
+ // eslint-disable-next-line react/no-unknown-property
65787
+ ,
65788
+ "navi-hidden": ""
65789
+ // eslint-disable-next-line react/no-unknown-property
65790
+ ,
65791
+ "navi-out-of-flow": "",
65792
+ "aria-expanded": "false",
65793
+ "data-testid": props["data-testid"]
65794
+ // eslint-disable-next-line react/no-unknown-property
65795
+ ,
65796
+ onnavi_request_open: e => {
65797
+ // Built in this same event, and asked again: the opening is the one
65798
+ // the dialog proper would have had — its source, its anchor, its value.
65799
+ build("request");
65800
+ const opened = dispatchCustomEvent(dialogRef.current, "navi_request_open", e.detail);
65801
+ if (!opened) {
65802
+ e.preventDefault();
65803
+ }
65804
+ }
65805
+ // eslint-disable-next-line react/no-unknown-property
65806
+ ,
65807
+ onnavi_open_press: () => {
65808
+ // What the opening will read is asked for at once, as the dialog
65809
+ // proper does (see open_controller.js); the dialog itself two frames
65810
+ // later, so the pressed trigger is painted first — the click that
65811
+ // follows opens it built, or builds it itself when it comes sooner.
65812
+ if (signal) {
65813
+ preloadState(signal, readOpenValue(value));
65814
+ }
65815
+ if (warmRef.current) {
65816
+ return;
65817
+ }
65818
+ warmRef.current = requestFrameAfterNext(() => {
65819
+ warmRef.current = null;
65820
+ build("press");
65821
+ });
65822
+ }
65823
+ });
65824
+ };
65825
+ // What needs the dialog proper from the start: an opening at mount, content
65826
+ // built ahead of any opening, a history entry to read, an anchor whose hover
65827
+ // or focus builds the content ahead of the click.
65828
+ const needsDialogFromMount = props => {
65829
+ if (props.defaultOpen || props.navState || props.anchor !== undefined) {
65830
+ return true;
65831
+ }
65832
+ return props.mount !== undefined && props.mount !== "from-first-open";
65833
+ };
65834
+
65656
65835
  // No openController passed: this Dialog is used declaratively (e.g. driven
65657
65836
  // by --navi-toggle/--navi-open/--navi-close commands, the `open` prop, or
65658
65837
  // `defaultOpen`) rather than owned by a parent component.
@@ -72626,9 +72805,17 @@ const ListUI = props => {
72626
72805
  // why not an effect), and the floor of 30 is its: it protects the scrolling
72627
72806
  // window, not a picture nobody has seen yet.
72628
72807
  const {
72629
- initial: initialRenderBudget,
72808
+ initial: initialRenderBudgetProp,
72630
72809
  after: renderBudgetAfterPaint
72631
72810
  } = resolveRenderBudget(renderBudgetProp);
72811
+ // Opening on a position that says how many rows were on screen from its row
72812
+ // on (the way back to where the list was, see reportPosition): that is what
72813
+ // the first picture shows, whatever `initial` guessed — with the row above
72814
+ // when it stood partly in view. The rest waits for the paint like any
72815
+ // `initial`: a page coming back in a route transition builds it inside the
72816
+ // update callback, and every row below the fold delays the movement.
72817
+ const openingPosition = scrolled ?? defaultScrolled;
72818
+ const initialRenderBudget = openingPosition && typeof openingPosition === "object" && typeof openingPosition.visibleCount === "number" ? openingPosition.visibleCount + (openingPosition.offset > 0 ? 1 : 0) : initialRenderBudgetProp;
72632
72819
  let renderBudget = renderBudgetAfterPaint;
72633
72820
  if (renderBudget < 30 && !renderBudgetSkipCheck) {
72634
72821
  console.warn(`List: renderBudget=${renderBudget} is too low. A renderBudget below 30 is not supported: on large screens or when the list grows, items outside the window would appear as blank space instead of rendered content. Use a value of at least 30, or omit the prop to use the default (${RENDER_BUDGET_DEFAULT}).`);
@@ -73091,11 +73278,23 @@ const useListScrollSync = ({
73091
73278
  horizontal
73092
73279
  });
73093
73280
  };
73281
+
73282
+ // How many rows the window keeps above the row the list is held at. Around it
73283
+ // for the scrolling to come, from the first paint on; before, the window is
73284
+ // the first picture's, and a picture of the list opening on a row shows that
73285
+ // row and what is below it — with the one above when a remembered position
73286
+ // says it stood partly in view.
73287
+ const rowsAboveOpening = (openAt, windowSize) => {
73288
+ if (renderBudget !== renderBudgetSteady) {
73289
+ return openAt && typeof openAt === "object" && openAt.offset > 0 ? 1 : 0;
73290
+ }
73291
+ return Math.floor(windowSize / 2);
73292
+ };
73094
73293
  const [renderWindow, setRenderWindow] = useState(() => {
73095
73294
  // Opening somewhere else than the beginning starts by framing there: the
73096
73295
  // rows the list will draw are the rows it will ask for.
73097
73296
  const openAt = scrolled ?? defaultScrolled;
73098
- const start = typeof openAt === "number" ? openAt - Math.floor(renderBudget / 2) : 0;
73297
+ const start = typeof openAt === "number" ? openAt - rowsAboveOpening(openAt, renderBudget) : 0;
73099
73298
  const startClamped = start < 0 ? 0 : start;
73100
73299
  return {
73101
73300
  start: startClamped,
@@ -73169,8 +73368,12 @@ const useListScrollSync = ({
73169
73368
  // Held somewhere it has not reached yet: what the window frames right now
73170
73369
  // is not what it will frame, so nothing should be fetched for it.
73171
73370
  listRows.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
73371
+ // Zero until the runs have rendered — they count their rows as they do,
73372
+ // after the list — which is not a collection fitting in the window: it is
73373
+ // one whose end is not known yet, and the window then frames the row the
73374
+ // list opens on, already in the first commit.
73172
73375
  const total = listRows.totalSignal.peek();
73173
- if (total <= renderBudget) {
73376
+ if (total > 0 && total <= renderBudget) {
73174
73377
  // The whole collection is what the list draws: wherever in it the list is
73175
73378
  // held, the window is already its place. Nowhere to move to means nothing
73176
73379
  // to wait for — a hold left standing here is a list that never asks for
@@ -73178,21 +73381,21 @@ const useListScrollSync = ({
73178
73381
  listRows.holdPending = false;
73179
73382
  return;
73180
73383
  }
73181
- const half = Math.floor(renderBudget / 2);
73384
+ const above = rowsAboveOpening(scrolledWanted, renderBudget);
73182
73385
  let wantedStart = null;
73183
73386
  if (scrolledWanted === "end") {
73184
73387
  wantedStart = total - renderBudget;
73185
73388
  } else if (typeof scrolledWanted === "number") {
73186
- wantedStart = scrolledWanted - half;
73389
+ wantedStart = scrolledWanted - above;
73187
73390
  } else if (scrolledWanted && scrolledWanted.id !== undefined) {
73188
73391
  const rowIndex = listRows.locateRow(scrolledWanted.id);
73189
73392
  if (rowIndex !== null) {
73190
- wantedStart = rowIndex - half;
73393
+ wantedStart = rowIndex - above;
73191
73394
  } else if (typeof scrolledWanted.index === "number") {
73192
73395
  // The row has not come back yet, but where it stood is known: near
73193
73396
  // enough to frame, and to put the scrollbar roughly where it will end
73194
73397
  // up rather than at the top.
73195
- wantedStart = scrolledWanted.index - half;
73398
+ wantedStart = scrolledWanted.index - above;
73196
73399
  }
73197
73400
  }
73198
73401
  if (wantedStart === null) {
@@ -73201,12 +73404,12 @@ const useListScrollSync = ({
73201
73404
  // back — the run asks for it by name (see useRequestMissing).
73202
73405
  return;
73203
73406
  }
73407
+ if (total > 0 && wantedStart + renderBudget > total) {
73408
+ wantedStart = total - renderBudget;
73409
+ }
73204
73410
  if (wantedStart < 0) {
73205
73411
  wantedStart = 0;
73206
73412
  }
73207
- if (wantedStart + renderBudget > total) {
73208
- wantedStart = total - renderBudget;
73209
- }
73210
73413
  const {
73211
73414
  start,
73212
73415
  end
@@ -73543,8 +73746,8 @@ const useListScrollSync = ({
73543
73746
  end
73544
73747
  } = renderWindowRef.current;
73545
73748
  if (rowIndex < start || rowIndex >= end) {
73546
- const half = Math.floor((end - start) / 2);
73547
- const wantedStart = rowIndex - half < 0 ? 0 : rowIndex - half;
73749
+ const above = rowsAboveOpening(scrolledWanted, end - start);
73750
+ const wantedStart = rowIndex - above < 0 ? 0 : rowIndex - above;
73548
73751
  updateRenderWindow(wantedStart, wantedStart + (end - start), `opening on row ${scrolledWanted.id}`);
73549
73752
  return;
73550
73753
  }
@@ -73590,6 +73793,15 @@ const useListScrollSync = ({
73590
73793
  }
73591
73794
  };
73592
73795
  useLayoutEffect(placeWhereHeld);
73796
+ // Held on a row of a list scrolling the document: where the document goes is
73797
+ // this list's to say, measured on the row, not the url's offset in pixels
73798
+ // (see holdDocumentScroll).
73799
+ useLayoutEffect(() => {
73800
+ if (!heldSomewhere || getScroller() !== document.scrollingElement) {
73801
+ return undefined;
73802
+ }
73803
+ return holdDocumentScroll();
73804
+ }, [heldSomewhere, scrollerElResolved]);
73593
73805
  // What to do when the list's own geometry moves under it, kept fresh for the
73594
73806
  // observer below (which is installed once).
73595
73807
  const onGeometryChangeRef = useRef(null);
@@ -73632,31 +73844,40 @@ const useListScrollSync = ({
73632
73844
  // Where the list was at the last thing that moved it. Kept whether anyone
73633
73845
  // asked for it or not: it is what a resize needs to put things back.
73634
73846
  const positionRef = useRef(null);
73847
+ const captureListAnchor = countVisible => captureScrollAnchor({
73848
+ scrollerEl: getScroller(),
73849
+ listEl: getListEl(),
73850
+ items: listRows.visibleItemsSignal.peek(),
73851
+ horizontal,
73852
+ countVisible
73853
+ });
73854
+ // Said from where the row lands on its own, and with `visibleCount`: the
73855
+ // rows on screen from that one on, which is what a list given this position
73856
+ // back draws first (see ListUI).
73857
+ const toScrolledPosition = position => {
73858
+ const rowEl = findRowElement(getListEl(), position.id);
73859
+ return {
73860
+ id: position.id,
73861
+ index: position.index,
73862
+ offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal),
73863
+ visibleCount: position.visibleCount
73864
+ };
73865
+ };
73635
73866
  const reportPosition = () => {
73636
73867
  if (!ref.current) {
73637
73868
  return;
73638
73869
  }
73639
- const position = captureScrollAnchor({
73640
- scrollerEl: getScroller(),
73641
- listEl: getListEl(),
73642
- items: listRows.visibleItemsSignal.peek(),
73643
- horizontal
73644
- });
73870
+ const remember = rememberScrollRef.current;
73871
+ const onScrolledChange = onScrolledChangeRef.current;
73872
+ const position = captureListAnchor(remember || Boolean(onScrolledChange));
73645
73873
  if (!position) {
73646
73874
  return;
73647
73875
  }
73648
73876
  positionRef.current = position;
73649
- const remember = rememberScrollRef.current;
73650
- const onScrolledChange = onScrolledChangeRef.current;
73651
73877
  if (!remember && !onScrolledChange) {
73652
73878
  return;
73653
73879
  }
73654
- const rowEl = findRowElement(getListEl(), position.id);
73655
- const scrolledNow = {
73656
- id: position.id,
73657
- index: position.index,
73658
- offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal)
73659
- };
73880
+ const scrolledNow = toScrolledPosition(position);
73660
73881
  if (remember) {
73661
73882
  rememberScrollerPosition(listId, scrolledNow);
73662
73883
  }
@@ -73664,6 +73885,20 @@ const useListScrollSync = ({
73664
73885
  onScrolledChange(scrolledNow);
73665
73886
  }
73666
73887
  };
73888
+ // Nothing scrolled, yet what is on screen changed: the list has just been
73889
+ // laid out, its rows have arrived, one of them grew. A list read where it
73890
+ // opened would otherwise come back with nothing kept — opening again as a
73891
+ // fresh arrival does, all of its window drawn in the first commit instead of
73892
+ // the rows that were on screen. Kept, and told to nobody: nobody scrolled.
73893
+ const rememberPosition = () => {
73894
+ if (!rememberScrollRef.current || !ref.current) {
73895
+ return;
73896
+ }
73897
+ const position = captureListAnchor(true);
73898
+ if (position) {
73899
+ rememberScrollerPosition(listId, toScrolledPosition(position));
73900
+ }
73901
+ };
73667
73902
  // Leaving: with its page, or alone (see forgetScrollerUnlessPageLeft).
73668
73903
  useLayoutEffect(() => {
73669
73904
  return () => {
@@ -73689,6 +73924,7 @@ const useListScrollSync = ({
73689
73924
  // list which has outgrown a bounded ancestor stops holding on to the
73690
73925
  // page.
73691
73926
  resolveScroller();
73927
+ rememberPosition();
73692
73928
  // Two things resize here, and they call for opposite answers. The LIST
73693
73929
  // growing is its own content settling: only a list holding itself
73694
73930
  // somewhere cares (the end it aims at has moved), and a list the user is
@@ -74477,12 +74713,15 @@ const getRowName = rowEl => rowEl.getAttribute("navi-list-item-real");
74477
74713
  // the list is rebuilt around it. Read off the rows' own boxes, not by
74478
74714
  // hit-testing the screen: a scrolling list takes its rows out of hit-testing
74479
74715
  // (see the navi-scrolling rule in the css above), and the scroll event is
74480
- // precisely when this is asked.
74716
+ // precisely when this is asked. `countVisible` adds how many rows are on
74717
+ // screen from that one on: what a list coming back there has to draw before
74718
+ // anything else (see the first paint in ListUI).
74481
74719
  const captureScrollAnchor = ({
74482
74720
  scrollerEl,
74483
74721
  listEl,
74484
74722
  items,
74485
- horizontal
74723
+ horizontal,
74724
+ countVisible
74486
74725
  }) => {
74487
74726
  if (!scrollerEl || !listEl) {
74488
74727
  return null;
@@ -74518,6 +74757,9 @@ const captureScrollAnchor = ({
74518
74757
  offset
74519
74758
  };
74520
74759
  if (offset >= 0) {
74760
+ if (countVisible) {
74761
+ anchor.visibleCount = countRowsStartingBefore(rowEls, i, range.to, horizontal);
74762
+ }
74521
74763
  return anchor;
74522
74764
  }
74523
74765
  // The row under the top of the view starts above it. Good enough to hold
@@ -74526,10 +74768,26 @@ const captureScrollAnchor = ({
74526
74768
  // top" reads, and can be drawn. Keep looking; this one is the fallback.
74527
74769
  if (!fallbackAnchor) {
74528
74770
  fallbackAnchor = anchor;
74771
+ if (countVisible) {
74772
+ anchor.visibleCount = countRowsStartingBefore(rowEls, i, range.to, horizontal);
74773
+ }
74529
74774
  }
74530
74775
  }
74531
74776
  return fallbackAnchor;
74532
74777
  };
74778
+ // How many of the rows from `fromIndex` on start before `to` along the scroll
74779
+ // axis — the ones on screen, when `to` is where the view ends.
74780
+ const countRowsStartingBefore = (rowEls, fromIndex, to, horizontal) => {
74781
+ let index = fromIndex;
74782
+ while (index < rowEls.length) {
74783
+ const rect = rowEls[index].getBoundingClientRect();
74784
+ if ((horizontal ? rect.left : rect.top) >= to) {
74785
+ break;
74786
+ }
74787
+ index++;
74788
+ }
74789
+ return index - fromIndex;
74790
+ };
74533
74791
  // The part of the list that is on screen, along the scrolling axis. Both edges
74534
74792
  // matter: the scroller may be larger than the list (scroller="parent") as well
74535
74793
  // as smaller (the list scrolls inside its own box).
@@ -76897,7 +77155,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76897
77155
  * are skeletons, the others hold their room. `0` says the list is already
76898
77156
  * known to be empty: the empty `fallback` shows right away rather than an
76899
77157
  * empty frame.
76900
- * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
77158
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.defaultScrolled="start"]
76901
77159
  * Where the list opens, after which the user owns the scroll — unless it is
76902
77160
  * being come back to (see `scrollResetOnNavigation`). `"end"` is a
76903
77161
  * thread read backwards — the last rows are the ones to show, and the ones
@@ -76909,8 +77167,10 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76909
77167
  * the screen it was saved on. `offset: 0` is where a `scrollIntoView()` puts
76910
77168
  * it — in front of the fixed bar the scroller gives room for, below the
76911
77169
  * sticky header and the group label the row lives under — so nothing of that
76912
- * room has to be restated as a number by whoever asks.
76913
- * @param {"start"|"end"|number|{id: string, offset?: number}} [props.scrolled]
77170
+ * room has to be restated as a number by whoever asks. The `index` and
77171
+ * `visibleCount` it also hands out say where to aim before the row is found,
77172
+ * and how many rows to draw before the first paint (see `renderBudget`).
77173
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.scrolled]
76914
77174
  * The same, but held: the list goes back there every time this changes, even
76915
77175
  * after the user has scrolled — the caller owns where the list is (see
76916
77176
  * `defaultScrolled` for the uncontrolled form, and `open`/`defaultOpen`
@@ -76921,12 +77181,12 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76921
77181
  * In every form the list holds itself there while it is still finding out
76922
77182
  * how many rows there are and how tall one is, and lets go the moment the
76923
77183
  * user reaches for the list.
76924
- * @param {(scrolled: {id: string, index: number, offset: number}) => void} [props.onScrolledChange]
77184
+ * @param {(scrolled: {id: string, index: number, offset: number, visibleCount: number}) => void} [props.onScrolledChange]
76925
77185
  * Where the list is, as the user scrolls: the row at the top of the view and
76926
77186
  * how far below the place a row lands on its own (see `defaultScrolled`) it
76927
- * starts. Keep it to come back to it
76928
- * later through `scrolled`/`defaultScrolled` — an index would not do, since
76929
- * rows get inserted while a list is being read.
77187
+ * starts, and how many rows are on screen from that one on. Keep it whole to
77188
+ * come back to it later through `scrolled`/`defaultScrolled` — an index alone
77189
+ * would not do, since rows get inserted while a list is being read.
76930
77190
  * @param {number|{initial: number, after: number}} [props.renderBudget=100]
76931
77191
  * How many rows of a `<List.Items>` run are in the DOM at once: the render
76932
77192
  * window, which slides as the user scrolls while fillers hold the room of
@@ -76938,11 +77198,15 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76938
77198
  * once, with room for that lookahead — the list warns below 30, and when a
76939
77199
  * budget leaves fewer than two rows beyond the screen.
76940
77200
  *
76941
- * `{ initial, after }` for a list drawn in the click that opens it (a popup):
76942
- * `initial` rows in the commit the browser paints first — about what a phone
76943
- * screen shows — and `after` from the paint on, where the floor of 30
76944
- * applies. The runs ask their source for `after` rows from the start, so the
76945
- * first picture costs no second request.
77201
+ * `{ initial, after }` for a list whose first picture is taken as it is
77202
+ * built — drawn in the click that opens a popup, or in the update callback
77203
+ * of a route transition bringing its page back: `initial` rows in the commit
77204
+ * the browser paints first — about what a phone screen shows — counted from
77205
+ * the row the list opens on, and `after` from the paint on, around it, where
77206
+ * the floor of 30 applies. Opening on a position that says how many rows were
77207
+ * on screen (`visibleCount`, see `onScrolledChange`) draws those first,
77208
+ * whatever `initial` says. The runs ask their source for `after` rows from
77209
+ * the start, so the first picture costs no second request.
76946
77210
  * @param {number} [props.virtualItemSize]
76947
77211
  * The size of one row along the scroll axis, in px, when every row has the
76948
77212
  * same: what the fillers are sized with and what a scroll position is
@@ -77412,10 +77676,10 @@ const useBadgeRegistry = (children, enabled) => {
77412
77676
  * list renders nothing at all. In a <Picker> this is the only placeholder
77413
77677
  * the user gets — a picker given a `ui` does not draw its own — so pass the
77414
77678
  * placeholder text: `fallback="Select skills…"`. Plain text is the right
77415
- * choice there: it reads at the picker's size, in the placeholder color the
77416
- * picker gives its empty value slot, and the box stays the same height. A
77417
- * transparent <Badge> matches the slot to the pixel instead, at the price of
77418
- * badge-sized text. See docs/badge_list.md.
77679
+ * choice there: it reads at the picker's size, in the normal text color (a
77680
+ * caller's `ui` is never greyed as a placeholder), and the box stays the same
77681
+ * height. A transparent <Badge> matches the slot to the pixel instead, at the
77682
+ * price of badge-sized text. See docs/typography.md#badgelist.
77419
77683
  * @param {boolean} [shrinkWrap]
77420
77684
  * Narrows the list down to its widest row so the last row isn't ragged.
77421
77685
  * Defaults to true inside a <Picker> — the trigger draws a border around the
@@ -79616,8 +79880,8 @@ const PickerFirstResolver = props => {
79616
79880
  * filter", "anywhere"), so its empty look is the caller's. A `ui` must
79617
79881
  * therefore say on its own that the picker is empty, and take the same room
79618
79882
  * empty as it does filled. A <BadgeList> gets both from its `fallback` — the
79619
- * placeholder text, as plain text, see docs/badge_list.md. `"default"` draws
79620
- * nothing in the slot at all.
79883
+ * placeholder text, as plain text, see docs/typography.md#badgelist.
79884
+ * `"default"` draws nothing in the slot at all.
79621
79885
  *
79622
79886
  * Under `variant="icon"` there is no value to draw, so this IS the trigger:
79623
79887
  * left out it is the icon the right slot would have shown (`rightSlotIcon`,