@jsenv/navi 0.29.322 → 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
  }
@@ -37410,6 +37431,46 @@ const acknowledgeOwnAction = (controller) => {
37410
37431
  controller.acknowledgeUIState();
37411
37432
  };
37412
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
+
37413
37474
  // Default aggregate/distribute implementations keyed by controlType or stateType.
37414
37475
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
37415
37476
  // distributeChildUIState. If neither a default nor an explicit impl is found for a
@@ -37418,7 +37479,7 @@ const GROUP_DEFAULTS = {
37418
37479
  radio_group: {
37419
37480
  childControlFilter: (child) =>
37420
37481
  child.controlType === "input" && child.controlHostProps?.type === "radio",
37421
- aggregateChildStates: firstDefinedChildUIState,
37482
+ aggregateChildStates: keptOrFirstDefinedChildUIState,
37422
37483
  distributeChildUIState: (newUIState, childUIStateController) => {
37423
37484
  const childSelected = childUIStateController.props.value === newUIState;
37424
37485
  if (childSelected) {
@@ -37431,16 +37492,7 @@ const GROUP_DEFAULTS = {
37431
37492
  childControlFilter: (child) =>
37432
37493
  child.controlType === "input" &&
37433
37494
  child.controlHostProps?.type === "checkbox",
37434
- aggregateChildStates: (children) => {
37435
- const values = [];
37436
- for (const child of children) {
37437
- const childUIState = child.uiState;
37438
- if (childUIState !== undefined) {
37439
- values.push(childUIState);
37440
- }
37441
- }
37442
- return values.length === 0 ? undefined : values;
37443
- },
37495
+ aggregateChildStates: keptAndCheckedChildUIStates,
37444
37496
  distributeChildUIState: (newUIState, childUIStateController) => {
37445
37497
  const childSelected =
37446
37498
  Array.isArray(newUIState) &&
@@ -60553,14 +60605,15 @@ const css$E = /* css */`
60553
60605
  * the focus only leaves it for something that asked by name (`autoFocus` on
60554
60606
  * that element, which outranks whatever the dialog says).
60555
60607
  * @param {boolean} [props.open] - Controlled open state.
60556
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
60608
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
60557
60609
  * state said the way every navi control says it: the dialog opens and closes
60558
60610
  * to match the signal, and writes into it whenever it opens or closes on its
60559
60611
  * own (Escape, backdrop, a --navi-close command) — one binding to both drive
60560
60612
  * the dialog and know where it is, and the state stays where the app put it.
60561
60613
  * Excludes `open`; `onOpen`/`onClose` still fire. A signal holding `true` at
60562
60614
  * mount behaves like `defaultOpen`: the dialog was already open, no entrance
60563
- * 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.
60564
60617
  * @param {any} [props.value] - What `signal` holds while THIS dialog is
60565
60618
  * open, for several of them sharing one signal that says which is open
60566
60619
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -62214,14 +62267,15 @@ const css$D = /* css */`
62214
62267
  * the focus only leaves it for something that asked by name (`autoFocus` on
62215
62268
  * that element, which outranks whatever the popover says).
62216
62269
  * @param {boolean} [props.open] - Controlled open state.
62217
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
62270
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
62218
62271
  * state said the way every navi control says it: the popover opens and closes
62219
62272
  * to match the signal, and writes into it whenever it opens or closes on its
62220
62273
  * own (Escape, light dismiss, a --navi-close command) — one binding to both
62221
62274
  * drive the popover and know where it is, and the state stays where the app
62222
62275
  * put it. Excludes `open`; `onOpen`/`onClose` still fire. A signal holding
62223
62276
  * `true` at mount behaves like `defaultOpen`: the popover was already open,
62224
- * 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.
62225
62279
  * @param {any} [props.value] - What `signal` holds while THIS popover is
62226
62280
  * open, for several of them sharing one signal that says which is open
62227
62281
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -65892,45 +65946,6 @@ const ListSelectable = props => {
65892
65946
  focusGroupDirection,
65893
65947
  focusGroupWrap
65894
65948
  } = props;
65895
- // `kept` is what the list holds as it is asked, which is not the same as what
65896
- // its rows say: a list draws the rows it needs and no more, so the selected
65897
- // one may be scrolled out of the window or filtered out of the view, and a
65898
- // row that is not there cannot say it is not selected. Reading it off the
65899
- // group rather than remembering it here is what makes a value put ON the list
65900
- // (a `value` prop, a signal, a reopened popup) replace the whole selection —
65901
- // a private memory of its own would go on holding the rows it could not see
65902
- // being unselected.
65903
- //
65904
- // `fallbackState` is the empty of the shape the list declared below
65905
- // (`stateType`): `[]` for a multiple list, nothing for a single one. Taking
65906
- // it is what lets an emptied list say "empty" — `undefined` is the word for
65907
- // "unset", and a bound stateSignal reads that as "nothing decided here, go
65908
- // back to the default" (see docs/control_value.md), which is how a list
65909
- // emptied down to its last row puts that row back on reload.
65910
- const aggregateChildStates = (children, fallbackState, kept) => {
65911
- if (multiple) {
65912
- const drawnValues = new Set(children.map(child => child.props.value));
65913
- const stillSelected = Array.isArray(kept) ? kept.filter(value => !drawnValues.has(value)) : [];
65914
- for (const child of children) {
65915
- if (child.uiState !== undefined) {
65916
- stillSelected.push(child.uiState);
65917
- }
65918
- }
65919
- return stillSelected.length === 0 ? fallbackState : stillSelected;
65920
- }
65921
- for (const child of children) {
65922
- if (child.uiState !== undefined) {
65923
- return child.uiState;
65924
- }
65925
- }
65926
- // No drawn row claims it. If the row that held it IS drawn, it was really
65927
- // deselected; if it is not, the list keeps what it holds.
65928
- const keptIsDrawn = children.some(child => child.props.value === kept);
65929
- if (keptIsDrawn) {
65930
- return undefined;
65931
- }
65932
- return kept;
65933
- };
65934
65949
  const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps({
65935
65950
  // The defaults of the group this IS (see CheckboxGroup / RadioGroup):
65936
65951
  // what a selectable list shows is a claim about what was accepted, so
@@ -65942,9 +65957,15 @@ const ListSelectable = props => {
65942
65957
  resetOnError: true,
65943
65958
  ...props
65944
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).
65945
65967
  stateType: multiple ? "array" : "",
65946
- controlType: multiple ? "checkbox_group" : "radio_group",
65947
- aggregateChildStates
65968
+ controlType: multiple ? "checkbox_group" : "radio_group"
65948
65969
  });
65949
65970
  const uiGroupStateController = getUIStateControllerById(listControlProps.id);
65950
65971
  useFocusGroup(ref, {
@@ -67146,6 +67167,7 @@ const ListUI = props => {
67146
67167
  ref,
67147
67168
  tracker,
67148
67169
  renderBudget,
67170
+ renderBudgetSteady: renderBudgetAfterPaint,
67149
67171
  virtualItemSize,
67150
67172
  virtual,
67151
67173
  scrolled,
@@ -67473,6 +67495,7 @@ const useListScrollSync = ({
67473
67495
  ref,
67474
67496
  tracker,
67475
67497
  renderBudget,
67498
+ renderBudgetSteady,
67476
67499
  virtualItemSize,
67477
67500
  virtual,
67478
67501
  scrolled,
@@ -68226,31 +68249,36 @@ const useListScrollSync = ({
68226
68249
  }
68227
68250
  });
68228
68251
 
68229
- // Scroll listener — slides the window as the user scrolls.
68230
- useLayoutEffect(() => {
68231
- const listContainerEl = ref.current;
68232
- if (!listContainerEl) {
68233
- 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;
68234
68270
  }
68235
68271
  const scrollerEl = getScroller();
68236
68272
  const listEl = getListEl();
68237
- const onScroll = () => {
68238
- updateCurrentScroll();
68239
- // Where the user is now is where things must be held from now on.
68240
- anchorRef.current = null;
68241
- if (scrolledByListRef.current) {
68242
- // The window stays where it is — the position it would be re-derived
68243
- // from was chosen to keep the rows still — but where the list is has
68244
- // genuinely changed, and whoever keeps that position must hear it.
68245
- scrolledByListRef.current = false;
68246
- reportPosition();
68247
- return;
68248
- }
68249
- reportPosition();
68250
- const total = virtual.totalSignal.peek();
68251
- if (total <= renderBudget) {
68252
- return;
68253
- }
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) {
68254
68282
  const scrollInfo = getScrollInfo({
68255
68283
  scrollValues: {
68256
68284
  left: scrollerEl.scrollLeft,
@@ -68267,17 +68295,8 @@ const useListScrollSync = ({
68267
68295
  return;
68268
68296
  }
68269
68297
  const {
68270
- index,
68271
- reason
68298
+ index
68272
68299
  } = scrollInfo;
68273
- // Recentering on every row crossed would rebuild the whole window a few
68274
- // times a second, and a window rebuilt is every row of it rendered
68275
- // again. It only moves once what is on screen comes near one of its
68276
- // edges — until then, it already holds what has to be drawn.
68277
- const {
68278
- start,
68279
- end
68280
- } = renderWindowRef.current;
68281
68300
  const margin = Math.floor(renderBudget / 4);
68282
68301
  const farFromStart = index - start >= margin || start === 0;
68283
68302
  const farFromEnd = end - index > margin || end === total;
@@ -68285,12 +68304,117 @@ const useListScrollSync = ({
68285
68304
  return;
68286
68305
  }
68287
68306
  const half = Math.floor(renderBudget / 2);
68288
- let newStart = Math.max(0, index - half);
68289
- let newEnd = Math.min(total, newStart + renderBudget);
68290
- if (newEnd === total) {
68291
- 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
+ }
68292
68404
  }
68293
- 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");
68294
68418
  };
68295
68419
  // A page-level scroller does not emit "scroll" on the element itself
68296
68420
  // (document.scrollingElement); the document does.
@@ -71282,8 +71406,11 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
71282
71406
  * window, which slides as the user scrolls while fillers hold the room of
71283
71407
  * the rows outside it. Rows declared one by one as `<List.Item>` children
71284
71408
  * are all drawn, whatever this says — a list with more than a few dozen rows
71285
- * gives them to a run (see docs/scroll.md, "Many rows"). Below 30 the list
71286
- * 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.
71287
71414
  *
71288
71415
  * `{ initial, after }` for a list drawn in the click that opens it (a popup):
71289
71416
  * `initial` rows in the commit the browser paints first — about what a phone
@@ -85896,7 +86023,7 @@ const css = /* css */`.navi_side_panel {
85896
86023
  * @param {object} props
85897
86024
  * @param {boolean} [props.open] - Controlled open state, forwarded as-is to
85898
86025
  * `Popup`'s own `open`.
85899
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
86026
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
85900
86027
  * state said the way every navi control says it: the panel opens and closes
85901
86028
  * to match the signal, and writes into it whenever it opens or closes on its
85902
86029
  * own (Escape, swipe, a --navi-close command) — one binding to both drive