@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.
@@ -34689,19 +34689,27 @@ const writeInSignal = (signal, value, { history }) => {
34689
34689
  // is open (`?seat=<gameId>` over a list of cards), and closed is the signal
34690
34690
  // holding none of their values — `undefined`, which a state signal reads as
34691
34691
  // its default.
34692
+ //
34693
+ // A popup without a `value` reads anything the signal holds — save `false`,
34694
+ // `null` and `undefined` — as open: a single sheet bound to `?open=<id>` is
34695
+ // open on whichever id the address names, and the id is the popup's content
34696
+ // to read, not its to write. Opening writes `true` only into a signal that
34697
+ // reads closed; closing writes `false` where the signal held `true`, so a
34698
+ // boolean signal keeps its boolean, and `undefined` for anything else.
34692
34699
  const writeOpenedInSignal = (signal, opened, event, popupValue) => {
34693
- if (readOpened(signal.peek(), popupValue) === opened) {
34700
+ const signalValue = signal.peek();
34701
+ if (readOpened(signalValue, popupValue) === opened) {
34694
34702
  // The signal already says so, meaning this open/close IS what it asked
34695
34703
  // for: a back press that took the popup out of the url, the application
34696
34704
  // writing it. Nothing to write back — and nothing to go back to either,
34697
34705
  // since the navigation navBack would undo is the one that asked for this.
34698
34706
  return;
34699
34707
  }
34700
- const closedValue = popupValue === undefined ? false : undefined;
34701
34708
  if (opened) {
34702
34709
  signal.value = popupValue === undefined ? true : popupValue;
34703
34710
  return;
34704
34711
  }
34712
+ const closedValue = readClosedValue(signalValue);
34705
34713
  if (
34706
34714
  signal.options?.getHistory?.() === "push" &&
34707
34715
  // Nothing of this document behind: the popup was opened by the url itself
@@ -34722,10 +34730,15 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
34722
34730
  };
34723
34731
  const readOpened = (signalValue, popupValue) => {
34724
34732
  if (popupValue === undefined) {
34725
- return signalValue;
34733
+ return (
34734
+ signalValue !== undefined && signalValue !== null && signalValue !== false
34735
+ );
34726
34736
  }
34727
34737
  return signalValue === popupValue;
34728
34738
  };
34739
+ const readClosedValue = (openSignalValue) => {
34740
+ return openSignalValue === true ? false : undefined;
34741
+ };
34729
34742
 
34730
34743
  /**
34731
34744
  * Keeps an open controller in sync with where the caller says the popup should
@@ -34780,6 +34793,13 @@ const useOpenPropsEffectOnOpenController = (
34780
34793
  : signal
34781
34794
  ? readOpened(signal.value, value)
34782
34795
  : props.open;
34796
+ // What the signal holds while it reads open — `true`, the popup's `value`,
34797
+ // or the id a single sheet is open on. Put back when a close is refused,
34798
+ // since by then the signal has been written closed.
34799
+ const openSignalValueRef = useRef(undefined);
34800
+ if (signal && open) {
34801
+ openSignalValueRef.current = signal.value;
34802
+ }
34783
34803
  // Assigned on every render, like openEffect, so it always closes over the
34784
34804
  // latest prop: a popup that opens or closes on its own (Escape, backdrop, a
34785
34805
  // --navi-close command) writes what happened where the caller keeps it, so
@@ -34860,10 +34880,11 @@ const useOpenPropsEffectOnOpenController = (
34860
34880
  // request already reads in the signal, and with a `value` what the
34861
34881
  // signal holds may be another popup's, which the write would erase.
34862
34882
  if (signal && openController.opened !== open) {
34863
- const opened = openController.opened;
34864
34883
  writeInSignal(
34865
34884
  signal,
34866
- value === undefined ? opened : opened ? value : undefined,
34885
+ openController.opened
34886
+ ? openSignalValueRef.current
34887
+ : readClosedValue(signal.peek()),
34867
34888
  { history: "replace" },
34868
34889
  );
34869
34890
  }
@@ -35883,6 +35904,8 @@ const createDisplayedEvent = (ancestor, becauseAncestorOpened) => {
35883
35904
  * @returns {Function} triggerAutofocus — can be called manually with a synthetic
35884
35905
  * event to re-run the focus logic outside of the layout-effect lifecycle.
35885
35906
  */
35907
+ const NO_ELEMENT_REF = { current: null };
35908
+
35886
35909
  const useAutoFocus = (
35887
35910
  focusableElementRef,
35888
35911
  autoFocus,
@@ -36016,8 +36039,15 @@ const useAutoFocus = (
36016
36039
  };
36017
36040
  };
36018
36041
 
36042
+ // Nothing to place when the control asks for no focus ("restore" is given
36043
+ // focus by a transfer, never by this effect): the displayed-effect is then
36044
+ // handed no element at all, so it never asks the layout whether one is on
36045
+ // screen — a question every control in a list would otherwise ask at
36046
+ // mount, for an answer this hook would not use. Read at mount, like the
36047
+ // deps below say.
36048
+ const claimsFocus = Boolean(autoFocus) && autoFocus !== "restore";
36019
36049
  useDisplayedLayoutEffect(
36020
- focusableElementRef,
36050
+ claimsFocus ? focusableElementRef : NO_ELEMENT_REF,
36021
36051
  (el, e) => {
36022
36052
  return triggerAutofocus(e);
36023
36053
  },
@@ -37401,6 +37431,46 @@ const acknowledgeOwnAction = (controller) => {
37401
37431
  controller.acknowledgeUIState();
37402
37432
  };
37403
37433
 
37434
+ // What a group of checkboxes or radios is worth is a claim about values, and
37435
+ // a value whose box is not there cannot be contradicted by that box: a row
37436
+ // scrolled out of a virtualized list, a filtered-out option, a whole page being
37437
+ // parked while a route action reruns — its children leave one at a time, and
37438
+ // each partial reading would otherwise be taken for the answer and written
37439
+ // into whatever the group is bound to (an array signal emptied down to `[]`
37440
+ // by its own unmount). `kept` is what the group holds as it is asked; a value
37441
+ // in it stays until a DRAWN child carrying that value says otherwise.
37442
+ //
37443
+ // Read off the group rather than remembered here on purpose: a value put ON
37444
+ // the group (a `value` prop, a signal, a reopened popup) replaces the whole
37445
+ // selection, and a private memory would go on holding the rows it could not
37446
+ // see being unselected.
37447
+ const keptAndCheckedChildUIStates = (children, fallbackState, kept) => {
37448
+ const drawnValues = new Set(children.map((child) => child.props.value));
37449
+ const values = Array.isArray(kept)
37450
+ ? kept.filter((value) => !drawnValues.has(value))
37451
+ : [];
37452
+ for (const child of children) {
37453
+ if (child.uiState !== undefined) {
37454
+ values.push(child.uiState);
37455
+ }
37456
+ }
37457
+ return values.length === 0 ? undefined : values;
37458
+ };
37459
+ const keptOrFirstDefinedChildUIState = (children, fallbackState, kept) => {
37460
+ for (const child of children) {
37461
+ if (child.uiState !== undefined) {
37462
+ return child.uiState;
37463
+ }
37464
+ }
37465
+ // No drawn child claims it. If the child that held it IS drawn, it was really
37466
+ // deselected; if it is not, the group keeps what it holds.
37467
+ const keptIsDrawn = children.some((child) => child.props.value === kept);
37468
+ if (keptIsDrawn) {
37469
+ return undefined;
37470
+ }
37471
+ return kept;
37472
+ };
37473
+
37404
37474
  // Default aggregate/distribute implementations keyed by controlType or stateType.
37405
37475
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
37406
37476
  // distributeChildUIState. If neither a default nor an explicit impl is found for a
@@ -37409,7 +37479,7 @@ const GROUP_DEFAULTS = {
37409
37479
  radio_group: {
37410
37480
  childControlFilter: (child) =>
37411
37481
  child.controlType === "input" && child.controlHostProps?.type === "radio",
37412
- aggregateChildStates: firstDefinedChildUIState,
37482
+ aggregateChildStates: keptOrFirstDefinedChildUIState,
37413
37483
  distributeChildUIState: (newUIState, childUIStateController) => {
37414
37484
  const childSelected = childUIStateController.props.value === newUIState;
37415
37485
  if (childSelected) {
@@ -37422,16 +37492,7 @@ const GROUP_DEFAULTS = {
37422
37492
  childControlFilter: (child) =>
37423
37493
  child.controlType === "input" &&
37424
37494
  child.controlHostProps?.type === "checkbox",
37425
- aggregateChildStates: (children) => {
37426
- const values = [];
37427
- for (const child of children) {
37428
- const childUIState = child.uiState;
37429
- if (childUIState !== undefined) {
37430
- values.push(childUIState);
37431
- }
37432
- }
37433
- return values.length === 0 ? undefined : values;
37434
- },
37495
+ aggregateChildStates: keptAndCheckedChildUIStates,
37435
37496
  distributeChildUIState: (newUIState, childUIStateController) => {
37436
37497
  const childSelected =
37437
37498
  Array.isArray(newUIState) &&
@@ -60544,14 +60605,15 @@ const css$E = /* css */`
60544
60605
  * the focus only leaves it for something that asked by name (`autoFocus` on
60545
60606
  * that element, which outranks whatever the dialog says).
60546
60607
  * @param {boolean} [props.open] - Controlled open state.
60547
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
60608
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
60548
60609
  * state said the way every navi control says it: the dialog opens and closes
60549
60610
  * to match the signal, and writes into it whenever it opens or closes on its
60550
60611
  * own (Escape, backdrop, a --navi-close command) — one binding to both drive
60551
60612
  * the dialog and know where it is, and the state stays where the app put it.
60552
60613
  * Excludes `open`; `onOpen`/`onClose` still fire. A signal holding `true` at
60553
60614
  * mount behaves like `defaultOpen`: the dialog was already open, no entrance
60554
- * plays.
60615
+ * plays. A signal holding an id (one dialog showing whichever card
60616
+ * the address names) reads as open, and the dialog leaves the id alone.
60555
60617
  * @param {any} [props.value] - What `signal` holds while THIS dialog is
60556
60618
  * open, for several of them sharing one signal that says which is open
60557
60619
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -62205,14 +62267,15 @@ const css$D = /* css */`
62205
62267
  * the focus only leaves it for something that asked by name (`autoFocus` on
62206
62268
  * that element, which outranks whatever the popover says).
62207
62269
  * @param {boolean} [props.open] - Controlled open state.
62208
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
62270
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
62209
62271
  * state said the way every navi control says it: the popover opens and closes
62210
62272
  * to match the signal, and writes into it whenever it opens or closes on its
62211
62273
  * own (Escape, light dismiss, a --navi-close command) — one binding to both
62212
62274
  * drive the popover and know where it is, and the state stays where the app
62213
62275
  * put it. Excludes `open`; `onOpen`/`onClose` still fire. A signal holding
62214
62276
  * `true` at mount behaves like `defaultOpen`: the popover was already open,
62215
- * no entrance plays.
62277
+ * no entrance plays. A signal holding an id (one popover showing whichever card
62278
+ * the address names) reads as open, and the popover leaves the id alone.
62216
62279
  * @param {any} [props.value] - What `signal` holds while THIS popover is
62217
62280
  * open, for several of them sharing one signal that says which is open
62218
62281
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -65883,45 +65946,6 @@ const ListSelectable = props => {
65883
65946
  focusGroupDirection,
65884
65947
  focusGroupWrap
65885
65948
  } = props;
65886
- // `kept` is what the list holds as it is asked, which is not the same as what
65887
- // its rows say: a list draws the rows it needs and no more, so the selected
65888
- // one may be scrolled out of the window or filtered out of the view, and a
65889
- // row that is not there cannot say it is not selected. Reading it off the
65890
- // group rather than remembering it here is what makes a value put ON the list
65891
- // (a `value` prop, a signal, a reopened popup) replace the whole selection —
65892
- // a private memory of its own would go on holding the rows it could not see
65893
- // being unselected.
65894
- //
65895
- // `fallbackState` is the empty of the shape the list declared below
65896
- // (`stateType`): `[]` for a multiple list, nothing for a single one. Taking
65897
- // it is what lets an emptied list say "empty" — `undefined` is the word for
65898
- // "unset", and a bound stateSignal reads that as "nothing decided here, go
65899
- // back to the default" (see docs/control_value.md), which is how a list
65900
- // emptied down to its last row puts that row back on reload.
65901
- const aggregateChildStates = (children, fallbackState, kept) => {
65902
- if (multiple) {
65903
- const drawnValues = new Set(children.map(child => child.props.value));
65904
- const stillSelected = Array.isArray(kept) ? kept.filter(value => !drawnValues.has(value)) : [];
65905
- for (const child of children) {
65906
- if (child.uiState !== undefined) {
65907
- stillSelected.push(child.uiState);
65908
- }
65909
- }
65910
- return stillSelected.length === 0 ? fallbackState : stillSelected;
65911
- }
65912
- for (const child of children) {
65913
- if (child.uiState !== undefined) {
65914
- return child.uiState;
65915
- }
65916
- }
65917
- // No drawn row claims it. If the row that held it IS drawn, it was really
65918
- // deselected; if it is not, the list keeps what it holds.
65919
- const keptIsDrawn = children.some(child => child.props.value === kept);
65920
- if (keptIsDrawn) {
65921
- return undefined;
65922
- }
65923
- return kept;
65924
- };
65925
65949
  const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps({
65926
65950
  // The defaults of the group this IS (see CheckboxGroup / RadioGroup):
65927
65951
  // what a selectable list shows is a claim about what was accepted, so
@@ -65933,9 +65957,15 @@ const ListSelectable = props => {
65933
65957
  resetOnError: true,
65934
65958
  ...props
65935
65959
  }, {
65960
+ // A list draws the rows it needs and no more, so a selected row may be
65961
+ // scrolled out of the window or filtered out of the view: the default
65962
+ // aggregate of these two groups keeps a value whose row is not drawn
65963
+ // (see GROUP_DEFAULTS in ui_state_controller.js). `stateType` is what
65964
+ // an emptied multiple list says — `[]`, not `undefined`, which a bound
65965
+ // stateSignal reads as "nothing decided here, go back to the default"
65966
+ // (see docs/control_value.md).
65936
65967
  stateType: multiple ? "array" : "",
65937
- controlType: multiple ? "checkbox_group" : "radio_group",
65938
- aggregateChildStates
65968
+ controlType: multiple ? "checkbox_group" : "radio_group"
65939
65969
  });
65940
65970
  const uiGroupStateController = getUIStateControllerById(listControlProps.id);
65941
65971
  useFocusGroup(ref, {
@@ -67137,6 +67167,7 @@ const ListUI = props => {
67137
67167
  ref,
67138
67168
  tracker,
67139
67169
  renderBudget,
67170
+ renderBudgetSteady: renderBudgetAfterPaint,
67140
67171
  virtualItemSize,
67141
67172
  virtual,
67142
67173
  scrolled,
@@ -67464,6 +67495,7 @@ const useListScrollSync = ({
67464
67495
  ref,
67465
67496
  tracker,
67466
67497
  renderBudget,
67498
+ renderBudgetSteady,
67467
67499
  virtualItemSize,
67468
67500
  virtual,
67469
67501
  scrolled,
@@ -68217,31 +68249,36 @@ const useListScrollSync = ({
68217
68249
  }
68218
68250
  });
68219
68251
 
68220
- // Scroll listener — slides the window as the user scrolls.
68221
- useLayoutEffect(() => {
68222
- const listContainerEl = ref.current;
68223
- if (!listContainerEl) {
68224
- return undefined;
68252
+ // The window the visible band asks for, decided from geometry: the band's
68253
+ // two edges in row units, from where the scroller's viewport cuts the list
68254
+ // and the row size. What has to be drawn is everything between the edges,
68255
+ // plus what the scroll is about to bring — so the window is judged on the
68256
+ // rows it keeps AHEAD of the band, in the direction the user goes, and moves
68257
+ // once those fall under half a screen. Re-framed, it puts three quarters of
68258
+ // its spare rows ahead and one quarter behind: a window centred on the band
68259
+ // is due to move again as soon as the band has crossed the few rows it kept
68260
+ // ahead, and each move is rows drawn while the user waits for them.
68261
+ // Without a row size there is no geometry to reason on; the row under a
68262
+ // probe stands in for the band, framed the old way.
68263
+ const scrollDirectionRef = useRef(1);
68264
+ const windowSlidRef = useRef(false);
68265
+ const budgetWarnedRef = useRef(false);
68266
+ const evaluateWindow = reason => {
68267
+ const total = virtual.totalSignal.peek();
68268
+ if (total <= renderBudget) {
68269
+ return;
68225
68270
  }
68226
68271
  const scrollerEl = getScroller();
68227
68272
  const listEl = getListEl();
68228
- const onScroll = () => {
68229
- updateCurrentScroll();
68230
- // Where the user is now is where things must be held from now on.
68231
- anchorRef.current = null;
68232
- if (scrolledByListRef.current) {
68233
- // The window stays where it is — the position it would be re-derived
68234
- // from was chosen to keep the rows still — but where the list is has
68235
- // genuinely changed, and whoever keeps that position must hear it.
68236
- scrolledByListRef.current = false;
68237
- reportPosition();
68238
- return;
68239
- }
68240
- reportPosition();
68241
- const total = virtual.totalSignal.peek();
68242
- if (total <= renderBudget) {
68243
- return;
68244
- }
68273
+ if (!scrollerEl || !listEl) {
68274
+ return;
68275
+ }
68276
+ const {
68277
+ start,
68278
+ end
68279
+ } = renderWindowRef.current;
68280
+ const virtualItemSize = virtualItemSizeSignal.peek();
68281
+ if (virtualItemSize === 0) {
68245
68282
  const scrollInfo = getScrollInfo({
68246
68283
  scrollValues: {
68247
68284
  left: scrollerEl.scrollLeft,
@@ -68258,17 +68295,8 @@ const useListScrollSync = ({
68258
68295
  return;
68259
68296
  }
68260
68297
  const {
68261
- index,
68262
- reason
68298
+ index
68263
68299
  } = scrollInfo;
68264
- // Recentering on every row crossed would rebuild the whole window a few
68265
- // times a second, and a window rebuilt is every row of it rendered
68266
- // again. It only moves once what is on screen comes near one of its
68267
- // edges — until then, it already holds what has to be drawn.
68268
- const {
68269
- start,
68270
- end
68271
- } = renderWindowRef.current;
68272
68300
  const margin = Math.floor(renderBudget / 4);
68273
68301
  const farFromStart = index - start >= margin || start === 0;
68274
68302
  const farFromEnd = end - index > margin || end === total;
@@ -68276,12 +68304,117 @@ const useListScrollSync = ({
68276
68304
  return;
68277
68305
  }
68278
68306
  const half = Math.floor(renderBudget / 2);
68279
- let newStart = Math.max(0, index - half);
68280
- let newEnd = Math.min(total, newStart + renderBudget);
68281
- if (newEnd === total) {
68282
- newStart = Math.max(0, total - renderBudget);
68307
+ let newStart = index - half < 0 ? 0 : index - half;
68308
+ let newEnd = newStart + renderBudget;
68309
+ if (newEnd > total) {
68310
+ newEnd = total;
68311
+ newStart = total - renderBudget < 0 ? 0 : total - renderBudget;
68312
+ }
68313
+ windowSlidRef.current = true;
68314
+ updateRenderWindow(newStart, newEnd, `${reason}: ${scrollInfo.reason}`);
68315
+ return;
68316
+ }
68317
+ const viewportRect = getScrollerViewportRect(scrollerEl);
68318
+ const listRect = listEl.getBoundingClientRect();
68319
+ const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
68320
+ const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
68321
+ const listFrom = horizontal ? listRect.left : listRect.top;
68322
+ let bandStart = Math.floor((viewportFrom - listFrom) / virtualItemSize);
68323
+ // Exclusive, like the window's end.
68324
+ let bandEnd = Math.ceil((viewportTo - listFrom) / virtualItemSize);
68325
+ if (bandStart < 0) {
68326
+ bandStart = 0;
68327
+ }
68328
+ if (bandEnd > total) {
68329
+ bandEnd = total;
68330
+ }
68331
+ if (bandEnd <= bandStart) {
68332
+ return;
68333
+ }
68334
+ const visibleCount = bandEnd - bandStart;
68335
+ const spare = renderBudget - visibleCount;
68336
+ if (!budgetWarnedRef.current && renderBudget === renderBudgetSteady && spare < 2) {
68337
+ budgetWarnedRef.current = true;
68338
+ console.warn(`List: renderBudget=${renderBudget} draws ${renderBudget} rows and this scroller shows ${visibleCount} at once: rows will go blank as it scrolls. Give it room for a screen ahead — ${visibleCount * 2} or more.`);
68339
+ }
68340
+ const behind = spare > 0 ? Math.floor(spare / 4) : 0;
68341
+ const ahead = spare > 0 ? spare - behind : 0;
68342
+ const halfScreen = Math.ceil(visibleCount / 2);
68343
+ const aheadNeeded = halfScreen < ahead / 2 ? halfScreen : Math.floor(ahead / 2);
68344
+ const behindNeeded = halfScreen < behind / 2 ? halfScreen : Math.floor(behind / 2);
68345
+ const forward = scrollDirectionRef.current > 0;
68346
+ const rowsAheadOfBand = forward ? end - bandEnd : bandStart - start;
68347
+ const rowsBehindBand = forward ? bandStart - start : end - bandEnd;
68348
+ const atEdgeAhead = forward ? end === total : start === 0;
68349
+ const atEdgeBehind = forward ? start === 0 : end === total;
68350
+ const coversBand = start <= bandStart && end >= bandEnd;
68351
+ const coversAhead = atEdgeAhead || rowsAheadOfBand >= aheadNeeded;
68352
+ const coversBehind = atEdgeBehind || rowsBehindBand >= behindNeeded;
68353
+ if (coversBand && coversAhead && coversBehind) {
68354
+ return;
68355
+ }
68356
+ let newStart = forward ? bandStart - behind : bandEnd + behind - renderBudget;
68357
+ if (newStart < 0) {
68358
+ newStart = 0;
68359
+ }
68360
+ let newEnd = newStart + renderBudget;
68361
+ if (newEnd > total) {
68362
+ newEnd = total;
68363
+ newStart = total - renderBudget < 0 ? 0 : total - renderBudget;
68364
+ }
68365
+ windowSlidRef.current = true;
68366
+ updateRenderWindow(newStart, newEnd, `${reason}: rows ${bandStart}-${bandEnd} on screen, going ${forward ? "forward" : "backward"}`);
68367
+ };
68368
+ // A slide is judged again once its rows are laid out: the scroll had moved
68369
+ // on while the rows were being drawn, or the anchoring of this very commit
68370
+ // moved it, and a window that stops short of the screen's edge with no
68371
+ // scroll event to come would stay short for good. On the next frame, after
68372
+ // the layout this commit's anchoring settles on.
68373
+ useLayoutEffect(() => {
68374
+ if (!windowSlidRef.current) {
68375
+ return undefined;
68376
+ }
68377
+ windowSlidRef.current = false;
68378
+ const frameId = requestAnimationFrame(() => {
68379
+ evaluateWindow("after slide");
68380
+ });
68381
+ return () => {
68382
+ cancelAnimationFrame(frameId);
68383
+ };
68384
+ }, [renderWindow]);
68385
+
68386
+ // Scroll listener — slides the window as the user scrolls.
68387
+ useLayoutEffect(() => {
68388
+ const listContainerEl = ref.current;
68389
+ if (!listContainerEl) {
68390
+ return undefined;
68391
+ }
68392
+ const scrollerEl = getScroller();
68393
+ const onScroll = () => {
68394
+ // Which way the user is going, read before the position is stored: what
68395
+ // the window keeps ahead is ahead of this.
68396
+ const previousScroll = currentScrollRef.current;
68397
+ if (previousScroll) {
68398
+ const delta = horizontal ? scrollerEl.scrollLeft - previousScroll.left : scrollerEl.scrollTop - previousScroll.top;
68399
+ if (delta > 0) {
68400
+ scrollDirectionRef.current = 1;
68401
+ } else if (delta < 0) {
68402
+ scrollDirectionRef.current = -1;
68403
+ }
68283
68404
  }
68284
- updateRenderWindow(newStart, newEnd, reason);
68405
+ updateCurrentScroll();
68406
+ // Where the user is now is where things must be held from now on.
68407
+ anchorRef.current = null;
68408
+ if (scrolledByListRef.current) {
68409
+ // The window stays where it is — the position it would be re-derived
68410
+ // from was chosen to keep the rows still — but where the list is has
68411
+ // genuinely changed, and whoever keeps that position must hear it.
68412
+ scrolledByListRef.current = false;
68413
+ reportPosition();
68414
+ return;
68415
+ }
68416
+ reportPosition();
68417
+ evaluateWindow("scroll");
68285
68418
  };
68286
68419
  // A page-level scroller does not emit "scroll" on the element itself
68287
68420
  // (document.scrollingElement); the document does.
@@ -71273,8 +71406,11 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71273
71406
  * window, which slides as the user scrolls while fillers hold the room of
71274
71407
  * the rows outside it. Rows declared one by one as `<List.Item>` children
71275
71408
  * are all drawn, whatever this says — a list with more than a few dozen rows
71276
- * gives them to a run (see docs/scroll.md, "Many rows"). Below 30 the list
71277
- * warns: a window shorter than a tall screen shows blank fillers.
71409
+ * gives them to a run (see docs/scroll.md, "Many rows"). The window keeps
71410
+ * most of its spare rows ahead of the scroll, and moves once those fall
71411
+ * under half a screen; it has to be larger than what the scroller shows at
71412
+ * once, with room for that lookahead — the list warns below 30, and when a
71413
+ * budget leaves fewer than two rows beyond the screen.
71278
71414
  *
71279
71415
  * `{ initial, after }` for a list drawn in the click that opens it (a popup):
71280
71416
  * `initial` rows in the commit the browser paints first — about what a phone
@@ -85887,7 +86023,7 @@ const css = /* css */`.navi_side_panel {
85887
86023
  * @param {object} props
85888
86024
  * @param {boolean} [props.open] - Controlled open state, forwarded as-is to
85889
86025
  * `Popup`'s own `open`.
85890
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
86026
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
85891
86027
  * state said the way every navi control says it: the panel opens and closes
85892
86028
  * to match the signal, and writes into it whenever it opens or closes on its
85893
86029
  * own (Escape, swipe, a --navi-close command) — one binding to both drive