@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.
@@ -6566,11 +6566,6 @@ const CONTROL_PROP_SET = new Set([
6566
6566
  "autoFocusVisible",
6567
6567
  "autoFocusSelect",
6568
6568
 
6569
- "onMouseDown",
6570
- "onClick",
6571
- "onKeyDown",
6572
- "onPaste",
6573
- "onInput",
6574
6569
  "eventReactionDefinitions",
6575
6570
 
6576
6571
  "onCancel",
@@ -12305,7 +12300,7 @@ const putBack = (restoration) => {
12305
12300
  // The page is not whole yet: the last release puts it back.
12306
12301
  return;
12307
12302
  }
12308
- if (documentHoldCount) {
12303
+ if (isDocumentHeld()) {
12309
12304
  pendingRestoration = null;
12310
12305
  return;
12311
12306
  }
@@ -12321,20 +12316,33 @@ const putBack = (restoration) => {
12321
12316
  // height, so the same pixels now fall on other rows. Put back after the list
12322
12317
  // has placed itself, they would undo it, in the very picture a route transition
12323
12318
  // takes. So the list holds the document while it is placing itself, and the
12324
- // url's offset is not put back meanwhile. Counted: two lists can share one
12325
- // document.
12326
- let documentHoldCount = 0;
12327
- const holdDocumentScroll = () => {
12328
- documentHoldCount++;
12329
- let released = false;
12319
+ // url's offset is not put back meanwhile. One hold per list: two lists can
12320
+ // share one document.
12321
+ //
12322
+ // A hold is for the page its list is on, and mounted is not shown: a page kept
12323
+ // while its route transition plays still has its list when the page returned to
12324
+ // renders, and that list says nothing about where the page arriving goes. So a
12325
+ // hold is taken with the list's page (RoutePageContext), asked whether it is
12326
+ // shown when a position is due — `null` outside any route container: that list
12327
+ // is on whatever page is shown. Not a url: which page an address shows is the
12328
+ // routes' to say, and one page can be shown under several (its params moving
12329
+ // under it).
12330
+ const documentHolds = new Set();
12331
+ const holdDocumentScroll = (routePage) => {
12332
+ const hold = { routePage };
12333
+ documentHolds.add(hold);
12330
12334
  return () => {
12331
- if (released) {
12332
- return;
12333
- }
12334
- released = true;
12335
- documentHoldCount--;
12335
+ documentHolds.delete(hold);
12336
12336
  };
12337
12337
  };
12338
+ const isDocumentHeld = () => {
12339
+ for (const { routePage } of documentHolds) {
12340
+ if (routePage === null || routePage.isShown()) {
12341
+ return true;
12342
+ }
12343
+ }
12344
+ return false;
12345
+ };
12338
12346
 
12339
12347
  // What a page holds instead: not the place, which stays the url's, but the
12340
12348
  // moment it is put back (see useHoldScrollRestoration). Counted: several parts
@@ -12592,9 +12600,9 @@ const restoreScrollPosition = (url) => {
12592
12600
  if (!position) {
12593
12601
  return false;
12594
12602
  }
12595
- if (documentHoldCount) {
12596
- // There is a place to go back to, and the list holding the document is
12597
- // putting it back (see holdDocumentScroll).
12603
+ if (isDocumentHeld()) {
12604
+ // There is a place to go back to, and the list holding the document on
12605
+ // that page is putting it back (see holdDocumentScroll).
12598
12606
  return true;
12599
12607
  }
12600
12608
  scrollTo(position);
@@ -46903,6 +46911,9 @@ const ControlgroupChildrenWrapper = ({
46903
46911
  })
46904
46912
  });
46905
46913
 
46914
+ // What the caller writes for these is called from the control's own reactions
46915
+ // (see `reactions` below), so the split leaves them out of both sides.
46916
+ const LISTENERS_CALLED_BY_REACTIONS = new Set(["onMouseDown", "onClick", "onKeyDown", "onPaste", "onInput"]);
46906
46917
  /**
46907
46918
  * Core hook for interactive field components (InputTextual, InputCheckbox, etc.).
46908
46919
  *
@@ -46917,12 +46928,22 @@ const ControlgroupChildrenWrapper = ({
46917
46928
  * All state changes route through DOM events on the field element so that
46918
46929
  * external subscribers (e.g. useControlUIState, Selectable) receive every update.
46919
46930
  *
46931
+ * @param {object} options
46932
+ * @param {"host"|"root"} [options.listenersOn] Where the caller's DOM listeners
46933
+ * (`onPointerDown`, `onFocus`…, never navi's own `onnavi_*`) land. `"host"`,
46934
+ * the default, is right wherever the host is what the person presses and
46935
+ * focuses: the field inside its box, or one element for both. `"root"` is for
46936
+ * a control whose host is a hidden carrier, or whose root also holds what
46937
+ * the control opens, so that a listener hears the control as a whole.
46938
+ * `onMouseDown`, `onClick`, `onKeyDown`, `onPaste` and `onInput` stay the
46939
+ * host's either way: the control's own reactions to them call them.
46920
46940
  * @returns {[controlRootProps, controlHostProps, { uiStateController }]}
46921
46941
  */
46922
46942
  const useControlProps = (props, {
46923
46943
  controlType,
46924
46944
  persists,
46925
- uiActionInternal
46945
+ uiActionInternal,
46946
+ listenersOn = "host"
46926
46947
  }) => {
46927
46948
  const debugUIState = useDebugUIState();
46928
46949
  const debugAction = useDebugAction();
@@ -47009,7 +47030,9 @@ const useControlProps = (props, {
47009
47030
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
47010
47031
  uiStateController,
47011
47032
  boundAction,
47012
- controlInfo
47033
+ controlInfo,
47034
+ listenersOn,
47035
+ listenersCalledByReactions: LISTENERS_CALLED_BY_REACTIONS
47013
47036
  });
47014
47037
  {
47015
47038
  const debugInteraction = useDebugInteraction();
@@ -48062,7 +48085,9 @@ const useControlgroupProps = (props, {
48062
48085
  wantRequesterButtonState,
48063
48086
  uiActionInternal,
48064
48087
  allowCapture = false,
48065
- cascadeValidationToChildren = false
48088
+ cascadeValidationToChildren = false,
48089
+ // See useControlProps.
48090
+ listenersOn = "host"
48066
48091
  }) => {
48067
48092
  const {
48068
48093
  action,
@@ -48116,7 +48141,8 @@ const useControlgroupProps = (props, {
48116
48141
  // so we don't have a value concept, nor readonly etc
48117
48142
  controlInfo: {
48118
48143
  controlType
48119
- }
48144
+ },
48145
+ listenersOn
48120
48146
  });
48121
48147
  const {
48122
48148
  basePseudoState
@@ -48255,12 +48281,17 @@ const ControlFacadeChildrenWrapper = ({
48255
48281
  const useInteractiveProps = (props, {
48256
48282
  uiStateController,
48257
48283
  boundAction,
48258
- controlInfo
48284
+ controlInfo,
48285
+ listenersOn,
48286
+ listenersCalledByReactions
48259
48287
  }) => {
48260
48288
  const {
48261
48289
  ref
48262
48290
  } = props;
48263
- const [controlRootProps, controlHostProps] = splitControlProps(props);
48291
+ const [controlRootProps, controlHostProps] = splitControlProps(props, {
48292
+ listenersOn,
48293
+ listenersCalledByReactions
48294
+ });
48264
48295
  controlRootProps["navi-control"] = controlInfo.controlType;
48265
48296
  if (props.selfInteractions) {
48266
48297
  // One attribute, in the DOM, because that is where the claim is read from —
@@ -48637,8 +48668,10 @@ const useInteractiveProps = (props, {
48637
48668
  }
48638
48669
  }
48639
48670
  };
48671
+ const onFocusFromProps = controlHostProps.onFocus;
48640
48672
  Object.assign(controlHostProps, {
48641
48673
  onFocus: e => {
48674
+ onFocusFromProps?.(e);
48642
48675
  // Transfer programmatic focus to the delegate target (navi-focus-delegate or navi-control-proxy-for)
48643
48676
  const focusProxyTarget = findFocusDelegateTarget(e.currentTarget) || findControlProxyTarget(e.currentTarget);
48644
48677
  if (focusProxyTarget) {
@@ -48870,7 +48903,10 @@ const useInteractiveProps = (props, {
48870
48903
  }
48871
48904
  return [controlRootProps, controlHostProps];
48872
48905
  };
48873
- const splitControlProps = props => {
48906
+ const splitControlProps = (props, {
48907
+ listenersOn,
48908
+ listenersCalledByReactions
48909
+ }) => {
48874
48910
  const {
48875
48911
  ref
48876
48912
  } = props;
@@ -48887,10 +48923,20 @@ const splitControlProps = props => {
48887
48923
  if (isControlProp(key)) {
48888
48924
  continue;
48889
48925
  }
48926
+ if (listenersCalledByReactions?.has(key)) {
48927
+ continue;
48928
+ }
48929
+ if (listenersOn === "host" && isDomListenerProp(key)) {
48930
+ controlHostProps[key] = props[key];
48931
+ continue;
48932
+ }
48890
48933
  controlRootProps[key] = props[key];
48891
48934
  }
48892
48935
  return [controlRootProps, controlHostProps];
48893
48936
  };
48937
+ // `onPointerDown`, `onFocusOut`… but not navi's own events (`onnavi_command`),
48938
+ // which keep the element they are dispatched on.
48939
+ const isDomListenerProp = key => /^on[A-Z]/.test(key);
48894
48940
  const isCheckableInput = (controlType, typeProp) => controlType === "input" && (typeProp === "radio" || typeProp === "checkbox");
48895
48941
 
48896
48942
  // The labels the DOM itself can hand over: a wrapping <label>, or a label[for]
@@ -61469,6 +61515,86 @@ const useAutoSelectReadOnly = (props) => {
61469
61515
  return { onFocus, onMouseDown, onPointerDown };
61470
61516
  };
61471
61517
 
61518
+ // selectOnPress: a field whose value is usually replaced rather than edited (an
61519
+ // address, a place to search) behaves like the address bar of a browser. The
61520
+ // first gesture that engages it after it took the focus selects its whole text,
61521
+ // so typing replaces it; a second press places the caret, to fix one part.
61522
+ //
61523
+ // Engaged by a gesture, never by the focus itself: a dialog opening hands the
61524
+ // focus to its first field (docs/autofocus.md), and a field selected on focus
61525
+ // would greet whoever opened the dialog already half-way into an edit. So a
61526
+ // focus that came without a press waits for the first key that types.
61527
+ //
61528
+ // The press selects once it is over, on `click`: the browser places the caret
61529
+ // on mousedown and may collapse a selection on mouseup, so anything selected
61530
+ // before the release is undone by it. `mousedown` decides whether a press
61531
+ // engages because a finger only produces one after a tap — a finger that
61532
+ // scrolled the page never reaches the field. Touch is included on purpose (see
61533
+ // use_autoselect_read_only.js for why a read-only field leaves it out): on a
61534
+ // field meant to be replaced, the native selection handles are expected.
61535
+ const useSelectOnPress = (props, selectOnPress) => {
61536
+ const engagedRef = useRef(false);
61537
+ const pressEngagesRef = useRef(false);
61538
+
61539
+ if (!selectOnPress) {
61540
+ return null;
61541
+ }
61542
+ const onMouseDown = (e) => {
61543
+ props.onMouseDown(e);
61544
+ if (e.defaultPrevented) {
61545
+ return;
61546
+ }
61547
+ if (e.button !== 0) {
61548
+ return;
61549
+ }
61550
+ pressEngagesRef.current = !engagedRef.current;
61551
+ engagedRef.current = true;
61552
+ };
61553
+ const onClick = (e) => {
61554
+ props.onClick(e);
61555
+ if (!pressEngagesRef.current) {
61556
+ return;
61557
+ }
61558
+ pressEngagesRef.current = false;
61559
+ if (e.defaultPrevented) {
61560
+ return;
61561
+ }
61562
+ const input = e.currentTarget;
61563
+ if (input.selectionStart !== input.selectionEnd) {
61564
+ // The press dragged across a part of the text: that part is what was meant.
61565
+ return;
61566
+ }
61567
+ input.select();
61568
+ // Keep the beginning of the text visible instead of scrolling to the end
61569
+ input.scrollLeft = 0;
61570
+ };
61571
+ const onKeyDown = (e) => {
61572
+ if (!engagedRef.current) {
61573
+ const defaultAction = getKeyboardEventDefaultAction(e);
61574
+ if (defaultAction === "type") {
61575
+ engagedRef.current = true;
61576
+ // Before the control's own keydown: maxLengthGuard measures the room
61577
+ // left against the selection the key is about to replace.
61578
+ e.currentTarget.select();
61579
+ } else if (defaultAction === "cursor_move") {
61580
+ engagedRef.current = true;
61581
+ }
61582
+ }
61583
+ props.onKeyDown(e);
61584
+ };
61585
+ const onBlur = (e) => {
61586
+ props.onBlur(e);
61587
+ if (document.activeElement === e.currentTarget) {
61588
+ // The window went to the background and the field still holds the focus:
61589
+ // coming back is not a new arrival.
61590
+ return;
61591
+ }
61592
+ engagedRef.current = false;
61593
+ };
61594
+
61595
+ return { onMouseDown, onClick, onKeyDown, onBlur };
61596
+ };
61597
+
61472
61598
  /**
61473
61599
  * Input component for all textual input types.
61474
61600
  *
@@ -61576,7 +61702,8 @@ const InputTextualUI = props => {
61576
61702
  ui,
61577
61703
  variant,
61578
61704
  backgroundColor,
61579
- width = "maxLength"
61705
+ width = "maxLength",
61706
+ selectOnPress
61580
61707
  } = props;
61581
61708
  const [inputControlRootProps, inputControlHostProps, controlChildrenWrapperProps] = useInputTextualProps(props);
61582
61709
  const {
@@ -61618,6 +61745,8 @@ const InputTextualUI = props => {
61618
61745
  } else {
61619
61746
  inputControlHostProps.width = width;
61620
61747
  }
61748
+ delete inputControlRootProps.selectOnPress;
61749
+ inputControlHostProps.selectOnPress = selectOnPress;
61621
61750
  return jsxs(Box, {
61622
61751
  as: "span",
61623
61752
  inline: true,
@@ -61690,7 +61819,7 @@ const resolveWidthFromMaxLength = (maxLength, inputMode) => {
61690
61819
  // rest — the guards, the slots, the constraints — which describe an edition
61691
61820
  // that does not happen here. What survives is what a Box understands: width,
61692
61821
  // spacing, colors, className, style.
61693
- 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"];
61822
+ 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"];
61694
61823
  const InputTextualAsText = props => {
61695
61824
  installInputCss();
61696
61825
  // The id a Field handed down, which is what its Label points at.
@@ -61754,14 +61883,20 @@ const InputTextualFirstResolver = props => {
61754
61883
  const InputTextual = /*#__PURE__*/createComponentResolver([InputTextualAsTextResolver, InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
61755
61884
  const RealInput = ({
61756
61885
  maxLength,
61886
+ selectOnPress,
61757
61887
  ...domProps
61758
61888
  }) => {
61759
61889
  const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
61890
+ const selectOnPressProps = useSelectOnPress({
61891
+ ...domProps,
61892
+ ...autoSelectReadOnlyProps
61893
+ }, selectOnPress);
61760
61894
  return jsx(Box, {
61761
61895
  ...domProps,
61762
61896
  as: "input",
61763
61897
  baseClassName: "navi_control_input",
61764
61898
  ...autoSelectReadOnlyProps,
61899
+ ...selectOnPressProps,
61765
61900
  // Never set native maxLength — our guard handles it. Omitting it entirely
61766
61901
  // avoids a Preact quirk: setting maxLength={undefined} on a fresh DOM element
61767
61902
  // (e.g. after a Suspense remount) causes Preact to run `el.maxLength = ""`
@@ -77592,6 +77727,11 @@ const ListItemSelectable = props => {
77592
77727
  ...(hasSelectedProp ? {
77593
77728
  checked: selected
77594
77729
  } : null)
77730
+ },
77731
+ // The host is the visually hidden input carrying the selection: the row
77732
+ // is what the person sees and presses (see onClick above).
77733
+ {
77734
+ listenersOn: "root"
77595
77735
  });
77596
77736
  const {
77597
77737
  checked,
@@ -79388,6 +79528,19 @@ const useListScrollSync = ({
79388
79528
  // eslint-disable-next-line no-unused-expressions
79389
79529
  virtualItemSizeSignal.value;
79390
79530
  }
79531
+ // The hold ending — the user reaching for the list, something else moving
79532
+ // it, the item it opens on never coming — ends its hold on the document with
79533
+ // it (see holdDocumentScroll), rather than at the next render, which nothing
79534
+ // owes: an offset put back on this page meanwhile is the reader's.
79535
+ const releaseDocumentHoldRef = useRef(null);
79536
+ const letGo = () => {
79537
+ startPlaceRef.current.userTookOver = true;
79538
+ const releaseDocumentHold = releaseDocumentHoldRef.current;
79539
+ if (releaseDocumentHold) {
79540
+ releaseDocumentHoldRef.current = null;
79541
+ releaseDocumentHold();
79542
+ }
79543
+ };
79391
79544
  // Set around a scroll the list performs itself. What it protects against is
79392
79545
  // not the scroll event as such, but what the listener would conclude from it:
79393
79546
  // the position it is about to read was chosen to keep the items where they
@@ -79622,7 +79775,7 @@ const useListScrollSync = ({
79622
79775
  }
79623
79776
  openAt = scrolledFallback;
79624
79777
  if (openAt === "start" || openAt === undefined) {
79625
- startPlaceRef.current.userTookOver = true;
79778
+ letGo();
79626
79779
  return;
79627
79780
  }
79628
79781
  } else {
@@ -79774,13 +79927,39 @@ const useListScrollSync = ({
79774
79927
  }, []);
79775
79928
  // Held on an item of a list scrolling the document: where the document goes is
79776
79929
  // this list's to say, measured on the item, not the url's offset in pixels
79777
- // (see holdDocumentScroll).
79930
+ // (see holdDocumentScroll). The hold read again: a layout effect of this
79931
+ // commit may have let go already (see placeWhereHeld).
79932
+ const routePage = useContext(RoutePageContext);
79778
79933
  useLayoutEffect(() => {
79779
- if (!heldSomewhere || getScroller() !== document.scrollingElement) {
79934
+ if (!heldSomewhere || startPlaceRef.current.userTookOver || getScroller() !== document.scrollingElement) {
79780
79935
  return undefined;
79781
79936
  }
79782
- return holdDocumentScroll();
79783
- }, [heldSomewhere, scrollerElResolved]);
79937
+ const releaseDocumentHold = holdDocumentScroll(routePage);
79938
+ releaseDocumentHoldRef.current = releaseDocumentHold;
79939
+ return () => {
79940
+ releaseDocumentHoldRef.current = null;
79941
+ releaseDocumentHold();
79942
+ };
79943
+ }, [heldSomewhere, scrolledWanted, scrollerElResolved]);
79944
+ // A page kept while its route transition plays is hidden until it is taken
79945
+ // down: nothing of the list can be measured there any more, and its scroller
79946
+ // shows the page arriving (see route_page_context.js). So the list lets go of
79947
+ // it, and stops answering its resizes and scrolls: measured hidden, every
79948
+ // item stands at 0×0, and the top item put back where it was would scroll
79949
+ // the page arriving by that item's offset. Kept mounted on purpose, so the
79950
+ // listeners stay; what they would do is what ends.
79951
+ const pageLeftRef = useRef(false);
79952
+ useLayoutEffect(() => {
79953
+ if (!routePage) {
79954
+ return undefined;
79955
+ }
79956
+ return routePage.leavingSignal.subscribe(leaving => {
79957
+ if (leaving) {
79958
+ pageLeftRef.current = true;
79959
+ letGo();
79960
+ }
79961
+ });
79962
+ }, []);
79784
79963
  // What to do when the list's own geometry moves under it, kept fresh for the
79785
79964
  // observer below (which is installed once).
79786
79965
  const onGeometryChangeRef = useRef(null);
@@ -79814,18 +79993,15 @@ const useListScrollSync = ({
79814
79993
  return undefined;
79815
79994
  }
79816
79995
  const scrollerEl = getScroller();
79817
- const takeOver = () => {
79818
- startPlaceRef.current.userTookOver = true;
79819
- };
79820
79996
  const takeOverEvents = ["wheel", "touchstart", "pointerdown", "keydown"];
79821
79997
  for (const type of takeOverEvents) {
79822
- scrollerEl.addEventListener(type, takeOver, {
79998
+ scrollerEl.addEventListener(type, letGo, {
79823
79999
  passive: true
79824
80000
  });
79825
80001
  }
79826
80002
  return () => {
79827
80003
  for (const type of takeOverEvents) {
79828
- scrollerEl.removeEventListener(type, takeOver);
80004
+ scrollerEl.removeEventListener(type, letGo);
79829
80005
  }
79830
80006
  };
79831
80007
  }, [scrolledWanted, scrollerElResolved]);
@@ -79983,6 +80159,9 @@ const useListScrollSync = ({
79983
80159
  }
79984
80160
  };
79985
80161
  const observer = new ResizeObserver(entries => {
80162
+ if (pageLeftRef.current) {
80163
+ return;
80164
+ }
79986
80165
  // A box that grows may be a box that starts to scroll — the scroller is
79987
80166
  // resolved again before anything is done about the resize, so that a
79988
80167
  // list which has outgrown a bounded ancestor stops holding on to the
@@ -80396,6 +80575,9 @@ const useListScrollSync = ({
80396
80575
  }
80397
80576
  const scrollerEl = getScroller();
80398
80577
  const onScroll = () => {
80578
+ if (pageLeftRef.current) {
80579
+ return;
80580
+ }
80399
80581
  // Which way the user is going, read before the position is stored: what
80400
80582
  // the window keeps ahead is ahead of this.
80401
80583
  const previousScroll = currentScrollRef.current;
@@ -80453,7 +80635,7 @@ const useListScrollSync = ({
80453
80635
  const low = way.from < way.to ? way.from : way.to;
80454
80636
  const high = way.from < way.to ? way.to : way.from;
80455
80637
  if (position < low - 1 || position > high + 1) {
80456
- place.userTookOver = true;
80638
+ letGo();
80457
80639
  }
80458
80640
  };
80459
80641
  // A page-level scroller does not emit "scroll" on the element itself
@@ -86530,7 +86712,11 @@ const PickerButton = props => {
86530
86712
  ...props,
86531
86713
  ref: inputRef
86532
86714
  }, {
86533
- controlType: "picker"
86715
+ controlType: "picker",
86716
+ // The root holds what the picker opens as well as its trigger: a
86717
+ // listener on the picker hears what bubbles out of the popup (the focus
86718
+ // leaving it, see picker_custom.jsx).
86719
+ listenersOn: "root"
86534
86720
  });
86535
86721
  const uiStateController = getUIStateControllerById(inputProps.id);
86536
86722
  const value = uiStateController.uiState;
@@ -89739,6 +89925,8 @@ const InputDuration = props => {
89739
89925
  }, {
89740
89926
  controlType: "duration_group",
89741
89927
  cascadeValidationToChildren: true,
89928
+ // The host is an <input type="hidden"> carrying the value.
89929
+ listenersOn: "root",
89742
89930
  // Aggregates sub-input values into an ISO 8601-like duration string.
89743
89931
  // durationToISOString preserves non-numeric mid-edit values
89744
89932
  // (e.g. hours="ab") between their unit markers ("PTabH30M"),
@@ -89805,7 +89993,7 @@ const InputDuration = props => {
89805
89993
  } = groupHostProps;
89806
89994
  const groupRef = useRef();
89807
89995
  useInputGroup(groupRef);
89808
- const clipboardProps = useClipboardProps(groupRef);
89996
+ const clipboardProps = useClipboardProps(groupRef, groupRootProps);
89809
89997
  const hourValue = components?.hours;
89810
89998
  const minuteValue = components?.minutes;
89811
89999
  const rawSecondValue = components?.seconds;
@@ -89878,7 +90066,11 @@ const InputDuration = props => {
89878
90066
  const getVisibleFields = container => {
89879
90067
  return ["hour", "minute", "second", "millisecond"].filter(f => container.querySelector(`[navi-input-type="${f}"]`) !== null);
89880
90068
  };
89881
- const useClipboardProps = groupRef => {
90069
+
90070
+ // Spread after the group's props, so the caller's listeners for these events
90071
+ // are called from here. Whatever they do, the group's own reading runs too, as
90072
+ // it does after a field's paste guard prevented the paste.
90073
+ const useClipboardProps = (groupRef, callerProps) => {
89882
90074
  const getClipboardPayload = () => {
89883
90075
  const groupEl = groupRef.current;
89884
90076
  if (!groupEl) {
@@ -89944,6 +90136,7 @@ const useClipboardProps = groupRef => {
89944
90136
  return true;
89945
90137
  };
89946
90138
  const onCopy = e => {
90139
+ callerProps.onCopy?.(e);
89947
90140
  if (!isFromDurationField(e)) {
89948
90141
  return;
89949
90142
  }
@@ -89956,6 +90149,7 @@ const useClipboardProps = groupRef => {
89956
90149
  e.preventDefault();
89957
90150
  };
89958
90151
  const onCut = e => {
90152
+ callerProps.onCut?.(e);
89959
90153
  if (!isFromDurationField(e)) {
89960
90154
  return;
89961
90155
  }
@@ -89968,6 +90162,7 @@ const useClipboardProps = groupRef => {
89968
90162
  applyToGroup("", e);
89969
90163
  };
89970
90164
  const onPaste = e => {
90165
+ callerProps.onPaste?.(e);
89971
90166
  if (!isFromDurationField(e)) {
89972
90167
  return;
89973
90168
  }
@@ -92537,7 +92732,10 @@ function WheelUI(props) {
92537
92732
  // action explicitly on settle. A caller can still override actionEvent.
92538
92733
  actionEvent: props.actionEvent ?? "custom"
92539
92734
  }, {
92540
- controlType: "input"
92735
+ controlType: "input",
92736
+ // The host is the hidden input carrying the value: the container is
92737
+ // what is focused and spun.
92738
+ listenersOn: "root"
92541
92739
  });
92542
92740
  const uiStateController = getUIStateControllerById(controlHostProps.id);
92543
92741
  const {