@jsenv/navi 0.29.476 → 0.29.478

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.
@@ -6345,11 +6345,6 @@ const CONTROL_PROP_SET = new Set([
6345
6345
  "autoFocusVisible",
6346
6346
  "autoFocusSelect",
6347
6347
 
6348
- "onMouseDown",
6349
- "onClick",
6350
- "onKeyDown",
6351
- "onPaste",
6352
- "onInput",
6353
6348
  "eventReactionDefinitions",
6354
6349
 
6355
6350
  "onCancel",
@@ -11877,7 +11872,7 @@ const putBack = (restoration) => {
11877
11872
  // The page is not whole yet: the last release puts it back.
11878
11873
  return;
11879
11874
  }
11880
- if (documentHoldCount) {
11875
+ if (isDocumentHeld()) {
11881
11876
  pendingRestoration = null;
11882
11877
  return;
11883
11878
  }
@@ -11893,20 +11888,33 @@ const putBack = (restoration) => {
11893
11888
  // height, so the same pixels now fall on other rows. Put back after the list
11894
11889
  // has placed itself, they would undo it, in the very picture a route transition
11895
11890
  // takes. So the list holds the document while it is placing itself, and the
11896
- // url's offset is not put back meanwhile. Counted: two lists can share one
11897
- // document.
11898
- let documentHoldCount = 0;
11899
- const holdDocumentScroll = () => {
11900
- documentHoldCount++;
11901
- let released = false;
11891
+ // url's offset is not put back meanwhile. One hold per list: two lists can
11892
+ // share one document.
11893
+ //
11894
+ // A hold is for the page its list is on, and mounted is not shown: a page kept
11895
+ // while its route transition plays still has its list when the page returned to
11896
+ // renders, and that list says nothing about where the page arriving goes. So a
11897
+ // hold is taken with the list's page (RoutePageContext), asked whether it is
11898
+ // shown when a position is due — `null` outside any route container: that list
11899
+ // is on whatever page is shown. Not a url: which page an address shows is the
11900
+ // routes' to say, and one page can be shown under several (its params moving
11901
+ // under it).
11902
+ const documentHolds = new Set();
11903
+ const holdDocumentScroll = (routePage) => {
11904
+ const hold = { routePage };
11905
+ documentHolds.add(hold);
11902
11906
  return () => {
11903
- if (released) {
11904
- return;
11905
- }
11906
- released = true;
11907
- documentHoldCount--;
11907
+ documentHolds.delete(hold);
11908
11908
  };
11909
11909
  };
11910
+ const isDocumentHeld = () => {
11911
+ for (const { routePage } of documentHolds) {
11912
+ if (routePage === null || routePage.isShown()) {
11913
+ return true;
11914
+ }
11915
+ }
11916
+ return false;
11917
+ };
11910
11918
 
11911
11919
  // What a page holds instead: not the place, which stays the url's, but the
11912
11920
  // moment it is put back (see useHoldScrollRestoration). Counted: several parts
@@ -12164,9 +12172,9 @@ const restoreScrollPosition = (url) => {
12164
12172
  if (!position) {
12165
12173
  return false;
12166
12174
  }
12167
- if (documentHoldCount) {
12168
- // There is a place to go back to, and the list holding the document is
12169
- // putting it back (see holdDocumentScroll).
12175
+ if (isDocumentHeld()) {
12176
+ // There is a place to go back to, and the list holding the document on
12177
+ // that page is putting it back (see holdDocumentScroll).
12170
12178
  return true;
12171
12179
  }
12172
12180
  scrollTo(position);
@@ -45739,6 +45747,9 @@ const ControlgroupChildrenWrapper = ({
45739
45747
  })
45740
45748
  });
45741
45749
 
45750
+ // What the caller writes for these is called from the control's own reactions
45751
+ // (see `reactions` below), so the split leaves them out of both sides.
45752
+ const LISTENERS_CALLED_BY_REACTIONS = new Set(["onMouseDown", "onClick", "onKeyDown", "onPaste", "onInput"]);
45742
45753
  /**
45743
45754
  * Core hook for interactive field components (InputTextual, InputCheckbox, etc.).
45744
45755
  *
@@ -45753,12 +45764,22 @@ const ControlgroupChildrenWrapper = ({
45753
45764
  * All state changes route through DOM events on the field element so that
45754
45765
  * external subscribers (e.g. useControlUIState, Selectable) receive every update.
45755
45766
  *
45767
+ * @param {object} options
45768
+ * @param {"host"|"root"} [options.listenersOn] Where the caller's DOM listeners
45769
+ * (`onPointerDown`, `onFocus`…, never navi's own `onnavi_*`) land. `"host"`,
45770
+ * the default, is right wherever the host is what the person presses and
45771
+ * focuses: the field inside its box, or one element for both. `"root"` is for
45772
+ * a control whose host is a hidden carrier, or whose root also holds what
45773
+ * the control opens, so that a listener hears the control as a whole.
45774
+ * `onMouseDown`, `onClick`, `onKeyDown`, `onPaste` and `onInput` stay the
45775
+ * host's either way: the control's own reactions to them call them.
45756
45776
  * @returns {[controlRootProps, controlHostProps, { uiStateController }]}
45757
45777
  */
45758
45778
  const useControlProps = (props, {
45759
45779
  controlType,
45760
45780
  persists,
45761
- uiActionInternal
45781
+ uiActionInternal,
45782
+ listenersOn = "host"
45762
45783
  }) => {
45763
45784
  const debugUIState = useDebugUIState();
45764
45785
 
@@ -45844,7 +45865,9 @@ const useControlProps = (props, {
45844
45865
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
45845
45866
  uiStateController,
45846
45867
  boundAction,
45847
- controlInfo
45868
+ controlInfo,
45869
+ listenersOn,
45870
+ listenersCalledByReactions: LISTENERS_CALLED_BY_REACTIONS
45848
45871
  });
45849
45872
  {
45850
45873
  const debugInteraction = useDebugInteraction();
@@ -46878,7 +46901,9 @@ const useControlgroupProps = (props, {
46878
46901
  wantRequesterButtonState,
46879
46902
  uiActionInternal,
46880
46903
  allowCapture = false,
46881
- cascadeValidationToChildren = false
46904
+ cascadeValidationToChildren = false,
46905
+ // See useControlProps.
46906
+ listenersOn = "host"
46882
46907
  }) => {
46883
46908
  const {
46884
46909
  action,
@@ -46928,7 +46953,8 @@ const useControlgroupProps = (props, {
46928
46953
  // so we don't have a value concept, nor readonly etc
46929
46954
  controlInfo: {
46930
46955
  controlType
46931
- }
46956
+ },
46957
+ listenersOn
46932
46958
  });
46933
46959
  const {
46934
46960
  basePseudoState
@@ -47067,12 +47093,17 @@ const ControlFacadeChildrenWrapper = ({
47067
47093
  const useInteractiveProps = (props, {
47068
47094
  uiStateController,
47069
47095
  boundAction,
47070
- controlInfo
47096
+ controlInfo,
47097
+ listenersOn,
47098
+ listenersCalledByReactions
47071
47099
  }) => {
47072
47100
  const {
47073
47101
  ref
47074
47102
  } = props;
47075
- const [controlRootProps, controlHostProps] = splitControlProps(props);
47103
+ const [controlRootProps, controlHostProps] = splitControlProps(props, {
47104
+ listenersOn,
47105
+ listenersCalledByReactions
47106
+ });
47076
47107
  controlRootProps["navi-control"] = controlInfo.controlType;
47077
47108
  if (props.selfInteractions) {
47078
47109
  // One attribute, in the DOM, because that is where the claim is read from —
@@ -47449,8 +47480,10 @@ const useInteractiveProps = (props, {
47449
47480
  }
47450
47481
  }
47451
47482
  };
47483
+ const onFocusFromProps = controlHostProps.onFocus;
47452
47484
  Object.assign(controlHostProps, {
47453
47485
  onFocus: e => {
47486
+ onFocusFromProps?.(e);
47454
47487
  // Transfer programmatic focus to the delegate target (navi-focus-delegate or navi-control-proxy-for)
47455
47488
  const focusProxyTarget = findFocusDelegateTarget(e.currentTarget) || findControlProxyTarget(e.currentTarget);
47456
47489
  if (focusProxyTarget) {
@@ -47680,7 +47713,10 @@ const useInteractiveProps = (props, {
47680
47713
  }
47681
47714
  return [controlRootProps, controlHostProps];
47682
47715
  };
47683
- const splitControlProps = props => {
47716
+ const splitControlProps = (props, {
47717
+ listenersOn,
47718
+ listenersCalledByReactions
47719
+ }) => {
47684
47720
  const {
47685
47721
  ref
47686
47722
  } = props;
@@ -47697,10 +47733,20 @@ const splitControlProps = props => {
47697
47733
  if (isControlProp(key)) {
47698
47734
  continue;
47699
47735
  }
47736
+ if (listenersCalledByReactions?.has(key)) {
47737
+ continue;
47738
+ }
47739
+ if (listenersOn === "host" && isDomListenerProp(key)) {
47740
+ controlHostProps[key] = props[key];
47741
+ continue;
47742
+ }
47700
47743
  controlRootProps[key] = props[key];
47701
47744
  }
47702
47745
  return [controlRootProps, controlHostProps];
47703
47746
  };
47747
+ // `onPointerDown`, `onFocusOut`… but not navi's own events (`onnavi_command`),
47748
+ // which keep the element they are dispatched on.
47749
+ const isDomListenerProp = key => /^on[A-Z]/.test(key);
47704
47750
  const isCheckableInput = (controlType, typeProp) => controlType === "input" && (typeProp === "radio" || typeProp === "checkbox");
47705
47751
 
47706
47752
  // The labels the DOM itself can hand over: a wrapping <label>, or a label[for]
@@ -60243,6 +60289,86 @@ const useAutoSelectReadOnly = (props) => {
60243
60289
  return { onFocus, onMouseDown, onPointerDown };
60244
60290
  };
60245
60291
 
60292
+ // selectOnPress: a field whose value is usually replaced rather than edited (an
60293
+ // address, a place to search) behaves like the address bar of a browser. The
60294
+ // first gesture that engages it after it took the focus selects its whole text,
60295
+ // so typing replaces it; a second press places the caret, to fix one part.
60296
+ //
60297
+ // Engaged by a gesture, never by the focus itself: a dialog opening hands the
60298
+ // focus to its first field (docs/autofocus.md), and a field selected on focus
60299
+ // would greet whoever opened the dialog already half-way into an edit. So a
60300
+ // focus that came without a press waits for the first key that types.
60301
+ //
60302
+ // The press selects once it is over, on `click`: the browser places the caret
60303
+ // on mousedown and may collapse a selection on mouseup, so anything selected
60304
+ // before the release is undone by it. `mousedown` decides whether a press
60305
+ // engages because a finger only produces one after a tap — a finger that
60306
+ // scrolled the page never reaches the field. Touch is included on purpose (see
60307
+ // use_autoselect_read_only.js for why a read-only field leaves it out): on a
60308
+ // field meant to be replaced, the native selection handles are expected.
60309
+ const useSelectOnPress = (props, selectOnPress) => {
60310
+ const engagedRef = useRef(false);
60311
+ const pressEngagesRef = useRef(false);
60312
+
60313
+ if (!selectOnPress) {
60314
+ return null;
60315
+ }
60316
+ const onMouseDown = (e) => {
60317
+ props.onMouseDown(e);
60318
+ if (e.defaultPrevented) {
60319
+ return;
60320
+ }
60321
+ if (e.button !== 0) {
60322
+ return;
60323
+ }
60324
+ pressEngagesRef.current = !engagedRef.current;
60325
+ engagedRef.current = true;
60326
+ };
60327
+ const onClick = (e) => {
60328
+ props.onClick(e);
60329
+ if (!pressEngagesRef.current) {
60330
+ return;
60331
+ }
60332
+ pressEngagesRef.current = false;
60333
+ if (e.defaultPrevented) {
60334
+ return;
60335
+ }
60336
+ const input = e.currentTarget;
60337
+ if (input.selectionStart !== input.selectionEnd) {
60338
+ // The press dragged across a part of the text: that part is what was meant.
60339
+ return;
60340
+ }
60341
+ input.select();
60342
+ // Keep the beginning of the text visible instead of scrolling to the end
60343
+ input.scrollLeft = 0;
60344
+ };
60345
+ const onKeyDown = (e) => {
60346
+ if (!engagedRef.current) {
60347
+ const defaultAction = getKeyboardEventDefaultAction(e);
60348
+ if (defaultAction === "type") {
60349
+ engagedRef.current = true;
60350
+ // Before the control's own keydown: maxLengthGuard measures the room
60351
+ // left against the selection the key is about to replace.
60352
+ e.currentTarget.select();
60353
+ } else if (defaultAction === "cursor_move") {
60354
+ engagedRef.current = true;
60355
+ }
60356
+ }
60357
+ props.onKeyDown(e);
60358
+ };
60359
+ const onBlur = (e) => {
60360
+ props.onBlur(e);
60361
+ if (document.activeElement === e.currentTarget) {
60362
+ // The window went to the background and the field still holds the focus:
60363
+ // coming back is not a new arrival.
60364
+ return;
60365
+ }
60366
+ engagedRef.current = false;
60367
+ };
60368
+
60369
+ return { onMouseDown, onClick, onKeyDown, onBlur };
60370
+ };
60371
+
60246
60372
  /**
60247
60373
  * Input component for all textual input types.
60248
60374
  *
@@ -60350,7 +60476,8 @@ const InputTextualUI = props => {
60350
60476
  ui,
60351
60477
  variant,
60352
60478
  backgroundColor,
60353
- width = "maxLength"
60479
+ width = "maxLength",
60480
+ selectOnPress
60354
60481
  } = props;
60355
60482
  const [inputControlRootProps, inputControlHostProps, controlChildrenWrapperProps] = useInputTextualProps(props);
60356
60483
  const {
@@ -60392,6 +60519,8 @@ const InputTextualUI = props => {
60392
60519
  } else {
60393
60520
  inputControlHostProps.width = width;
60394
60521
  }
60522
+ delete inputControlRootProps.selectOnPress;
60523
+ inputControlHostProps.selectOnPress = selectOnPress;
60395
60524
  return jsxs(Box, {
60396
60525
  as: "span",
60397
60526
  inline: true,
@@ -60464,7 +60593,7 @@ const resolveWidthFromMaxLength = (maxLength, inputMode) => {
60464
60593
  // rest — the guards, the slots, the constraints — which describe an edition
60465
60594
  // that does not happen here. What survives is what a Box understands: width,
60466
60595
  // spacing, colors, className, style.
60467
- const INPUT_ONLY_PROPS = ["value", "defaultValue", "signal", "id", "maxLength", "inputMode", "width", "variant", "type", "name", "placeholder", "required", "readOnly", "disabled", "loading", "error", "min", "max", "step", "pattern", "autoComplete", "autoCorrect", "spellcheck", "charGuard", "maxLengthGuard", "list", "suggestions", "headless", "fieldSizing", "action", "uiAction", "ui", "children"];
60596
+ const INPUT_ONLY_PROPS = ["value", "defaultValue", "signal", "id", "maxLength", "inputMode", "width", "variant", "type", "name", "placeholder", "required", "readOnly", "disabled", "loading", "error", "min", "max", "step", "pattern", "autoComplete", "autoCorrect", "spellcheck", "charGuard", "maxLengthGuard", "list", "suggestions", "headless", "fieldSizing", "selectOnPress", "action", "uiAction", "ui", "children"];
60468
60597
  const InputTextualAsText = props => {
60469
60598
  installInputCss();
60470
60599
  // The id a Field handed down, which is what its Label points at.
@@ -60528,14 +60657,20 @@ const InputTextualFirstResolver = props => {
60528
60657
  const InputTextual = /*#__PURE__*/createComponentResolver([InputTextualAsTextResolver, InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
60529
60658
  const RealInput = ({
60530
60659
  maxLength,
60660
+ selectOnPress,
60531
60661
  ...domProps
60532
60662
  }) => {
60533
60663
  const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
60664
+ const selectOnPressProps = useSelectOnPress({
60665
+ ...domProps,
60666
+ ...autoSelectReadOnlyProps
60667
+ }, selectOnPress);
60534
60668
  return jsx(Box, {
60535
60669
  ...domProps,
60536
60670
  as: "input",
60537
60671
  baseClassName: "navi_control_input",
60538
60672
  ...autoSelectReadOnlyProps,
60673
+ ...selectOnPressProps,
60539
60674
  // Never set native maxLength — our guard handles it. Omitting it entirely
60540
60675
  // avoids a Preact quirk: setting maxLength={undefined} on a fresh DOM element
60541
60676
  // (e.g. after a Suspense remount) causes Preact to run `el.maxLength = ""`
@@ -76166,6 +76301,11 @@ const ListItemSelectable = props => {
76166
76301
  ...(hasSelectedProp ? {
76167
76302
  checked: selected
76168
76303
  } : null)
76304
+ },
76305
+ // The host is the visually hidden input carrying the selection: the row
76306
+ // is what the person sees and presses (see onClick above).
76307
+ {
76308
+ listenersOn: "root"
76169
76309
  });
76170
76310
  const {
76171
76311
  checked,
@@ -77911,6 +78051,19 @@ const useListScrollSync = ({
77911
78051
  // eslint-disable-next-line no-unused-expressions
77912
78052
  virtualItemSizeSignal.value;
77913
78053
  }
78054
+ // The hold ending — the user reaching for the list, something else moving
78055
+ // it, the item it opens on never coming — ends its hold on the document with
78056
+ // it (see holdDocumentScroll), rather than at the next render, which nothing
78057
+ // owes: an offset put back on this page meanwhile is the reader's.
78058
+ const releaseDocumentHoldRef = useRef(null);
78059
+ const letGo = () => {
78060
+ startPlaceRef.current.userTookOver = true;
78061
+ const releaseDocumentHold = releaseDocumentHoldRef.current;
78062
+ if (releaseDocumentHold) {
78063
+ releaseDocumentHoldRef.current = null;
78064
+ releaseDocumentHold();
78065
+ }
78066
+ };
77914
78067
  // Set around a scroll the list performs itself. What it protects against is
77915
78068
  // not the scroll event as such, but what the listener would conclude from it:
77916
78069
  // the position it is about to read was chosen to keep the items where they
@@ -78144,7 +78297,7 @@ const useListScrollSync = ({
78144
78297
  }
78145
78298
  openAt = scrolledFallback;
78146
78299
  if (openAt === "start" || openAt === undefined) {
78147
- startPlaceRef.current.userTookOver = true;
78300
+ letGo();
78148
78301
  return;
78149
78302
  }
78150
78303
  } else {
@@ -78296,13 +78449,39 @@ const useListScrollSync = ({
78296
78449
  }, []);
78297
78450
  // Held on an item of a list scrolling the document: where the document goes is
78298
78451
  // this list's to say, measured on the item, not the url's offset in pixels
78299
- // (see holdDocumentScroll).
78452
+ // (see holdDocumentScroll). The hold read again: a layout effect of this
78453
+ // commit may have let go already (see placeWhereHeld).
78454
+ const routePage = useContext(RoutePageContext);
78300
78455
  useLayoutEffect(() => {
78301
- if (!heldSomewhere || getScroller() !== document.scrollingElement) {
78456
+ if (!heldSomewhere || startPlaceRef.current.userTookOver || getScroller() !== document.scrollingElement) {
78302
78457
  return undefined;
78303
78458
  }
78304
- return holdDocumentScroll();
78305
- }, [heldSomewhere, scrollerElResolved]);
78459
+ const releaseDocumentHold = holdDocumentScroll(routePage);
78460
+ releaseDocumentHoldRef.current = releaseDocumentHold;
78461
+ return () => {
78462
+ releaseDocumentHoldRef.current = null;
78463
+ releaseDocumentHold();
78464
+ };
78465
+ }, [heldSomewhere, scrolledWanted, scrollerElResolved]);
78466
+ // A page kept while its route transition plays is hidden until it is taken
78467
+ // down: nothing of the list can be measured there any more, and its scroller
78468
+ // shows the page arriving (see route_page_context.js). So the list lets go of
78469
+ // it, and stops answering its resizes and scrolls: measured hidden, every
78470
+ // item stands at 0×0, and the top item put back where it was would scroll
78471
+ // the page arriving by that item's offset. Kept mounted on purpose, so the
78472
+ // listeners stay; what they would do is what ends.
78473
+ const pageLeftRef = useRef(false);
78474
+ useLayoutEffect(() => {
78475
+ if (!routePage) {
78476
+ return undefined;
78477
+ }
78478
+ return routePage.leavingSignal.subscribe(leaving => {
78479
+ if (leaving) {
78480
+ pageLeftRef.current = true;
78481
+ letGo();
78482
+ }
78483
+ });
78484
+ }, []);
78306
78485
  // What to do when the list's own geometry moves under it, kept fresh for the
78307
78486
  // observer below (which is installed once).
78308
78487
  const onGeometryChangeRef = useRef(null);
@@ -78336,18 +78515,15 @@ const useListScrollSync = ({
78336
78515
  return undefined;
78337
78516
  }
78338
78517
  const scrollerEl = getScroller();
78339
- const takeOver = () => {
78340
- startPlaceRef.current.userTookOver = true;
78341
- };
78342
78518
  const takeOverEvents = ["wheel", "touchstart", "pointerdown", "keydown"];
78343
78519
  for (const type of takeOverEvents) {
78344
- scrollerEl.addEventListener(type, takeOver, {
78520
+ scrollerEl.addEventListener(type, letGo, {
78345
78521
  passive: true
78346
78522
  });
78347
78523
  }
78348
78524
  return () => {
78349
78525
  for (const type of takeOverEvents) {
78350
- scrollerEl.removeEventListener(type, takeOver);
78526
+ scrollerEl.removeEventListener(type, letGo);
78351
78527
  }
78352
78528
  };
78353
78529
  }, [scrolledWanted, scrollerElResolved]);
@@ -78504,6 +78680,9 @@ const useListScrollSync = ({
78504
78680
  }
78505
78681
  };
78506
78682
  const observer = new ResizeObserver(entries => {
78683
+ if (pageLeftRef.current) {
78684
+ return;
78685
+ }
78507
78686
  // A box that grows may be a box that starts to scroll — the scroller is
78508
78687
  // resolved again before anything is done about the resize, so that a
78509
78688
  // list which has outgrown a bounded ancestor stops holding on to the
@@ -78908,6 +79087,9 @@ const useListScrollSync = ({
78908
79087
  }
78909
79088
  const scrollerEl = getScroller();
78910
79089
  const onScroll = () => {
79090
+ if (pageLeftRef.current) {
79091
+ return;
79092
+ }
78911
79093
  // Which way the user is going, read before the position is stored: what
78912
79094
  // the window keeps ahead is ahead of this.
78913
79095
  const previousScroll = currentScrollRef.current;
@@ -78965,7 +79147,7 @@ const useListScrollSync = ({
78965
79147
  const low = way.from < way.to ? way.from : way.to;
78966
79148
  const high = way.from < way.to ? way.to : way.from;
78967
79149
  if (position < low - 1 || position > high + 1) {
78968
- place.userTookOver = true;
79150
+ letGo();
78969
79151
  }
78970
79152
  };
78971
79153
  // A page-level scroller does not emit "scroll" on the element itself
@@ -84909,7 +85091,11 @@ const PickerButton = props => {
84909
85091
  ...props,
84910
85092
  ref: inputRef
84911
85093
  }, {
84912
- controlType: "picker"
85094
+ controlType: "picker",
85095
+ // The root holds what the picker opens as well as its trigger: a
85096
+ // listener on the picker hears what bubbles out of the popup (the focus
85097
+ // leaving it, see picker_custom.jsx).
85098
+ listenersOn: "root"
84913
85099
  });
84914
85100
  const uiStateController = getUIStateControllerById(inputProps.id);
84915
85101
  const value = uiStateController.uiState;
@@ -87446,6 +87632,8 @@ const InputDuration = props => {
87446
87632
  }, {
87447
87633
  controlType: "duration_group",
87448
87634
  cascadeValidationToChildren: true,
87635
+ // The host is an <input type="hidden"> carrying the value.
87636
+ listenersOn: "root",
87449
87637
  // Aggregates sub-input values into an ISO 8601-like duration string.
87450
87638
  // durationToISOString preserves non-numeric mid-edit values
87451
87639
  // (e.g. hours="ab") between their unit markers ("PTabH30M"),
@@ -87512,7 +87700,7 @@ const InputDuration = props => {
87512
87700
  } = groupHostProps;
87513
87701
  const groupRef = useRef();
87514
87702
  useInputGroup(groupRef);
87515
- const clipboardProps = useClipboardProps(groupRef);
87703
+ const clipboardProps = useClipboardProps(groupRef, groupRootProps);
87516
87704
  const hourValue = components?.hours;
87517
87705
  const minuteValue = components?.minutes;
87518
87706
  const rawSecondValue = components?.seconds;
@@ -87585,7 +87773,11 @@ const InputDuration = props => {
87585
87773
  const getVisibleFields = container => {
87586
87774
  return ["hour", "minute", "second", "millisecond"].filter(f => container.querySelector(`[navi-input-type="${f}"]`) !== null);
87587
87775
  };
87588
- const useClipboardProps = groupRef => {
87776
+
87777
+ // Spread after the group's props, so the caller's listeners for these events
87778
+ // are called from here. Whatever they do, the group's own reading runs too, as
87779
+ // it does after a field's paste guard prevented the paste.
87780
+ const useClipboardProps = (groupRef, callerProps) => {
87589
87781
  const getClipboardPayload = () => {
87590
87782
  const groupEl = groupRef.current;
87591
87783
  if (!groupEl) {
@@ -87651,6 +87843,7 @@ const useClipboardProps = groupRef => {
87651
87843
  return true;
87652
87844
  };
87653
87845
  const onCopy = e => {
87846
+ callerProps.onCopy?.(e);
87654
87847
  if (!isFromDurationField(e)) {
87655
87848
  return;
87656
87849
  }
@@ -87663,6 +87856,7 @@ const useClipboardProps = groupRef => {
87663
87856
  e.preventDefault();
87664
87857
  };
87665
87858
  const onCut = e => {
87859
+ callerProps.onCut?.(e);
87666
87860
  if (!isFromDurationField(e)) {
87667
87861
  return;
87668
87862
  }
@@ -87675,6 +87869,7 @@ const useClipboardProps = groupRef => {
87675
87869
  applyToGroup("", e);
87676
87870
  };
87677
87871
  const onPaste = e => {
87872
+ callerProps.onPaste?.(e);
87678
87873
  if (!isFromDurationField(e)) {
87679
87874
  return;
87680
87875
  }
@@ -90240,7 +90435,10 @@ function WheelUI(props) {
90240
90435
  // action explicitly on settle. A caller can still override actionEvent.
90241
90436
  actionEvent: props.actionEvent ?? "custom"
90242
90437
  }, {
90243
- controlType: "input"
90438
+ controlType: "input",
90439
+ // The host is the hidden input carrying the value: the container is
90440
+ // what is focused and spun.
90441
+ listenersOn: "root"
90244
90442
  });
90245
90443
  const uiStateController = getUIStateControllerById(controlHostProps.id);
90246
90444
  const {