@jsenv/navi 0.29.407 → 0.29.408

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.
@@ -13122,7 +13122,8 @@ const useActionBoundToOneParam = (action, paramsSignal) => {
13122
13122
  }, []);
13123
13123
  return [boundAction, getValue(), setValue];
13124
13124
  };
13125
- // used by <details> to just call their action
13125
+ // The action bound to the signal, without reading it: the caller is not
13126
+ // re-rendered by the params (a control, a <details>).
13126
13127
  const useAction = (action, paramsSignal) => {
13127
13128
  return useBoundAction(action, paramsSignal);
13128
13129
  };
@@ -23009,7 +23010,7 @@ import.meta.css = /* css */[`[data-scrollable] > .navi_loading_outline_wrapper,
23009
23010
  }
23010
23011
  }
23011
23012
 
23012
- [hidden] {
23013
+ [hidden]:not([hidden="until-found" i]) {
23013
23014
  display: none !important;
23014
23015
  }
23015
23016
  `, "@jsenv/navi/src/box/box.jsx"];
@@ -42656,7 +42657,14 @@ const useControlProps = (props, {
42656
42657
  persists,
42657
42658
  uiActionInternal
42658
42659
  });
42659
- const [boundAction] = useActionBoundToOneParam(props.action, uiStateController.uiStateSignal);
42660
+ // What the control draws is what it holds by itself, and this read is what
42661
+ // re-renders it when that changes. A button inheriting the value of the
42662
+ // control around it (a submit in a form, see ownUIStateSignal) draws none of
42663
+ // that value, it only hands it to its action: subscribed to it, every button
42664
+ // of a form would re-render at each change of any of its fields.
42665
+ // eslint-disable-next-line no-unused-expressions
42666
+ uiStateController.ownUIStateSignal.value;
42667
+ const boundAction = useAction(props.action, uiStateController.uiStateSignal);
42660
42668
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
42661
42669
  uiStateController,
42662
42670
  boundAction,
@@ -43423,7 +43431,10 @@ const useControlProps = (props, {
43423
43431
  };
43424
43432
  }
43425
43433
  }
43426
- const uiState = uiStateController.uiStateSignal.peek();
43434
+
43435
+ // Its own, like syncDomState: a button's DOM value is not the value it
43436
+ // inherits from the control around it.
43437
+ const uiState = uiStateController.ownUIStateSignal.peek();
43427
43438
  const domProps = toDomProps(uiState);
43428
43439
  {
43429
43440
  // Same as syncDomState: a field being typed into keeps its own text, so a
@@ -43972,7 +43983,9 @@ const useInteractiveProps = (props, {
43972
43983
  // stop waiting on a run that is still going. What it waits on is the run
43973
43984
  // it started, from navi_action_start to its settlement.
43974
43985
  const runningAction = uiStateController.runningActionSignal.value;
43975
- const actionStatus = useActionStatus(runningAction || boundAction);
43986
+ // Only whether it runs: the whole status (useActionStatus) reads the params
43987
+ // too, which for a button inheriting its form's value are the form.
43988
+ const actionRunning = (runningAction || boundAction).runningStateSignal.value === RUNNING;
43976
43989
  const networkPolicyReason = useNetworkPolicyReason();
43977
43990
  const {
43978
43991
  disabled,
@@ -44020,7 +44033,7 @@ const useInteractiveProps = (props, {
44020
44033
  // just set stays visible and interactive while the action runs — no
44021
44034
  // loading, no readonly. On failure resetOnError rolls the state back and
44022
44035
  // the error callout says why.
44023
- const actionLoading = optimistic ? false : actionStatus.loading;
44036
+ const actionLoading = optimistic ? false : actionRunning;
44024
44037
  const loadingResolved = loadingBase || actionLoading;
44025
44038
  const readOnlyResolved = readOnlyBase || actionLoading;
44026
44039
  // Read-only, and what this control opens still opens: reading what is in
@@ -44215,7 +44228,11 @@ const useInteractiveProps = (props, {
44215
44228
  errorEffect: actionErrorEffect,
44216
44229
  errorMapping
44217
44230
  });
44218
- const dataAction = action === undefined ? undefined : boundAction.callSource;
44231
+ // What the attribute says is that an action is there (see
44232
+ // findClosestControlWithAction); its text is a label. Peeked (toString),
44233
+ // because `.callSource` follows the params — for a button inheriting its
44234
+ // form's value, a re-render at every change of any field.
44235
+ const dataAction = action === undefined ? undefined : String(boundAction);
44219
44236
  Object.assign(controlHostProps, {
44220
44237
  "data-action": dataAction
44221
44238
  });
@@ -53143,30 +53160,9 @@ const ButtonUI = props => {
53143
53160
  } = buttonControlHostProps;
53144
53161
  const loading = basePseudoState[":-navi-loading"];
53145
53162
 
53146
- // subscribe to document url to re-render and re-compute getHrefTargetInfo
53147
- useDocumentUrl();
53148
- const isLink = href !== undefined;
53149
- let as = "button";
53150
- let innerTarget;
53151
- let innerRel;
53152
- let innerCurrent;
53153
- if (isLink) {
53154
- as = "a";
53155
- const {
53156
- isSameSite,
53157
- isCurrent
53158
- } = getHrefTargetInfo(href);
53159
- innerTarget = target === undefined ? isSameSite ? undefined : "_blank" : target;
53160
- innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
53161
- innerCurrent = isCurrent;
53162
- }
53163
- // For a button that has only an href: nothing else knows it points at the
53164
- // page one is on. A route says so through `pseudoState`, which Box lays over
53165
- // this one.
53166
- buttonControlHostProps.basePseudoState = {
53167
- ...basePseudoState,
53168
- ":-navi-href-current": innerCurrent
53169
- };
53163
+ // Where the href leads next to the page one is on (the target, the rel, the
53164
+ // current state) is resolved above, by ButtonHrefResolver.
53165
+ const as = href === undefined ? "button" : "a";
53170
53166
 
53171
53167
  // Worn as an attribute, like a link's (see link_replace.js): read off the
53172
53168
  // anchor by the click handler, off the source by --navi-nav-to.
@@ -53215,8 +53211,8 @@ const ButtonUI = props => {
53215
53211
  ref: ref,
53216
53212
  as: as,
53217
53213
  href: href,
53218
- target: innerTarget,
53219
- rel: innerRel,
53214
+ target: target,
53215
+ rel: rel,
53220
53216
  replace: undefined,
53221
53217
  ...replaceRequest,
53222
53218
  document: undefined,
@@ -53516,6 +53512,49 @@ const ButtonConfirm = ({
53516
53512
  });
53517
53513
  };
53518
53514
 
53515
+ const ButtonHrefResolver = props => {
53516
+ const Next = useNextResolver();
53517
+ if (props.href !== undefined) {
53518
+ return renderResolver(ButtonWithHref, props);
53519
+ }
53520
+ return jsx(Next, {
53521
+ ...props
53522
+ });
53523
+ };
53524
+
53525
+ // What the href means next to the page one is on depends on the document url,
53526
+ // so a button with an href re-renders at every address write. One without
53527
+ // never reaches here: it has nothing to re-compute, and every button on screen
53528
+ // would otherwise pay for each write.
53529
+ const ButtonWithHref = props => {
53530
+ const Next = useNextResolver();
53531
+ const {
53532
+ href,
53533
+ target,
53534
+ rel,
53535
+ pseudoState
53536
+ } = props;
53537
+ // Read for the subscription: getHrefTargetInfo reads window.location.
53538
+ useDocumentUrl();
53539
+ const {
53540
+ isSameSite,
53541
+ isCurrent
53542
+ } = getHrefTargetInfo(href);
53543
+ return jsx(Next, {
53544
+ ...props,
53545
+ target: target === undefined ? isSameSite ? undefined : "_blank" : target,
53546
+ rel: rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel
53547
+ // Under whatever the caller already says: a route knows better whether
53548
+ // it is the page one is on (see button_route.jsx), and a demo can force
53549
+ // the state.
53550
+ ,
53551
+ pseudoState: {
53552
+ ":-navi-href-current": isCurrent,
53553
+ ...pseudoState
53554
+ }
53555
+ });
53556
+ };
53557
+
53519
53558
  const ButtonRouteResolver = props => {
53520
53559
  const Next = useNextResolver();
53521
53560
  if (props.route) {
@@ -53773,7 +53812,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
53773
53812
  * (it wears it itself) and loses the shrink under the finger, which has
53774
53813
  * nothing left to scale but the interactive area itself.
53775
53814
  */
53776
- const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonUI]);
53815
+ const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonHrefResolver, ButtonUI]);
53777
53816
 
53778
53817
  installImportMetaCssBuild(import.meta);
53779
53818
  const css$O = /* css */`.navi_control_swap {
@@ -71269,12 +71308,28 @@ const css$x = /* css */`@layer navi {
71269
71308
  display: inline-block;
71270
71309
  }
71271
71310
 
71311
+ .navi_list_find_stand_in {
71312
+ height: calc(var(--x-find-line-count) * var(--x-find-line-size));
71313
+ color: #0000;
71314
+ line-height: var(--x-find-line-size);
71315
+ white-space: pre;
71316
+ overflow: clip;
71317
+ }
71318
+
71272
71319
  .navi_list_container[data-horizontal] {
71273
71320
  --list-max-height: none;
71274
71321
 
71275
71322
  & > .navi_list_scroll_container > .navi_list > .navi_list_virtual_filler {
71276
71323
  width: var(--size-to-fill, 0px);
71277
71324
  height: 100%;
71325
+ display: flex;
71326
+
71327
+ & > .navi_list_find_stand_in {
71328
+ width: calc(var(--x-find-line-count) * var(--x-find-line-size));
71329
+ writing-mode: vertical-lr;
71330
+ flex-shrink: 0;
71331
+ height: auto;
71332
+ }
71278
71333
  }
71279
71334
  }
71280
71335
 
@@ -73871,10 +73926,12 @@ const Fallback = ({
73871
73926
  // settled after the first commit.
73872
73927
  const VirtualFiller = ({
73873
73928
  edge,
73874
- itemCount
73929
+ itemCount,
73930
+ findChunks
73875
73931
  }) => {
73876
73932
  const listRows = useContext(ListRowsContext);
73877
- const sizeToFill = itemCount * listRows.virtualItemSizeSignal.value;
73933
+ const virtualItemSize = listRows.virtualItemSizeSignal.value;
73934
+ const sizeToFill = itemCount * virtualItemSize;
73878
73935
  if (!sizeToFill) {
73879
73936
  return null;
73880
73937
  }
@@ -73885,8 +73942,17 @@ const VirtualFiller = ({
73885
73942
  "navi-virtual-filler": edge,
73886
73943
  "aria-hidden": true,
73887
73944
  style: {
73888
- "--size-to-fill": `${sizeToFill}px`
73889
- }
73945
+ "--size-to-fill": `${sizeToFill}px`,
73946
+ "--x-find-line-size": findChunks ? `${virtualItemSize}px` : undefined
73947
+ },
73948
+ children: findChunks && findChunks.map(chunk => jsx("div", {
73949
+ className: "navi_list_find_stand_in",
73950
+ hidden: "until-found",
73951
+ style: {
73952
+ "--x-find-line-count": chunk.to - chunk.from
73953
+ },
73954
+ children: chunk.text
73955
+ }, `${chunk.from}_${chunk.to}`))
73890
73956
  });
73891
73957
  };
73892
73958
 
@@ -74522,6 +74588,7 @@ const VISIBILITY_HIDDEN_STYLE = {
74522
74588
  *
74523
74589
  * @type {import("ignore:preact").FunctionComponent<{
74524
74590
  * renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
74591
+ * findText?: (item: any, index: number) => string,
74525
74592
  * items?: any[],
74526
74593
  * itemsAction?: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
74527
74594
  * count?: number,
@@ -74540,6 +74607,13 @@ const VISIBILITY_HIDDEN_STYLE = {
74540
74607
  * the run. `state.refreshing` says the rows drawn are the ones from before
74541
74608
  * while the run reads the collection again — the list carries
74542
74609
  * `navi-refreshing` for the same reason.
74610
+ * @param {(item: any, index: number) => string} [props.findText]
74611
+ * The text of a row as the browser's find in page (Cmd/Ctrl + F) sees it
74612
+ * while the row is not drawn — without it, find reaches only the rows in the
74613
+ * render window. The rows held off screen then carry that text where they
74614
+ * stand, hidden until the browser finds it there: it scrolls to the place,
74615
+ * and the list draws the row. One line per row. A row the run does not hold
74616
+ * yet has nothing to be found by.
74543
74617
  * @param {any[]} [props.items]
74544
74618
  * The collection, when it is held in memory: all of it, in order. Nothing is
74545
74619
  * ever asked for — `itemsAction`, `count`, `pageSize` and `memoryBudget` have
@@ -74636,12 +74710,19 @@ const createStoreHoldingNothing = rowCount => {
74636
74710
  };
74637
74711
  };
74638
74712
 
74713
+ // How many rows share one element of the text a filler carries for find in
74714
+ // page (see findText). One element per row would put back the DOM nodes the
74715
+ // window saves; one per filler would be revealed whole — every row of it laid
74716
+ // out — by the first match found in it.
74717
+ const FIND_CHUNK_ROW_COUNT = 64;
74718
+
74639
74719
  // What a run draws: the rows its window frames, a skeleton for each one the
74640
74720
  // store does not hold, and fillers holding the room of the rows outside it.
74641
74721
  // Which rows are held, and fetching the others, is the store's (see
74642
74722
  // useItemStore).
74643
74723
  const useRunRows = (store, {
74644
74724
  renderItem,
74725
+ findText,
74645
74726
  pageSize,
74646
74727
  groupBy,
74647
74728
  renderGroupLabel,
@@ -74849,6 +74930,37 @@ const useRunRows = (store, {
74849
74930
  }
74850
74931
  group.children.push(rowNode);
74851
74932
  };
74933
+ // The text of the rows a filler stands for, cut in chunks aligned on the
74934
+ // run's own ranks: the window sliding changes the chunk at its edge and
74935
+ // leaves the others as they are.
74936
+ const getFindChunks = (from, to) => {
74937
+ if (!findText) {
74938
+ return null;
74939
+ }
74940
+ const chunks = [];
74941
+ let chunkFrom = from;
74942
+ while (chunkFrom < to) {
74943
+ const chunkIndex = Math.floor(rankOf(chunkFrom) / FIND_CHUNK_ROW_COUNT);
74944
+ const alignedTo = rowOf((chunkIndex + 1) * FIND_CHUNK_ROW_COUNT);
74945
+ const chunkTo = alignedTo < to ? alignedTo : to;
74946
+ const lines = [];
74947
+ let lineRowIndex = chunkFrom;
74948
+ while (lineRowIndex < chunkTo) {
74949
+ const item = getItemAt(lineRowIndex);
74950
+ // A row not held has nothing to find, but keeps its line: the rows
74951
+ // after it stay at their place.
74952
+ lines.push(item === undefined ? "" : toFindLine(findText(item, lineRowIndex)));
74953
+ lineRowIndex++;
74954
+ }
74955
+ chunks.push({
74956
+ from: chunkFrom,
74957
+ to: chunkTo,
74958
+ text: lines.join("\n")
74959
+ });
74960
+ chunkFrom = chunkTo;
74961
+ }
74962
+ return chunks;
74963
+ };
74852
74964
  // The room held for this run's own rows that the window leaves out. It
74853
74965
  // belongs to the run and not to the list: a list is not necessarily made of
74854
74966
  // one run, and what sits before or after it (a header, rows given one by
@@ -74856,7 +74968,8 @@ const useRunRows = (store, {
74856
74968
  if (windowFrom > runStart) {
74857
74969
  rows.push(jsx(VirtualFiller, {
74858
74970
  edge: "before",
74859
- itemCount: windowFrom - runStart
74971
+ itemCount: windowFrom - runStart,
74972
+ findChunks: getFindChunks(runStart, windowFrom)
74860
74973
  }, "navi-list-filler-before"));
74861
74974
  }
74862
74975
  const renderItemState = {
@@ -74958,11 +75071,20 @@ const useRunRows = (store, {
74958
75071
  if (runEnd > windowTo) {
74959
75072
  rows.push(jsx(VirtualFiller, {
74960
75073
  edge: "after",
74961
- itemCount: runEnd - windowTo
75074
+ itemCount: runEnd - windowTo,
75075
+ findChunks: getFindChunks(windowTo, runEnd)
74962
75076
  }, "navi-list-filler-after"));
74963
75077
  }
74964
75078
  return rows;
74965
75079
  };
75080
+ // One row, one line: a line break inside it would push every row below off
75081
+ // its place.
75082
+ const toFindLine = text => {
75083
+ if (text === undefined || text === null) {
75084
+ return "";
75085
+ }
75086
+ return String(text).replace(/[\r\n]+/g, " ");
75087
+ };
74966
75088
 
74967
75089
  // A run's row that has not arrived, standing where the real one will. It never
74968
75090
  // reaches ListItemUI (see ListItemSkeletonResolver), so it is drawn among the
@@ -82988,6 +83110,7 @@ const useWheelInteractions = ({
82988
83110
  attemptInteraction,
82989
83111
  cancelAnim,
82990
83112
  setPos,
83113
+ releaseScrub,
82991
83114
  snapPosToRow,
82992
83115
  getItemSize,
82993
83116
  viewportMain,
@@ -83396,6 +83519,8 @@ const useWheelInteractions = ({
83396
83519
  clearTimeout(settleTimer);
83397
83520
  stopClaimingGesture();
83398
83521
  cancelAnim();
83522
+ // Nothing is left to settle the scrub in progress.
83523
+ releaseScrub();
83399
83524
  document.removeEventListener("pointerdown", onDocumentPointerDown, {
83400
83525
  capture: true
83401
83526
  });
@@ -83561,11 +83686,11 @@ function WheelUI(props) {
83561
83686
  ...props,
83562
83687
  ref: inputRef,
83563
83688
  type: "navi_js",
83564
- // uiAction fires live (every center-crossing, via setPos live reports);
83565
- // the committed action must fire only when the wheel stabilizes. "custom"
83566
- // stops setUIState from auto-firing the action on every value change, so
83567
- // the live reports are uiAction-only; commitSelection then dispatches the
83568
- // action explicitly on settle. A caller can still override actionEvent.
83689
+ // An arrow or a tap changes the value at once while the row glides into
83690
+ // place, and the action must wait for the wheel to come to rest. "custom"
83691
+ // stops setUIState from auto-firing the action on every value change;
83692
+ // commitSelection dispatches it explicitly on settle. A caller can still
83693
+ // override actionEvent.
83569
83694
  actionEvent: props.actionEvent ?? "custom"
83570
83695
  }, {
83571
83696
  controlType: "input"
@@ -83661,6 +83786,11 @@ function WheelUI(props) {
83661
83786
  // while our own scroll-driven selection does not scroll a second time. Holds the
83662
83787
  // TARGET so rapid inputs step from it and accumulate. null = nothing pending.
83663
83788
  const centeredIndexRef = useRef(null);
83789
+ // A user scrub moved the wheel onto a row that is not the value yet: set at
83790
+ // the first row it crosses, cleared when it settles (commitSelection), when
83791
+ // an arrow or a tap sets a value at once (glideToIndex), or when it is
83792
+ // dropped (releaseScrub). See setPos.
83793
+ const scrubHoldsRowRef = useRef(false);
83664
83794
  // The wheel does NOT use native scroll. `posRef` is our own virtual scroll
83665
83795
  // position (px, main axis); the list track is translated by -pos. This is the
83666
83796
  // single source of truth, wrapped modulo the real-list extent in loop mode, so
@@ -83710,8 +83840,22 @@ function WheelUI(props) {
83710
83840
  ...style
83711
83841
  };
83712
83842
 
83713
- // Main-axis size of the viewport, from the orientation.
83714
- const viewportMain = vp => isHorizontal ? vp.clientWidth : vp.clientHeight;
83843
+ // Main-axis size of the viewport, from the orientation. Cached like the row
83844
+ // size: applyOffset runs right after each window commit, where reading the
83845
+ // box would force a layout at every row crossing. The observer below keeps it
83846
+ // current; 0 (no box yet) is never kept, so the next read measures again.
83847
+ const viewportSizeRef = useRef(0);
83848
+ const measureViewportMain = vp => isHorizontal ? vp.clientWidth : vp.clientHeight;
83849
+ const viewportMain = vp => {
83850
+ if (viewportSizeRef.current > 0) {
83851
+ return viewportSizeRef.current;
83852
+ }
83853
+ const size = measureViewportMain(vp);
83854
+ if (size > 0) {
83855
+ viewportSizeRef.current = size;
83856
+ }
83857
+ return size;
83858
+ };
83715
83859
 
83716
83860
  // Row size (px). Rows are uniform, so one measurement (cached) drives all the
83717
83861
  // geometry — positions are then computed from value indices, not read off the
@@ -83782,6 +83926,21 @@ function WheelUI(props) {
83782
83926
  applyOffset(vp);
83783
83927
  }
83784
83928
  };
83929
+ // Refreshes the cached viewport size (see viewportMain). Read in the observer
83930
+ // callback, where the layout is already clean. A new size moves the center
83931
+ // window, so the offset is re-applied with it.
83932
+ useLayoutEffect(() => {
83933
+ const vp = getViewport();
83934
+ viewportSizeRef.current = 0;
83935
+ const resizeObserver = new ResizeObserver(() => {
83936
+ viewportSizeRef.current = measureViewportMain(vp);
83937
+ applyOffset(vp);
83938
+ });
83939
+ resizeObserver.observe(vp);
83940
+ return () => {
83941
+ resizeObserver.disconnect();
83942
+ };
83943
+ }, [isHorizontal]);
83785
83944
 
83786
83945
  // The value index at the center for the current position.
83787
83946
  const centeredIndex = vp => {
@@ -83820,12 +83979,14 @@ function WheelUI(props) {
83820
83979
  posRef.current = clampNumber(posRef.current, 0, (count - 1) * size);
83821
83980
  };
83822
83981
 
83823
- // `live` (user scrubs: drag, momentum, wheel scroll) reports the value as each
83824
- // item crosses the center, so uiAction tracks the wheel in real time — like
83825
- // every other control. The committed action still fires only once it settles
83826
- // (commitSelection). A programmatic move (centerOnIndex on an external value
83827
- // sync) omits it, so scrolling the wheel into place doesn't echo the value
83828
- // back out.
83982
+ // `live` marks a user scrub (drag, momentum, wheel scroll). A scrub moves the
83983
+ // drawing, not the value: the row it reaches is held by the wheel, and only
83984
+ // commitSelection turns it into the value, once the motion settles. Reported
83985
+ // at each crossing, every notch would be a value change for all that is
83986
+ // bound to the wheel — its group, its form, a signal, the address, which
83987
+ // records where a value was put down, not the path the finger took
83988
+ // (navigation.md, "A value written per frame"). A programmatic move
83989
+ // (centerOnIndex) omits `live`: it places the value the wheel already has.
83829
83990
  const setPos = (vp, pos, {
83830
83991
  live = false
83831
83992
  } = {}) => {
@@ -83836,18 +83997,25 @@ function WheelUI(props) {
83836
83997
  renderPos(vp);
83837
83998
  if (live && interactive) {
83838
83999
  const index = centeredIndex(vp);
83839
- if (index !== centeredIndexRef.current) {
83840
- const item = trackedItemsRef.current[index];
83841
- if (item) {
83842
- // Keep centeredIndexRef in step so the every-render
83843
- // syncCenterToSelection doesn't read this as an external change and
83844
- // fight the in-progress motion.
83845
- centeredIndexRef.current = index;
83846
- requestSelectValue(item.value, new CustomEvent("navi_wheel_scrub"));
83847
- }
84000
+ if (index !== centeredIndexRef.current && trackedItemsRef.current[index]) {
84001
+ // In step with the scrub so the next arrow or tap steps from the row
84002
+ // the wheel is on (see currentTargetIndex).
84003
+ centeredIndexRef.current = index;
84004
+ scrubHoldsRowRef.current = true;
83848
84005
  }
83849
84006
  }
83850
84007
  };
84008
+ // The scrub was dropped before it settled (the wheel stopped being
84009
+ // interactive, or its input bindings were torn down): the row it held never
84010
+ // became the value. Forgetting it lets the next sync put the wheel back on
84011
+ // the value.
84012
+ const releaseScrub = () => {
84013
+ if (!scrubHoldsRowRef.current) {
84014
+ return;
84015
+ }
84016
+ scrubHoldsRowRef.current = false;
84017
+ centeredIndexRef.current = null;
84018
+ };
83851
84019
 
83852
84020
  // Returns whether it actually interrupted a motion in flight — lets the caller
83853
84021
  // tell a fresh gesture from one that "caught" a spinning/gliding wheel.
@@ -83883,6 +84051,7 @@ function WheelUI(props) {
83883
84051
  renderPos(vp);
83884
84052
  }
83885
84053
  centeredIndexRef.current = index;
84054
+ scrubHoldsRowRef.current = false;
83886
84055
  if (!interactive || glideFromOutsideRef.current) {
83887
84056
  return;
83888
84057
  }
@@ -84087,6 +84256,7 @@ function WheelUI(props) {
84087
84256
  // inputs accumulate.
84088
84257
  const glideToIndex = (vp, index, event) => {
84089
84258
  centeredIndexRef.current = index;
84259
+ scrubHoldsRowRef.current = false;
84090
84260
  requestSelectValue(trackedItemsRef.current[index].value, event);
84091
84261
  glideTo(vp, glideTargetFor(vp, index));
84092
84262
  };
@@ -84117,12 +84287,12 @@ function WheelUI(props) {
84117
84287
  // Sync the center with the current value — used on first display and whenever
84118
84288
  // the controlled value changes from outside.
84119
84289
  const syncCenterToSelection = (viewportEl, behavior) => {
84120
- // A fling in flight reports its own value at every row crossing and commits
84121
- // on settle: the finger that threw it owns the wheel until then, and a value
84122
- // arriving meanwhile is re-stated by the next crossing anyway. A glide is
84123
- // different: its target is re-aimed below (glideTo never restarts the
84124
- // loop), so a value set from outside mid-glide is where the wheel ends up.
84125
- if (momentumRef.current !== null) {
84290
+ // A fling in flight, or a scrub holding a row, commits on settle: the finger
84291
+ // owns the wheel until then, and the row it leaves the wheel on overrules a
84292
+ // value arriving meanwhile. A glide is different: its target is re-aimed
84293
+ // below (glideTo never restarts the loop), so a value set from outside
84294
+ // mid-glide is where the wheel ends up.
84295
+ if (momentumRef.current !== null || scrubHoldsRowRef.current) {
84126
84296
  return;
84127
84297
  }
84128
84298
  if (trackedItemsRef.current.length === 0) {
@@ -84205,6 +84375,7 @@ function WheelUI(props) {
84205
84375
  attemptInteraction,
84206
84376
  cancelAnim,
84207
84377
  setPos,
84378
+ releaseScrub,
84208
84379
  snapPosToRow,
84209
84380
  getItemSize,
84210
84381
  viewportMain,