@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.
@@ -12639,7 +12639,8 @@ const useActionBoundToOneParam = (action, paramsSignal) => {
12639
12639
  }, []);
12640
12640
  return [boundAction, getValue(), setValue];
12641
12641
  };
12642
- // used by <details> to just call their action
12642
+ // The action bound to the signal, without reading it: the caller is not
12643
+ // re-rendered by the params (a control, a <details>).
12643
12644
  const useAction = (action, paramsSignal) => {
12644
12645
  return useBoundAction(action, paramsSignal);
12645
12646
  };
@@ -22377,7 +22378,7 @@ import.meta.css = /* css */[`[data-scrollable] > .navi_loading_outline_wrapper,
22377
22378
  }
22378
22379
  }
22379
22380
 
22380
- [hidden] {
22381
+ [hidden]:not([hidden="until-found" i]) {
22381
22382
  display: none !important;
22382
22383
  }
22383
22384
  `, "@jsenv/navi/src/box/box.jsx"];
@@ -41540,7 +41541,14 @@ const useControlProps = (props, {
41540
41541
  persists,
41541
41542
  uiActionInternal
41542
41543
  });
41543
- const [boundAction] = useActionBoundToOneParam(props.action, uiStateController.uiStateSignal);
41544
+ // What the control draws is what it holds by itself, and this read is what
41545
+ // re-renders it when that changes. A button inheriting the value of the
41546
+ // control around it (a submit in a form, see ownUIStateSignal) draws none of
41547
+ // that value, it only hands it to its action: subscribed to it, every button
41548
+ // of a form would re-render at each change of any of its fields.
41549
+ // eslint-disable-next-line no-unused-expressions
41550
+ uiStateController.ownUIStateSignal.value;
41551
+ const boundAction = useAction(props.action, uiStateController.uiStateSignal);
41544
41552
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
41545
41553
  uiStateController,
41546
41554
  boundAction,
@@ -42305,7 +42313,10 @@ const useControlProps = (props, {
42305
42313
  };
42306
42314
  }
42307
42315
  }
42308
- const uiState = uiStateController.uiStateSignal.peek();
42316
+
42317
+ // Its own, like syncDomState: a button's DOM value is not the value it
42318
+ // inherits from the control around it.
42319
+ const uiState = uiStateController.ownUIStateSignal.peek();
42309
42320
  const domProps = toDomProps(uiState);
42310
42321
  {
42311
42322
  // Same as syncDomState: a field being typed into keeps its own text, so a
@@ -42833,7 +42844,9 @@ const useInteractiveProps = (props, {
42833
42844
  // stop waiting on a run that is still going. What it waits on is the run
42834
42845
  // it started, from navi_action_start to its settlement.
42835
42846
  const runningAction = uiStateController.runningActionSignal.value;
42836
- const actionStatus = useActionStatus(runningAction || boundAction);
42847
+ // Only whether it runs: the whole status (useActionStatus) reads the params
42848
+ // too, which for a button inheriting its form's value are the form.
42849
+ const actionRunning = (runningAction || boundAction).runningStateSignal.value === RUNNING;
42837
42850
  const networkPolicyReason = useNetworkPolicyReason();
42838
42851
  const {
42839
42852
  disabled,
@@ -42881,7 +42894,7 @@ const useInteractiveProps = (props, {
42881
42894
  // just set stays visible and interactive while the action runs — no
42882
42895
  // loading, no readonly. On failure resetOnError rolls the state back and
42883
42896
  // the error callout says why.
42884
- const actionLoading = optimistic ? false : actionStatus.loading;
42897
+ const actionLoading = optimistic ? false : actionRunning;
42885
42898
  const loadingResolved = loadingBase || actionLoading;
42886
42899
  const readOnlyResolved = readOnlyBase || actionLoading;
42887
42900
  // Read-only, and what this control opens still opens: reading what is in
@@ -43076,7 +43089,11 @@ const useInteractiveProps = (props, {
43076
43089
  errorEffect: actionErrorEffect,
43077
43090
  errorMapping
43078
43091
  });
43079
- const dataAction = action === undefined ? undefined : boundAction.callSource;
43092
+ // What the attribute says is that an action is there (see
43093
+ // findClosestControlWithAction); its text is a label. Peeked (toString),
43094
+ // because `.callSource` follows the params — for a button inheriting its
43095
+ // form's value, a re-render at every change of any field.
43096
+ const dataAction = action === undefined ? undefined : String(boundAction);
43080
43097
  Object.assign(controlHostProps, {
43081
43098
  "data-action": dataAction
43082
43099
  });
@@ -51993,30 +52010,9 @@ const ButtonUI = props => {
51993
52010
  } = buttonControlHostProps;
51994
52011
  const loading = basePseudoState[":-navi-loading"];
51995
52012
 
51996
- // subscribe to document url to re-render and re-compute getHrefTargetInfo
51997
- useDocumentUrl();
51998
- const isLink = href !== undefined;
51999
- let as = "button";
52000
- let innerTarget;
52001
- let innerRel;
52002
- let innerCurrent;
52003
- if (isLink) {
52004
- as = "a";
52005
- const {
52006
- isSameSite,
52007
- isCurrent
52008
- } = getHrefTargetInfo(href);
52009
- innerTarget = target === undefined ? isSameSite ? undefined : "_blank" : target;
52010
- innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
52011
- innerCurrent = isCurrent;
52012
- }
52013
- // For a button that has only an href: nothing else knows it points at the
52014
- // page one is on. A route says so through `pseudoState`, which Box lays over
52015
- // this one.
52016
- buttonControlHostProps.basePseudoState = {
52017
- ...basePseudoState,
52018
- ":-navi-href-current": innerCurrent
52019
- };
52013
+ // Where the href leads next to the page one is on (the target, the rel, the
52014
+ // current state) is resolved above, by ButtonHrefResolver.
52015
+ const as = href === undefined ? "button" : "a";
52020
52016
 
52021
52017
  // Worn as an attribute, like a link's (see link_replace.js): read off the
52022
52018
  // anchor by the click handler, off the source by --navi-nav-to.
@@ -52065,8 +52061,8 @@ const ButtonUI = props => {
52065
52061
  ref: ref,
52066
52062
  as: as,
52067
52063
  href: href,
52068
- target: innerTarget,
52069
- rel: innerRel,
52064
+ target: target,
52065
+ rel: rel,
52070
52066
  replace: undefined,
52071
52067
  ...replaceRequest,
52072
52068
  document: undefined,
@@ -52366,6 +52362,49 @@ const ButtonConfirm = ({
52366
52362
  });
52367
52363
  };
52368
52364
 
52365
+ const ButtonHrefResolver = props => {
52366
+ const Next = useNextResolver();
52367
+ if (props.href !== undefined) {
52368
+ return renderResolver(ButtonWithHref, props);
52369
+ }
52370
+ return jsx(Next, {
52371
+ ...props
52372
+ });
52373
+ };
52374
+
52375
+ // What the href means next to the page one is on depends on the document url,
52376
+ // so a button with an href re-renders at every address write. One without
52377
+ // never reaches here: it has nothing to re-compute, and every button on screen
52378
+ // would otherwise pay for each write.
52379
+ const ButtonWithHref = props => {
52380
+ const Next = useNextResolver();
52381
+ const {
52382
+ href,
52383
+ target,
52384
+ rel,
52385
+ pseudoState
52386
+ } = props;
52387
+ // Read for the subscription: getHrefTargetInfo reads window.location.
52388
+ useDocumentUrl();
52389
+ const {
52390
+ isSameSite,
52391
+ isCurrent
52392
+ } = getHrefTargetInfo(href);
52393
+ return jsx(Next, {
52394
+ ...props,
52395
+ target: target === undefined ? isSameSite ? undefined : "_blank" : target,
52396
+ rel: rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel
52397
+ // Under whatever the caller already says: a route knows better whether
52398
+ // it is the page one is on (see button_route.jsx), and a demo can force
52399
+ // the state.
52400
+ ,
52401
+ pseudoState: {
52402
+ ":-navi-href-current": isCurrent,
52403
+ ...pseudoState
52404
+ }
52405
+ });
52406
+ };
52407
+
52369
52408
  const ButtonRouteResolver = props => {
52370
52409
  const Next = useNextResolver();
52371
52410
  if (props.route) {
@@ -52620,7 +52659,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
52620
52659
  * (it wears it itself) and loses the shrink under the finger, which has
52621
52660
  * nothing left to scale but the interactive area itself.
52622
52661
  */
52623
- const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonUI]);
52662
+ const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonHrefResolver, ButtonUI]);
52624
52663
 
52625
52664
  installImportMetaCssBuild(import.meta);
52626
52665
  const css$O = /* css */`.navi_control_swap {
@@ -69896,12 +69935,28 @@ const css$x = /* css */`@layer navi {
69896
69935
  display: inline-block;
69897
69936
  }
69898
69937
 
69938
+ .navi_list_find_stand_in {
69939
+ height: calc(var(--x-find-line-count) * var(--x-find-line-size));
69940
+ color: #0000;
69941
+ line-height: var(--x-find-line-size);
69942
+ white-space: pre;
69943
+ overflow: clip;
69944
+ }
69945
+
69899
69946
  .navi_list_container[data-horizontal] {
69900
69947
  --list-max-height: none;
69901
69948
 
69902
69949
  & > .navi_list_scroll_container > .navi_list > .navi_list_virtual_filler {
69903
69950
  width: var(--size-to-fill, 0px);
69904
69951
  height: 100%;
69952
+ display: flex;
69953
+
69954
+ & > .navi_list_find_stand_in {
69955
+ width: calc(var(--x-find-line-count) * var(--x-find-line-size));
69956
+ writing-mode: vertical-lr;
69957
+ flex-shrink: 0;
69958
+ height: auto;
69959
+ }
69905
69960
  }
69906
69961
  }
69907
69962
 
@@ -72399,10 +72454,12 @@ const Fallback = ({
72399
72454
  // settled after the first commit.
72400
72455
  const VirtualFiller = ({
72401
72456
  edge,
72402
- itemCount
72457
+ itemCount,
72458
+ findChunks
72403
72459
  }) => {
72404
72460
  const listRows = useContext(ListRowsContext);
72405
- const sizeToFill = itemCount * listRows.virtualItemSizeSignal.value;
72461
+ const virtualItemSize = listRows.virtualItemSizeSignal.value;
72462
+ const sizeToFill = itemCount * virtualItemSize;
72406
72463
  if (!sizeToFill) {
72407
72464
  return null;
72408
72465
  }
@@ -72413,8 +72470,17 @@ const VirtualFiller = ({
72413
72470
  "navi-virtual-filler": edge,
72414
72471
  "aria-hidden": true,
72415
72472
  style: {
72416
- "--size-to-fill": `${sizeToFill}px`
72417
- }
72473
+ "--size-to-fill": `${sizeToFill}px`,
72474
+ "--x-find-line-size": findChunks ? `${virtualItemSize}px` : undefined
72475
+ },
72476
+ children: findChunks && findChunks.map(chunk => jsx("div", {
72477
+ className: "navi_list_find_stand_in",
72478
+ hidden: "until-found",
72479
+ style: {
72480
+ "--x-find-line-count": chunk.to - chunk.from
72481
+ },
72482
+ children: chunk.text
72483
+ }, `${chunk.from}_${chunk.to}`))
72418
72484
  });
72419
72485
  };
72420
72486
 
@@ -73047,6 +73113,7 @@ const VISIBILITY_HIDDEN_STYLE = {
73047
73113
  *
73048
73114
  * @type {import("ignore:preact").FunctionComponent<{
73049
73115
  * renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
73116
+ * findText?: (item: any, index: number) => string,
73050
73117
  * items?: any[],
73051
73118
  * itemsAction?: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
73052
73119
  * count?: number,
@@ -73065,6 +73132,13 @@ const VISIBILITY_HIDDEN_STYLE = {
73065
73132
  * the run. `state.refreshing` says the rows drawn are the ones from before
73066
73133
  * while the run reads the collection again — the list carries
73067
73134
  * `navi-refreshing` for the same reason.
73135
+ * @param {(item: any, index: number) => string} [props.findText]
73136
+ * The text of a row as the browser's find in page (Cmd/Ctrl + F) sees it
73137
+ * while the row is not drawn — without it, find reaches only the rows in the
73138
+ * render window. The rows held off screen then carry that text where they
73139
+ * stand, hidden until the browser finds it there: it scrolls to the place,
73140
+ * and the list draws the row. One line per row. A row the run does not hold
73141
+ * yet has nothing to be found by.
73068
73142
  * @param {any[]} [props.items]
73069
73143
  * The collection, when it is held in memory: all of it, in order. Nothing is
73070
73144
  * ever asked for — `itemsAction`, `count`, `pageSize` and `memoryBudget` have
@@ -73161,12 +73235,19 @@ const createStoreHoldingNothing = rowCount => {
73161
73235
  };
73162
73236
  };
73163
73237
 
73238
+ // How many rows share one element of the text a filler carries for find in
73239
+ // page (see findText). One element per row would put back the DOM nodes the
73240
+ // window saves; one per filler would be revealed whole — every row of it laid
73241
+ // out — by the first match found in it.
73242
+ const FIND_CHUNK_ROW_COUNT = 64;
73243
+
73164
73244
  // What a run draws: the rows its window frames, a skeleton for each one the
73165
73245
  // store does not hold, and fillers holding the room of the rows outside it.
73166
73246
  // Which rows are held, and fetching the others, is the store's (see
73167
73247
  // useItemStore).
73168
73248
  const useRunRows = (store, {
73169
73249
  renderItem,
73250
+ findText,
73170
73251
  pageSize,
73171
73252
  groupBy,
73172
73253
  renderGroupLabel,
@@ -73374,6 +73455,37 @@ const useRunRows = (store, {
73374
73455
  }
73375
73456
  group.children.push(rowNode);
73376
73457
  };
73458
+ // The text of the rows a filler stands for, cut in chunks aligned on the
73459
+ // run's own ranks: the window sliding changes the chunk at its edge and
73460
+ // leaves the others as they are.
73461
+ const getFindChunks = (from, to) => {
73462
+ if (!findText) {
73463
+ return null;
73464
+ }
73465
+ const chunks = [];
73466
+ let chunkFrom = from;
73467
+ while (chunkFrom < to) {
73468
+ const chunkIndex = Math.floor(rankOf(chunkFrom) / FIND_CHUNK_ROW_COUNT);
73469
+ const alignedTo = rowOf((chunkIndex + 1) * FIND_CHUNK_ROW_COUNT);
73470
+ const chunkTo = alignedTo < to ? alignedTo : to;
73471
+ const lines = [];
73472
+ let lineRowIndex = chunkFrom;
73473
+ while (lineRowIndex < chunkTo) {
73474
+ const item = getItemAt(lineRowIndex);
73475
+ // A row not held has nothing to find, but keeps its line: the rows
73476
+ // after it stay at their place.
73477
+ lines.push(item === undefined ? "" : toFindLine(findText(item, lineRowIndex)));
73478
+ lineRowIndex++;
73479
+ }
73480
+ chunks.push({
73481
+ from: chunkFrom,
73482
+ to: chunkTo,
73483
+ text: lines.join("\n")
73484
+ });
73485
+ chunkFrom = chunkTo;
73486
+ }
73487
+ return chunks;
73488
+ };
73377
73489
  // The room held for this run's own rows that the window leaves out. It
73378
73490
  // belongs to the run and not to the list: a list is not necessarily made of
73379
73491
  // one run, and what sits before or after it (a header, rows given one by
@@ -73381,7 +73493,8 @@ const useRunRows = (store, {
73381
73493
  if (windowFrom > runStart) {
73382
73494
  rows.push(jsx(VirtualFiller, {
73383
73495
  edge: "before",
73384
- itemCount: windowFrom - runStart
73496
+ itemCount: windowFrom - runStart,
73497
+ findChunks: getFindChunks(runStart, windowFrom)
73385
73498
  }, "navi-list-filler-before"));
73386
73499
  }
73387
73500
  const renderItemState = {
@@ -73483,11 +73596,20 @@ const useRunRows = (store, {
73483
73596
  if (runEnd > windowTo) {
73484
73597
  rows.push(jsx(VirtualFiller, {
73485
73598
  edge: "after",
73486
- itemCount: runEnd - windowTo
73599
+ itemCount: runEnd - windowTo,
73600
+ findChunks: getFindChunks(windowTo, runEnd)
73487
73601
  }, "navi-list-filler-after"));
73488
73602
  }
73489
73603
  return rows;
73490
73604
  };
73605
+ // One row, one line: a line break inside it would push every row below off
73606
+ // its place.
73607
+ const toFindLine = text => {
73608
+ if (text === undefined || text === null) {
73609
+ return "";
73610
+ }
73611
+ return String(text).replace(/[\r\n]+/g, " ");
73612
+ };
73491
73613
 
73492
73614
  // A run's row that has not arrived, standing where the real one will. It never
73493
73615
  // reaches ListItemUI (see ListItemSkeletonResolver), so it is drawn among the
@@ -80867,6 +80989,7 @@ const useWheelInteractions = ({
80867
80989
  attemptInteraction,
80868
80990
  cancelAnim,
80869
80991
  setPos,
80992
+ releaseScrub,
80870
80993
  snapPosToRow,
80871
80994
  getItemSize,
80872
80995
  viewportMain,
@@ -81274,6 +81397,8 @@ const useWheelInteractions = ({
81274
81397
  clearTimeout(settleTimer);
81275
81398
  stopClaimingGesture();
81276
81399
  cancelAnim();
81400
+ // Nothing is left to settle the scrub in progress.
81401
+ releaseScrub();
81277
81402
  document.removeEventListener("pointerdown", onDocumentPointerDown, {
81278
81403
  capture: true
81279
81404
  });
@@ -81439,11 +81564,11 @@ function WheelUI(props) {
81439
81564
  ...props,
81440
81565
  ref: inputRef,
81441
81566
  type: "navi_js",
81442
- // uiAction fires live (every center-crossing, via setPos live reports);
81443
- // the committed action must fire only when the wheel stabilizes. "custom"
81444
- // stops setUIState from auto-firing the action on every value change, so
81445
- // the live reports are uiAction-only; commitSelection then dispatches the
81446
- // action explicitly on settle. A caller can still override actionEvent.
81567
+ // An arrow or a tap changes the value at once while the row glides into
81568
+ // place, and the action must wait for the wheel to come to rest. "custom"
81569
+ // stops setUIState from auto-firing the action on every value change;
81570
+ // commitSelection dispatches it explicitly on settle. A caller can still
81571
+ // override actionEvent.
81447
81572
  actionEvent: props.actionEvent ?? "custom"
81448
81573
  }, {
81449
81574
  controlType: "input"
@@ -81539,6 +81664,11 @@ function WheelUI(props) {
81539
81664
  // while our own scroll-driven selection does not scroll a second time. Holds the
81540
81665
  // TARGET so rapid inputs step from it and accumulate. null = nothing pending.
81541
81666
  const centeredIndexRef = useRef(null);
81667
+ // A user scrub moved the wheel onto a row that is not the value yet: set at
81668
+ // the first row it crosses, cleared when it settles (commitSelection), when
81669
+ // an arrow or a tap sets a value at once (glideToIndex), or when it is
81670
+ // dropped (releaseScrub). See setPos.
81671
+ const scrubHoldsRowRef = useRef(false);
81542
81672
  // The wheel does NOT use native scroll. `posRef` is our own virtual scroll
81543
81673
  // position (px, main axis); the list track is translated by -pos. This is the
81544
81674
  // single source of truth, wrapped modulo the real-list extent in loop mode, so
@@ -81588,8 +81718,22 @@ function WheelUI(props) {
81588
81718
  ...style
81589
81719
  };
81590
81720
 
81591
- // Main-axis size of the viewport, from the orientation.
81592
- const viewportMain = vp => isHorizontal ? vp.clientWidth : vp.clientHeight;
81721
+ // Main-axis size of the viewport, from the orientation. Cached like the row
81722
+ // size: applyOffset runs right after each window commit, where reading the
81723
+ // box would force a layout at every row crossing. The observer below keeps it
81724
+ // current; 0 (no box yet) is never kept, so the next read measures again.
81725
+ const viewportSizeRef = useRef(0);
81726
+ const measureViewportMain = vp => isHorizontal ? vp.clientWidth : vp.clientHeight;
81727
+ const viewportMain = vp => {
81728
+ if (viewportSizeRef.current > 0) {
81729
+ return viewportSizeRef.current;
81730
+ }
81731
+ const size = measureViewportMain(vp);
81732
+ if (size > 0) {
81733
+ viewportSizeRef.current = size;
81734
+ }
81735
+ return size;
81736
+ };
81593
81737
 
81594
81738
  // Row size (px). Rows are uniform, so one measurement (cached) drives all the
81595
81739
  // geometry — positions are then computed from value indices, not read off the
@@ -81660,6 +81804,21 @@ function WheelUI(props) {
81660
81804
  applyOffset(vp);
81661
81805
  }
81662
81806
  };
81807
+ // Refreshes the cached viewport size (see viewportMain). Read in the observer
81808
+ // callback, where the layout is already clean. A new size moves the center
81809
+ // window, so the offset is re-applied with it.
81810
+ useLayoutEffect(() => {
81811
+ const vp = getViewport();
81812
+ viewportSizeRef.current = 0;
81813
+ const resizeObserver = new ResizeObserver(() => {
81814
+ viewportSizeRef.current = measureViewportMain(vp);
81815
+ applyOffset(vp);
81816
+ });
81817
+ resizeObserver.observe(vp);
81818
+ return () => {
81819
+ resizeObserver.disconnect();
81820
+ };
81821
+ }, [isHorizontal]);
81663
81822
 
81664
81823
  // The value index at the center for the current position.
81665
81824
  const centeredIndex = vp => {
@@ -81698,12 +81857,14 @@ function WheelUI(props) {
81698
81857
  posRef.current = clampNumber(posRef.current, 0, (count - 1) * size);
81699
81858
  };
81700
81859
 
81701
- // `live` (user scrubs: drag, momentum, wheel scroll) reports the value as each
81702
- // item crosses the center, so uiAction tracks the wheel in real time — like
81703
- // every other control. The committed action still fires only once it settles
81704
- // (commitSelection). A programmatic move (centerOnIndex on an external value
81705
- // sync) omits it, so scrolling the wheel into place doesn't echo the value
81706
- // back out.
81860
+ // `live` marks a user scrub (drag, momentum, wheel scroll). A scrub moves the
81861
+ // drawing, not the value: the row it reaches is held by the wheel, and only
81862
+ // commitSelection turns it into the value, once the motion settles. Reported
81863
+ // at each crossing, every notch would be a value change for all that is
81864
+ // bound to the wheel — its group, its form, a signal, the address, which
81865
+ // records where a value was put down, not the path the finger took
81866
+ // (navigation.md, "A value written per frame"). A programmatic move
81867
+ // (centerOnIndex) omits `live`: it places the value the wheel already has.
81707
81868
  const setPos = (vp, pos, {
81708
81869
  live = false
81709
81870
  } = {}) => {
@@ -81714,18 +81875,25 @@ function WheelUI(props) {
81714
81875
  renderPos(vp);
81715
81876
  if (live && interactive) {
81716
81877
  const index = centeredIndex(vp);
81717
- if (index !== centeredIndexRef.current) {
81718
- const item = trackedItemsRef.current[index];
81719
- if (item) {
81720
- // Keep centeredIndexRef in step so the every-render
81721
- // syncCenterToSelection doesn't read this as an external change and
81722
- // fight the in-progress motion.
81723
- centeredIndexRef.current = index;
81724
- requestSelectValue(item.value, new CustomEvent("navi_wheel_scrub"));
81725
- }
81878
+ if (index !== centeredIndexRef.current && trackedItemsRef.current[index]) {
81879
+ // In step with the scrub so the next arrow or tap steps from the row
81880
+ // the wheel is on (see currentTargetIndex).
81881
+ centeredIndexRef.current = index;
81882
+ scrubHoldsRowRef.current = true;
81726
81883
  }
81727
81884
  }
81728
81885
  };
81886
+ // The scrub was dropped before it settled (the wheel stopped being
81887
+ // interactive, or its input bindings were torn down): the row it held never
81888
+ // became the value. Forgetting it lets the next sync put the wheel back on
81889
+ // the value.
81890
+ const releaseScrub = () => {
81891
+ if (!scrubHoldsRowRef.current) {
81892
+ return;
81893
+ }
81894
+ scrubHoldsRowRef.current = false;
81895
+ centeredIndexRef.current = null;
81896
+ };
81729
81897
 
81730
81898
  // Returns whether it actually interrupted a motion in flight — lets the caller
81731
81899
  // tell a fresh gesture from one that "caught" a spinning/gliding wheel.
@@ -81761,6 +81929,7 @@ function WheelUI(props) {
81761
81929
  renderPos(vp);
81762
81930
  }
81763
81931
  centeredIndexRef.current = index;
81932
+ scrubHoldsRowRef.current = false;
81764
81933
  if (!interactive || glideFromOutsideRef.current) {
81765
81934
  return;
81766
81935
  }
@@ -81958,6 +82127,7 @@ function WheelUI(props) {
81958
82127
  // inputs accumulate.
81959
82128
  const glideToIndex = (vp, index, event) => {
81960
82129
  centeredIndexRef.current = index;
82130
+ scrubHoldsRowRef.current = false;
81961
82131
  requestSelectValue(trackedItemsRef.current[index].value, event);
81962
82132
  glideTo(vp, glideTargetFor(vp, index));
81963
82133
  };
@@ -81988,12 +82158,12 @@ function WheelUI(props) {
81988
82158
  // Sync the center with the current value — used on first display and whenever
81989
82159
  // the controlled value changes from outside.
81990
82160
  const syncCenterToSelection = (viewportEl, behavior) => {
81991
- // A fling in flight reports its own value at every row crossing and commits
81992
- // on settle: the finger that threw it owns the wheel until then, and a value
81993
- // arriving meanwhile is re-stated by the next crossing anyway. A glide is
81994
- // different: its target is re-aimed below (glideTo never restarts the
81995
- // loop), so a value set from outside mid-glide is where the wheel ends up.
81996
- if (momentumRef.current !== null) {
82161
+ // A fling in flight, or a scrub holding a row, commits on settle: the finger
82162
+ // owns the wheel until then, and the row it leaves the wheel on overrules a
82163
+ // value arriving meanwhile. A glide is different: its target is re-aimed
82164
+ // below (glideTo never restarts the loop), so a value set from outside
82165
+ // mid-glide is where the wheel ends up.
82166
+ if (momentumRef.current !== null || scrubHoldsRowRef.current) {
81997
82167
  return;
81998
82168
  }
81999
82169
  if (trackedItemsRef.current.length === 0) {
@@ -82076,6 +82246,7 @@ function WheelUI(props) {
82076
82246
  attemptInteraction,
82077
82247
  cancelAnim,
82078
82248
  setPos,
82249
+ releaseScrub,
82079
82250
  snapPosToRow,
82080
82251
  getItemSize,
82081
82252
  viewportMain,