@jsenv/navi 0.29.321 → 0.29.323

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.
@@ -33840,19 +33840,27 @@ const writeInSignal = (signal, value, { history }) => {
33840
33840
  // is open (`?seat=<gameId>` over a list of cards), and closed is the signal
33841
33841
  // holding none of their values — `undefined`, which a state signal reads as
33842
33842
  // its default.
33843
+ //
33844
+ // A popup without a `value` reads anything the signal holds — save `false`,
33845
+ // `null` and `undefined` — as open: a single sheet bound to `?open=<id>` is
33846
+ // open on whichever id the address names, and the id is the popup's content
33847
+ // to read, not its to write. Opening writes `true` only into a signal that
33848
+ // reads closed; closing writes `false` where the signal held `true`, so a
33849
+ // boolean signal keeps its boolean, and `undefined` for anything else.
33843
33850
  const writeOpenedInSignal = (signal, opened, event, popupValue) => {
33844
- if (readOpened(signal.peek(), popupValue) === opened) {
33851
+ const signalValue = signal.peek();
33852
+ if (readOpened(signalValue, popupValue) === opened) {
33845
33853
  // The signal already says so, meaning this open/close IS what it asked
33846
33854
  // for: a back press that took the popup out of the url, the application
33847
33855
  // writing it. Nothing to write back — and nothing to go back to either,
33848
33856
  // since the navigation navBack would undo is the one that asked for this.
33849
33857
  return;
33850
33858
  }
33851
- const closedValue = popupValue === undefined ? false : undefined;
33852
33859
  if (opened) {
33853
33860
  signal.value = popupValue === undefined ? true : popupValue;
33854
33861
  return;
33855
33862
  }
33863
+ const closedValue = readClosedValue(signalValue);
33856
33864
  if (
33857
33865
  signal.options?.getHistory?.() === "push" &&
33858
33866
  // Nothing of this document behind: the popup was opened by the url itself
@@ -33873,10 +33881,15 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
33873
33881
  };
33874
33882
  const readOpened = (signalValue, popupValue) => {
33875
33883
  if (popupValue === undefined) {
33876
- return signalValue;
33884
+ return (
33885
+ signalValue !== undefined && signalValue !== null && signalValue !== false
33886
+ );
33877
33887
  }
33878
33888
  return signalValue === popupValue;
33879
33889
  };
33890
+ const readClosedValue = (openSignalValue) => {
33891
+ return openSignalValue === true ? false : undefined;
33892
+ };
33880
33893
 
33881
33894
  /**
33882
33895
  * Keeps an open controller in sync with where the caller says the popup should
@@ -33918,6 +33931,13 @@ const useOpenPropsEffectOnOpenController = (
33918
33931
  : signal
33919
33932
  ? readOpened(signal.value, value)
33920
33933
  : props.open;
33934
+ // What the signal holds while it reads open — `true`, the popup's `value`,
33935
+ // or the id a single sheet is open on. Put back when a close is refused,
33936
+ // since by then the signal has been written closed.
33937
+ const openSignalValueRef = useRef(undefined);
33938
+ if (signal && open) {
33939
+ openSignalValueRef.current = signal.value;
33940
+ }
33921
33941
  // Assigned on every render, like openEffect, so it always closes over the
33922
33942
  // latest prop: a popup that opens or closes on its own (Escape, backdrop, a
33923
33943
  // --navi-close command) writes what happened where the caller keeps it, so
@@ -33998,10 +34018,11 @@ const useOpenPropsEffectOnOpenController = (
33998
34018
  // request already reads in the signal, and with a `value` what the
33999
34019
  // signal holds may be another popup's, which the write would erase.
34000
34020
  if (signal && openController.opened !== open) {
34001
- const opened = openController.opened;
34002
34021
  writeInSignal(
34003
34022
  signal,
34004
- value === undefined ? opened : opened ? value : undefined,
34023
+ openController.opened
34024
+ ? openSignalValueRef.current
34025
+ : readClosedValue(signal.peek()),
34005
34026
  { history: "replace" },
34006
34027
  );
34007
34028
  }
@@ -34983,6 +35004,8 @@ const createDisplayedEvent = (ancestor, becauseAncestorOpened) => {
34983
35004
  * @returns {Function} triggerAutofocus — can be called manually with a synthetic
34984
35005
  * event to re-run the focus logic outside of the layout-effect lifecycle.
34985
35006
  */
35007
+ const NO_ELEMENT_REF = { current: null };
35008
+
34986
35009
  const useAutoFocus = (
34987
35010
  focusableElementRef,
34988
35011
  autoFocus,
@@ -35114,8 +35137,15 @@ const useAutoFocus = (
35114
35137
  };
35115
35138
  };
35116
35139
 
35140
+ // Nothing to place when the control asks for no focus ("restore" is given
35141
+ // focus by a transfer, never by this effect): the displayed-effect is then
35142
+ // handed no element at all, so it never asks the layout whether one is on
35143
+ // screen — a question every control in a list would otherwise ask at
35144
+ // mount, for an answer this hook would not use. Read at mount, like the
35145
+ // deps below say.
35146
+ const claimsFocus = Boolean(autoFocus) && autoFocus !== "restore";
35117
35147
  useDisplayedLayoutEffect(
35118
- focusableElementRef,
35148
+ claimsFocus ? focusableElementRef : NO_ELEMENT_REF,
35119
35149
  (el, e) => {
35120
35150
  return triggerAutofocus(e);
35121
35151
  },
@@ -36477,6 +36507,46 @@ const acknowledgeOwnAction = (controller) => {
36477
36507
  controller.acknowledgeUIState();
36478
36508
  };
36479
36509
 
36510
+ // What a group of checkboxes or radios is worth is a claim about values, and
36511
+ // a value whose box is not there cannot be contradicted by that box: a row
36512
+ // scrolled out of a virtualized list, a filtered-out option, a whole page being
36513
+ // parked while a route action reruns — its children leave one at a time, and
36514
+ // each partial reading would otherwise be taken for the answer and written
36515
+ // into whatever the group is bound to (an array signal emptied down to `[]`
36516
+ // by its own unmount). `kept` is what the group holds as it is asked; a value
36517
+ // in it stays until a DRAWN child carrying that value says otherwise.
36518
+ //
36519
+ // Read off the group rather than remembered here on purpose: a value put ON
36520
+ // the group (a `value` prop, a signal, a reopened popup) replaces the whole
36521
+ // selection, and a private memory would go on holding the rows it could not
36522
+ // see being unselected.
36523
+ const keptAndCheckedChildUIStates = (children, fallbackState, kept) => {
36524
+ const drawnValues = new Set(children.map((child) => child.props.value));
36525
+ const values = Array.isArray(kept)
36526
+ ? kept.filter((value) => !drawnValues.has(value))
36527
+ : [];
36528
+ for (const child of children) {
36529
+ if (child.uiState !== undefined) {
36530
+ values.push(child.uiState);
36531
+ }
36532
+ }
36533
+ return values.length === 0 ? undefined : values;
36534
+ };
36535
+ const keptOrFirstDefinedChildUIState = (children, fallbackState, kept) => {
36536
+ for (const child of children) {
36537
+ if (child.uiState !== undefined) {
36538
+ return child.uiState;
36539
+ }
36540
+ }
36541
+ // No drawn child claims it. If the child that held it IS drawn, it was really
36542
+ // deselected; if it is not, the group keeps what it holds.
36543
+ const keptIsDrawn = children.some((child) => child.props.value === kept);
36544
+ if (keptIsDrawn) {
36545
+ return undefined;
36546
+ }
36547
+ return kept;
36548
+ };
36549
+
36480
36550
  // Default aggregate/distribute implementations keyed by controlType or stateType.
36481
36551
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
36482
36552
  // distributeChildUIState. If neither a default nor an explicit impl is found for a
@@ -36485,7 +36555,7 @@ const GROUP_DEFAULTS = {
36485
36555
  radio_group: {
36486
36556
  childControlFilter: (child) =>
36487
36557
  child.controlType === "input" && child.controlHostProps?.type === "radio",
36488
- aggregateChildStates: firstDefinedChildUIState,
36558
+ aggregateChildStates: keptOrFirstDefinedChildUIState,
36489
36559
  distributeChildUIState: (newUIState, childUIStateController) => {
36490
36560
  const childSelected = childUIStateController.props.value === newUIState;
36491
36561
  if (childSelected) {
@@ -36498,16 +36568,7 @@ const GROUP_DEFAULTS = {
36498
36568
  childControlFilter: (child) =>
36499
36569
  child.controlType === "input" &&
36500
36570
  child.controlHostProps?.type === "checkbox",
36501
- aggregateChildStates: (children) => {
36502
- const values = [];
36503
- for (const child of children) {
36504
- const childUIState = child.uiState;
36505
- if (childUIState !== undefined) {
36506
- values.push(childUIState);
36507
- }
36508
- }
36509
- return values.length === 0 ? undefined : values;
36510
- },
36571
+ aggregateChildStates: keptAndCheckedChildUIStates,
36511
36572
  distributeChildUIState: (newUIState, childUIStateController) => {
36512
36573
  const childSelected =
36513
36574
  Array.isArray(newUIState) &&
@@ -59409,14 +59470,15 @@ const css$E = /* css */`
59409
59470
  * the focus only leaves it for something that asked by name (`autoFocus` on
59410
59471
  * that element, which outranks whatever the dialog says).
59411
59472
  * @param {boolean} [props.open] - Controlled open state.
59412
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
59473
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
59413
59474
  * state said the way every navi control says it: the dialog opens and closes
59414
59475
  * to match the signal, and writes into it whenever it opens or closes on its
59415
59476
  * own (Escape, backdrop, a --navi-close command) — one binding to both drive
59416
59477
  * the dialog and know where it is, and the state stays where the app put it.
59417
59478
  * Excludes `open`; `onOpen`/`onClose` still fire. A signal holding `true` at
59418
59479
  * mount behaves like `defaultOpen`: the dialog was already open, no entrance
59419
- * plays.
59480
+ * plays. A signal holding an id (one dialog showing whichever card
59481
+ * the address names) reads as open, and the dialog leaves the id alone.
59420
59482
  * @param {any} [props.value] - What `signal` holds while THIS dialog is
59421
59483
  * open, for several of them sharing one signal that says which is open
59422
59484
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -61024,14 +61086,15 @@ const css$D = /* css */`
61024
61086
  * the focus only leaves it for something that asked by name (`autoFocus` on
61025
61087
  * that element, which outranks whatever the popover says).
61026
61088
  * @param {boolean} [props.open] - Controlled open state.
61027
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
61089
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
61028
61090
  * state said the way every navi control says it: the popover opens and closes
61029
61091
  * to match the signal, and writes into it whenever it opens or closes on its
61030
61092
  * own (Escape, light dismiss, a --navi-close command) — one binding to both
61031
61093
  * drive the popover and know where it is, and the state stays where the app
61032
61094
  * put it. Excludes `open`; `onOpen`/`onClose` still fire. A signal holding
61033
61095
  * `true` at mount behaves like `defaultOpen`: the popover was already open,
61034
- * no entrance plays.
61096
+ * no entrance plays. A signal holding an id (one popover showing whichever card
61097
+ * the address names) reads as open, and the popover leaves the id alone.
61035
61098
  * @param {any} [props.value] - What `signal` holds while THIS popover is
61036
61099
  * open, for several of them sharing one signal that says which is open
61037
61100
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -64679,45 +64742,6 @@ const ListSelectable = props => {
64679
64742
  focusGroupDirection,
64680
64743
  focusGroupWrap
64681
64744
  } = props;
64682
- // `kept` is what the list holds as it is asked, which is not the same as what
64683
- // its rows say: a list draws the rows it needs and no more, so the selected
64684
- // one may be scrolled out of the window or filtered out of the view, and a
64685
- // row that is not there cannot say it is not selected. Reading it off the
64686
- // group rather than remembering it here is what makes a value put ON the list
64687
- // (a `value` prop, a signal, a reopened popup) replace the whole selection —
64688
- // a private memory of its own would go on holding the rows it could not see
64689
- // being unselected.
64690
- //
64691
- // `fallbackState` is the empty of the shape the list declared below
64692
- // (`stateType`): `[]` for a multiple list, nothing for a single one. Taking
64693
- // it is what lets an emptied list say "empty" — `undefined` is the word for
64694
- // "unset", and a bound stateSignal reads that as "nothing decided here, go
64695
- // back to the default" (see docs/control_value.md), which is how a list
64696
- // emptied down to its last row puts that row back on reload.
64697
- const aggregateChildStates = (children, fallbackState, kept) => {
64698
- if (multiple) {
64699
- const drawnValues = new Set(children.map(child => child.props.value));
64700
- const stillSelected = Array.isArray(kept) ? kept.filter(value => !drawnValues.has(value)) : [];
64701
- for (const child of children) {
64702
- if (child.uiState !== undefined) {
64703
- stillSelected.push(child.uiState);
64704
- }
64705
- }
64706
- return stillSelected.length === 0 ? fallbackState : stillSelected;
64707
- }
64708
- for (const child of children) {
64709
- if (child.uiState !== undefined) {
64710
- return child.uiState;
64711
- }
64712
- }
64713
- // No drawn row claims it. If the row that held it IS drawn, it was really
64714
- // deselected; if it is not, the list keeps what it holds.
64715
- const keptIsDrawn = children.some(child => child.props.value === kept);
64716
- if (keptIsDrawn) {
64717
- return undefined;
64718
- }
64719
- return kept;
64720
- };
64721
64745
  const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps({
64722
64746
  // The defaults of the group this IS (see CheckboxGroup / RadioGroup):
64723
64747
  // what a selectable list shows is a claim about what was accepted, so
@@ -64729,9 +64753,15 @@ const ListSelectable = props => {
64729
64753
  resetOnError: true,
64730
64754
  ...props
64731
64755
  }, {
64756
+ // A list draws the rows it needs and no more, so a selected row may be
64757
+ // scrolled out of the window or filtered out of the view: the default
64758
+ // aggregate of these two groups keeps a value whose row is not drawn
64759
+ // (see GROUP_DEFAULTS in ui_state_controller.js). `stateType` is what
64760
+ // an emptied multiple list says — `[]`, not `undefined`, which a bound
64761
+ // stateSignal reads as "nothing decided here, go back to the default"
64762
+ // (see docs/control_value.md).
64732
64763
  stateType: multiple ? "array" : "",
64733
- controlType: multiple ? "checkbox_group" : "radio_group",
64734
- aggregateChildStates
64764
+ controlType: multiple ? "checkbox_group" : "radio_group"
64735
64765
  });
64736
64766
  const uiGroupStateController = getUIStateControllerById(listControlProps.id);
64737
64767
  useFocusGroup(ref, {
@@ -65933,6 +65963,7 @@ const ListUI = props => {
65933
65963
  ref,
65934
65964
  tracker,
65935
65965
  renderBudget,
65966
+ renderBudgetSteady: renderBudgetAfterPaint,
65936
65967
  virtualItemSize,
65937
65968
  virtual,
65938
65969
  scrolled,
@@ -66260,6 +66291,7 @@ const useListScrollSync = ({
66260
66291
  ref,
66261
66292
  tracker,
66262
66293
  renderBudget,
66294
+ renderBudgetSteady,
66263
66295
  virtualItemSize,
66264
66296
  virtual,
66265
66297
  scrolled,
@@ -67010,31 +67042,36 @@ const useListScrollSync = ({
67010
67042
  }
67011
67043
  });
67012
67044
 
67013
- // Scroll listener — slides the window as the user scrolls.
67014
- useLayoutEffect(() => {
67015
- const listContainerEl = ref.current;
67016
- if (!listContainerEl) {
67017
- return undefined;
67045
+ // The window the visible band asks for, decided from geometry: the band's
67046
+ // two edges in row units, from where the scroller's viewport cuts the list
67047
+ // and the row size. What has to be drawn is everything between the edges,
67048
+ // plus what the scroll is about to bring — so the window is judged on the
67049
+ // rows it keeps AHEAD of the band, in the direction the user goes, and moves
67050
+ // once those fall under half a screen. Re-framed, it puts three quarters of
67051
+ // its spare rows ahead and one quarter behind: a window centred on the band
67052
+ // is due to move again as soon as the band has crossed the few rows it kept
67053
+ // ahead, and each move is rows drawn while the user waits for them.
67054
+ // Without a row size there is no geometry to reason on; the row under a
67055
+ // probe stands in for the band, framed the old way.
67056
+ const scrollDirectionRef = useRef(1);
67057
+ const windowSlidRef = useRef(false);
67058
+ useRef(false);
67059
+ const evaluateWindow = reason => {
67060
+ const total = virtual.totalSignal.peek();
67061
+ if (total <= renderBudget) {
67062
+ return;
67018
67063
  }
67019
67064
  const scrollerEl = getScroller();
67020
67065
  const listEl = getListEl();
67021
- const onScroll = () => {
67022
- updateCurrentScroll();
67023
- // Where the user is now is where things must be held from now on.
67024
- anchorRef.current = null;
67025
- if (scrolledByListRef.current) {
67026
- // The window stays where it is — the position it would be re-derived
67027
- // from was chosen to keep the rows still — but where the list is has
67028
- // genuinely changed, and whoever keeps that position must hear it.
67029
- scrolledByListRef.current = false;
67030
- reportPosition();
67031
- return;
67032
- }
67033
- reportPosition();
67034
- const total = virtual.totalSignal.peek();
67035
- if (total <= renderBudget) {
67036
- return;
67037
- }
67066
+ if (!scrollerEl || !listEl) {
67067
+ return;
67068
+ }
67069
+ const {
67070
+ start,
67071
+ end
67072
+ } = renderWindowRef.current;
67073
+ const virtualItemSize = virtualItemSizeSignal.peek();
67074
+ if (virtualItemSize === 0) {
67038
67075
  const scrollInfo = getScrollInfo({
67039
67076
  scrollValues: {
67040
67077
  left: scrollerEl.scrollLeft,
@@ -67051,17 +67088,8 @@ const useListScrollSync = ({
67051
67088
  return;
67052
67089
  }
67053
67090
  const {
67054
- index,
67055
- reason
67091
+ index
67056
67092
  } = scrollInfo;
67057
- // Recentering on every row crossed would rebuild the whole window a few
67058
- // times a second, and a window rebuilt is every row of it rendered
67059
- // again. It only moves once what is on screen comes near one of its
67060
- // edges — until then, it already holds what has to be drawn.
67061
- const {
67062
- start,
67063
- end
67064
- } = renderWindowRef.current;
67065
67093
  const margin = Math.floor(renderBudget / 4);
67066
67094
  const farFromStart = index - start >= margin || start === 0;
67067
67095
  const farFromEnd = end - index > margin || end === total;
@@ -67069,12 +67097,113 @@ const useListScrollSync = ({
67069
67097
  return;
67070
67098
  }
67071
67099
  const half = Math.floor(renderBudget / 2);
67072
- let newStart = Math.max(0, index - half);
67073
- let newEnd = Math.min(total, newStart + renderBudget);
67074
- if (newEnd === total) {
67075
- newStart = Math.max(0, total - renderBudget);
67100
+ let newStart = index - half < 0 ? 0 : index - half;
67101
+ let newEnd = newStart + renderBudget;
67102
+ if (newEnd > total) {
67103
+ newEnd = total;
67104
+ newStart = total - renderBudget < 0 ? 0 : total - renderBudget;
67105
+ }
67106
+ windowSlidRef.current = true;
67107
+ updateRenderWindow(newStart, newEnd, `${reason}: ${scrollInfo.reason}`);
67108
+ return;
67109
+ }
67110
+ const viewportRect = getScrollerViewportRect(scrollerEl);
67111
+ const listRect = listEl.getBoundingClientRect();
67112
+ const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
67113
+ const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
67114
+ const listFrom = horizontal ? listRect.left : listRect.top;
67115
+ let bandStart = Math.floor((viewportFrom - listFrom) / virtualItemSize);
67116
+ // Exclusive, like the window's end.
67117
+ let bandEnd = Math.ceil((viewportTo - listFrom) / virtualItemSize);
67118
+ if (bandStart < 0) {
67119
+ bandStart = 0;
67120
+ }
67121
+ if (bandEnd > total) {
67122
+ bandEnd = total;
67123
+ }
67124
+ if (bandEnd <= bandStart) {
67125
+ return;
67126
+ }
67127
+ const visibleCount = bandEnd - bandStart;
67128
+ const spare = renderBudget - visibleCount;
67129
+ const behind = spare > 0 ? Math.floor(spare / 4) : 0;
67130
+ const ahead = spare > 0 ? spare - behind : 0;
67131
+ const halfScreen = Math.ceil(visibleCount / 2);
67132
+ const aheadNeeded = halfScreen < ahead / 2 ? halfScreen : Math.floor(ahead / 2);
67133
+ const behindNeeded = halfScreen < behind / 2 ? halfScreen : Math.floor(behind / 2);
67134
+ const forward = scrollDirectionRef.current > 0;
67135
+ const rowsAheadOfBand = forward ? end - bandEnd : bandStart - start;
67136
+ const rowsBehindBand = forward ? bandStart - start : end - bandEnd;
67137
+ const atEdgeAhead = forward ? end === total : start === 0;
67138
+ const atEdgeBehind = forward ? start === 0 : end === total;
67139
+ const coversBand = start <= bandStart && end >= bandEnd;
67140
+ const coversAhead = atEdgeAhead || rowsAheadOfBand >= aheadNeeded;
67141
+ const coversBehind = atEdgeBehind || rowsBehindBand >= behindNeeded;
67142
+ if (coversBand && coversAhead && coversBehind) {
67143
+ return;
67144
+ }
67145
+ let newStart = forward ? bandStart - behind : bandEnd + behind - renderBudget;
67146
+ if (newStart < 0) {
67147
+ newStart = 0;
67148
+ }
67149
+ let newEnd = newStart + renderBudget;
67150
+ if (newEnd > total) {
67151
+ newEnd = total;
67152
+ newStart = total - renderBudget < 0 ? 0 : total - renderBudget;
67153
+ }
67154
+ windowSlidRef.current = true;
67155
+ updateRenderWindow(newStart, newEnd);
67156
+ };
67157
+ // A slide is judged again once its rows are laid out: the scroll had moved
67158
+ // on while the rows were being drawn, or the anchoring of this very commit
67159
+ // moved it, and a window that stops short of the screen's edge with no
67160
+ // scroll event to come would stay short for good. On the next frame, after
67161
+ // the layout this commit's anchoring settles on.
67162
+ useLayoutEffect(() => {
67163
+ if (!windowSlidRef.current) {
67164
+ return undefined;
67165
+ }
67166
+ windowSlidRef.current = false;
67167
+ const frameId = requestAnimationFrame(() => {
67168
+ evaluateWindow("after slide");
67169
+ });
67170
+ return () => {
67171
+ cancelAnimationFrame(frameId);
67172
+ };
67173
+ }, [renderWindow]);
67174
+
67175
+ // Scroll listener — slides the window as the user scrolls.
67176
+ useLayoutEffect(() => {
67177
+ const listContainerEl = ref.current;
67178
+ if (!listContainerEl) {
67179
+ return undefined;
67180
+ }
67181
+ const scrollerEl = getScroller();
67182
+ const onScroll = () => {
67183
+ // Which way the user is going, read before the position is stored: what
67184
+ // the window keeps ahead is ahead of this.
67185
+ const previousScroll = currentScrollRef.current;
67186
+ if (previousScroll) {
67187
+ const delta = horizontal ? scrollerEl.scrollLeft - previousScroll.left : scrollerEl.scrollTop - previousScroll.top;
67188
+ if (delta > 0) {
67189
+ scrollDirectionRef.current = 1;
67190
+ } else if (delta < 0) {
67191
+ scrollDirectionRef.current = -1;
67192
+ }
67193
+ }
67194
+ updateCurrentScroll();
67195
+ // Where the user is now is where things must be held from now on.
67196
+ anchorRef.current = null;
67197
+ if (scrolledByListRef.current) {
67198
+ // The window stays where it is — the position it would be re-derived
67199
+ // from was chosen to keep the rows still — but where the list is has
67200
+ // genuinely changed, and whoever keeps that position must hear it.
67201
+ scrolledByListRef.current = false;
67202
+ reportPosition();
67203
+ return;
67076
67204
  }
67077
- updateRenderWindow(newStart, newEnd);
67205
+ reportPosition();
67206
+ evaluateWindow("scroll");
67078
67207
  };
67079
67208
  // A page-level scroller does not emit "scroll" on the element itself
67080
67209
  // (document.scrollingElement); the document does.
@@ -70008,8 +70137,11 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
70008
70137
  * window, which slides as the user scrolls while fillers hold the room of
70009
70138
  * the rows outside it. Rows declared one by one as `<List.Item>` children
70010
70139
  * are all drawn, whatever this says — a list with more than a few dozen rows
70011
- * gives them to a run (see docs/scroll.md, "Many rows"). Below 30 the list
70012
- * warns: a window shorter than a tall screen shows blank fillers.
70140
+ * gives them to a run (see docs/scroll.md, "Many rows"). The window keeps
70141
+ * most of its spare rows ahead of the scroll, and moves once those fall
70142
+ * under half a screen; it has to be larger than what the scroller shows at
70143
+ * once, with room for that lookahead — the list warns below 30, and when a
70144
+ * budget leaves fewer than two rows beyond the screen.
70013
70145
  *
70014
70146
  * `{ initial, after }` for a list drawn in the click that opens it (a popup):
70015
70147
  * `initial` rows in the commit the browser paints first — about what a phone
@@ -84043,7 +84175,7 @@ const css = /* css */`.navi_side_panel {
84043
84175
  * @param {object} props
84044
84176
  * @param {boolean} [props.open] - Controlled open state, forwarded as-is to
84045
84177
  * `Popup`'s own `open`.
84046
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
84178
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
84047
84179
  * state said the way every navi control says it: the panel opens and closes
84048
84180
  * to match the signal, and writes into it whenever it opens or closes on its
84049
84181
  * own (Escape, swipe, a --navi-close command) — one binding to both drive