@jsenv/navi 0.29.476 → 0.29.477

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",
@@ -45739,6 +45734,9 @@ const ControlgroupChildrenWrapper = ({
45739
45734
  })
45740
45735
  });
45741
45736
 
45737
+ // What the caller writes for these is called from the control's own reactions
45738
+ // (see `reactions` below), so the split leaves them out of both sides.
45739
+ const LISTENERS_CALLED_BY_REACTIONS = new Set(["onMouseDown", "onClick", "onKeyDown", "onPaste", "onInput"]);
45742
45740
  /**
45743
45741
  * Core hook for interactive field components (InputTextual, InputCheckbox, etc.).
45744
45742
  *
@@ -45753,12 +45751,22 @@ const ControlgroupChildrenWrapper = ({
45753
45751
  * All state changes route through DOM events on the field element so that
45754
45752
  * external subscribers (e.g. useControlUIState, Selectable) receive every update.
45755
45753
  *
45754
+ * @param {object} options
45755
+ * @param {"host"|"root"} [options.listenersOn] Where the caller's DOM listeners
45756
+ * (`onPointerDown`, `onFocus`…, never navi's own `onnavi_*`) land. `"host"`,
45757
+ * the default, is right wherever the host is what the person presses and
45758
+ * focuses: the field inside its box, or one element for both. `"root"` is for
45759
+ * a control whose host is a hidden carrier, or whose root also holds what
45760
+ * the control opens, so that a listener hears the control as a whole.
45761
+ * `onMouseDown`, `onClick`, `onKeyDown`, `onPaste` and `onInput` stay the
45762
+ * host's either way: the control's own reactions to them call them.
45756
45763
  * @returns {[controlRootProps, controlHostProps, { uiStateController }]}
45757
45764
  */
45758
45765
  const useControlProps = (props, {
45759
45766
  controlType,
45760
45767
  persists,
45761
- uiActionInternal
45768
+ uiActionInternal,
45769
+ listenersOn = "host"
45762
45770
  }) => {
45763
45771
  const debugUIState = useDebugUIState();
45764
45772
 
@@ -45844,7 +45852,9 @@ const useControlProps = (props, {
45844
45852
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
45845
45853
  uiStateController,
45846
45854
  boundAction,
45847
- controlInfo
45855
+ controlInfo,
45856
+ listenersOn,
45857
+ listenersCalledByReactions: LISTENERS_CALLED_BY_REACTIONS
45848
45858
  });
45849
45859
  {
45850
45860
  const debugInteraction = useDebugInteraction();
@@ -46878,7 +46888,9 @@ const useControlgroupProps = (props, {
46878
46888
  wantRequesterButtonState,
46879
46889
  uiActionInternal,
46880
46890
  allowCapture = false,
46881
- cascadeValidationToChildren = false
46891
+ cascadeValidationToChildren = false,
46892
+ // See useControlProps.
46893
+ listenersOn = "host"
46882
46894
  }) => {
46883
46895
  const {
46884
46896
  action,
@@ -46928,7 +46940,8 @@ const useControlgroupProps = (props, {
46928
46940
  // so we don't have a value concept, nor readonly etc
46929
46941
  controlInfo: {
46930
46942
  controlType
46931
- }
46943
+ },
46944
+ listenersOn
46932
46945
  });
46933
46946
  const {
46934
46947
  basePseudoState
@@ -47067,12 +47080,17 @@ const ControlFacadeChildrenWrapper = ({
47067
47080
  const useInteractiveProps = (props, {
47068
47081
  uiStateController,
47069
47082
  boundAction,
47070
- controlInfo
47083
+ controlInfo,
47084
+ listenersOn,
47085
+ listenersCalledByReactions
47071
47086
  }) => {
47072
47087
  const {
47073
47088
  ref
47074
47089
  } = props;
47075
- const [controlRootProps, controlHostProps] = splitControlProps(props);
47090
+ const [controlRootProps, controlHostProps] = splitControlProps(props, {
47091
+ listenersOn,
47092
+ listenersCalledByReactions
47093
+ });
47076
47094
  controlRootProps["navi-control"] = controlInfo.controlType;
47077
47095
  if (props.selfInteractions) {
47078
47096
  // One attribute, in the DOM, because that is where the claim is read from —
@@ -47449,8 +47467,10 @@ const useInteractiveProps = (props, {
47449
47467
  }
47450
47468
  }
47451
47469
  };
47470
+ const onFocusFromProps = controlHostProps.onFocus;
47452
47471
  Object.assign(controlHostProps, {
47453
47472
  onFocus: e => {
47473
+ onFocusFromProps?.(e);
47454
47474
  // Transfer programmatic focus to the delegate target (navi-focus-delegate or navi-control-proxy-for)
47455
47475
  const focusProxyTarget = findFocusDelegateTarget(e.currentTarget) || findControlProxyTarget(e.currentTarget);
47456
47476
  if (focusProxyTarget) {
@@ -47680,7 +47700,10 @@ const useInteractiveProps = (props, {
47680
47700
  }
47681
47701
  return [controlRootProps, controlHostProps];
47682
47702
  };
47683
- const splitControlProps = props => {
47703
+ const splitControlProps = (props, {
47704
+ listenersOn,
47705
+ listenersCalledByReactions
47706
+ }) => {
47684
47707
  const {
47685
47708
  ref
47686
47709
  } = props;
@@ -47697,10 +47720,20 @@ const splitControlProps = props => {
47697
47720
  if (isControlProp(key)) {
47698
47721
  continue;
47699
47722
  }
47723
+ if (listenersCalledByReactions?.has(key)) {
47724
+ continue;
47725
+ }
47726
+ if (listenersOn === "host" && isDomListenerProp(key)) {
47727
+ controlHostProps[key] = props[key];
47728
+ continue;
47729
+ }
47700
47730
  controlRootProps[key] = props[key];
47701
47731
  }
47702
47732
  return [controlRootProps, controlHostProps];
47703
47733
  };
47734
+ // `onPointerDown`, `onFocusOut`… but not navi's own events (`onnavi_command`),
47735
+ // which keep the element they are dispatched on.
47736
+ const isDomListenerProp = key => /^on[A-Z]/.test(key);
47704
47737
  const isCheckableInput = (controlType, typeProp) => controlType === "input" && (typeProp === "radio" || typeProp === "checkbox");
47705
47738
 
47706
47739
  // The labels the DOM itself can hand over: a wrapping <label>, or a label[for]
@@ -60243,6 +60276,86 @@ const useAutoSelectReadOnly = (props) => {
60243
60276
  return { onFocus, onMouseDown, onPointerDown };
60244
60277
  };
60245
60278
 
60279
+ // selectOnPress: a field whose value is usually replaced rather than edited (an
60280
+ // address, a place to search) behaves like the address bar of a browser. The
60281
+ // first gesture that engages it after it took the focus selects its whole text,
60282
+ // so typing replaces it; a second press places the caret, to fix one part.
60283
+ //
60284
+ // Engaged by a gesture, never by the focus itself: a dialog opening hands the
60285
+ // focus to its first field (docs/autofocus.md), and a field selected on focus
60286
+ // would greet whoever opened the dialog already half-way into an edit. So a
60287
+ // focus that came without a press waits for the first key that types.
60288
+ //
60289
+ // The press selects once it is over, on `click`: the browser places the caret
60290
+ // on mousedown and may collapse a selection on mouseup, so anything selected
60291
+ // before the release is undone by it. `mousedown` decides whether a press
60292
+ // engages because a finger only produces one after a tap — a finger that
60293
+ // scrolled the page never reaches the field. Touch is included on purpose (see
60294
+ // use_autoselect_read_only.js for why a read-only field leaves it out): on a
60295
+ // field meant to be replaced, the native selection handles are expected.
60296
+ const useSelectOnPress = (props, selectOnPress) => {
60297
+ const engagedRef = useRef(false);
60298
+ const pressEngagesRef = useRef(false);
60299
+
60300
+ if (!selectOnPress) {
60301
+ return null;
60302
+ }
60303
+ const onMouseDown = (e) => {
60304
+ props.onMouseDown(e);
60305
+ if (e.defaultPrevented) {
60306
+ return;
60307
+ }
60308
+ if (e.button !== 0) {
60309
+ return;
60310
+ }
60311
+ pressEngagesRef.current = !engagedRef.current;
60312
+ engagedRef.current = true;
60313
+ };
60314
+ const onClick = (e) => {
60315
+ props.onClick(e);
60316
+ if (!pressEngagesRef.current) {
60317
+ return;
60318
+ }
60319
+ pressEngagesRef.current = false;
60320
+ if (e.defaultPrevented) {
60321
+ return;
60322
+ }
60323
+ const input = e.currentTarget;
60324
+ if (input.selectionStart !== input.selectionEnd) {
60325
+ // The press dragged across a part of the text: that part is what was meant.
60326
+ return;
60327
+ }
60328
+ input.select();
60329
+ // Keep the beginning of the text visible instead of scrolling to the end
60330
+ input.scrollLeft = 0;
60331
+ };
60332
+ const onKeyDown = (e) => {
60333
+ if (!engagedRef.current) {
60334
+ const defaultAction = getKeyboardEventDefaultAction(e);
60335
+ if (defaultAction === "type") {
60336
+ engagedRef.current = true;
60337
+ // Before the control's own keydown: maxLengthGuard measures the room
60338
+ // left against the selection the key is about to replace.
60339
+ e.currentTarget.select();
60340
+ } else if (defaultAction === "cursor_move") {
60341
+ engagedRef.current = true;
60342
+ }
60343
+ }
60344
+ props.onKeyDown(e);
60345
+ };
60346
+ const onBlur = (e) => {
60347
+ props.onBlur(e);
60348
+ if (document.activeElement === e.currentTarget) {
60349
+ // The window went to the background and the field still holds the focus:
60350
+ // coming back is not a new arrival.
60351
+ return;
60352
+ }
60353
+ engagedRef.current = false;
60354
+ };
60355
+
60356
+ return { onMouseDown, onClick, onKeyDown, onBlur };
60357
+ };
60358
+
60246
60359
  /**
60247
60360
  * Input component for all textual input types.
60248
60361
  *
@@ -60350,7 +60463,8 @@ const InputTextualUI = props => {
60350
60463
  ui,
60351
60464
  variant,
60352
60465
  backgroundColor,
60353
- width = "maxLength"
60466
+ width = "maxLength",
60467
+ selectOnPress
60354
60468
  } = props;
60355
60469
  const [inputControlRootProps, inputControlHostProps, controlChildrenWrapperProps] = useInputTextualProps(props);
60356
60470
  const {
@@ -60392,6 +60506,8 @@ const InputTextualUI = props => {
60392
60506
  } else {
60393
60507
  inputControlHostProps.width = width;
60394
60508
  }
60509
+ delete inputControlRootProps.selectOnPress;
60510
+ inputControlHostProps.selectOnPress = selectOnPress;
60395
60511
  return jsxs(Box, {
60396
60512
  as: "span",
60397
60513
  inline: true,
@@ -60464,7 +60580,7 @@ const resolveWidthFromMaxLength = (maxLength, inputMode) => {
60464
60580
  // rest — the guards, the slots, the constraints — which describe an edition
60465
60581
  // that does not happen here. What survives is what a Box understands: width,
60466
60582
  // 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"];
60583
+ 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
60584
  const InputTextualAsText = props => {
60469
60585
  installInputCss();
60470
60586
  // The id a Field handed down, which is what its Label points at.
@@ -60528,14 +60644,20 @@ const InputTextualFirstResolver = props => {
60528
60644
  const InputTextual = /*#__PURE__*/createComponentResolver([InputTextualAsTextResolver, InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
60529
60645
  const RealInput = ({
60530
60646
  maxLength,
60647
+ selectOnPress,
60531
60648
  ...domProps
60532
60649
  }) => {
60533
60650
  const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
60651
+ const selectOnPressProps = useSelectOnPress({
60652
+ ...domProps,
60653
+ ...autoSelectReadOnlyProps
60654
+ }, selectOnPress);
60534
60655
  return jsx(Box, {
60535
60656
  ...domProps,
60536
60657
  as: "input",
60537
60658
  baseClassName: "navi_control_input",
60538
60659
  ...autoSelectReadOnlyProps,
60660
+ ...selectOnPressProps,
60539
60661
  // Never set native maxLength — our guard handles it. Omitting it entirely
60540
60662
  // avoids a Preact quirk: setting maxLength={undefined} on a fresh DOM element
60541
60663
  // (e.g. after a Suspense remount) causes Preact to run `el.maxLength = ""`
@@ -76166,6 +76288,11 @@ const ListItemSelectable = props => {
76166
76288
  ...(hasSelectedProp ? {
76167
76289
  checked: selected
76168
76290
  } : null)
76291
+ },
76292
+ // The host is the visually hidden input carrying the selection: the row
76293
+ // is what the person sees and presses (see onClick above).
76294
+ {
76295
+ listenersOn: "root"
76169
76296
  });
76170
76297
  const {
76171
76298
  checked,
@@ -84909,7 +85036,11 @@ const PickerButton = props => {
84909
85036
  ...props,
84910
85037
  ref: inputRef
84911
85038
  }, {
84912
- controlType: "picker"
85039
+ controlType: "picker",
85040
+ // The root holds what the picker opens as well as its trigger: a
85041
+ // listener on the picker hears what bubbles out of the popup (the focus
85042
+ // leaving it, see picker_custom.jsx).
85043
+ listenersOn: "root"
84913
85044
  });
84914
85045
  const uiStateController = getUIStateControllerById(inputProps.id);
84915
85046
  const value = uiStateController.uiState;
@@ -87446,6 +87577,8 @@ const InputDuration = props => {
87446
87577
  }, {
87447
87578
  controlType: "duration_group",
87448
87579
  cascadeValidationToChildren: true,
87580
+ // The host is an <input type="hidden"> carrying the value.
87581
+ listenersOn: "root",
87449
87582
  // Aggregates sub-input values into an ISO 8601-like duration string.
87450
87583
  // durationToISOString preserves non-numeric mid-edit values
87451
87584
  // (e.g. hours="ab") between their unit markers ("PTabH30M"),
@@ -87512,7 +87645,7 @@ const InputDuration = props => {
87512
87645
  } = groupHostProps;
87513
87646
  const groupRef = useRef();
87514
87647
  useInputGroup(groupRef);
87515
- const clipboardProps = useClipboardProps(groupRef);
87648
+ const clipboardProps = useClipboardProps(groupRef, groupRootProps);
87516
87649
  const hourValue = components?.hours;
87517
87650
  const minuteValue = components?.minutes;
87518
87651
  const rawSecondValue = components?.seconds;
@@ -87585,7 +87718,11 @@ const InputDuration = props => {
87585
87718
  const getVisibleFields = container => {
87586
87719
  return ["hour", "minute", "second", "millisecond"].filter(f => container.querySelector(`[navi-input-type="${f}"]`) !== null);
87587
87720
  };
87588
- const useClipboardProps = groupRef => {
87721
+
87722
+ // Spread after the group's props, so the caller's listeners for these events
87723
+ // are called from here. Whatever they do, the group's own reading runs too, as
87724
+ // it does after a field's paste guard prevented the paste.
87725
+ const useClipboardProps = (groupRef, callerProps) => {
87589
87726
  const getClipboardPayload = () => {
87590
87727
  const groupEl = groupRef.current;
87591
87728
  if (!groupEl) {
@@ -87651,6 +87788,7 @@ const useClipboardProps = groupRef => {
87651
87788
  return true;
87652
87789
  };
87653
87790
  const onCopy = e => {
87791
+ callerProps.onCopy?.(e);
87654
87792
  if (!isFromDurationField(e)) {
87655
87793
  return;
87656
87794
  }
@@ -87663,6 +87801,7 @@ const useClipboardProps = groupRef => {
87663
87801
  e.preventDefault();
87664
87802
  };
87665
87803
  const onCut = e => {
87804
+ callerProps.onCut?.(e);
87666
87805
  if (!isFromDurationField(e)) {
87667
87806
  return;
87668
87807
  }
@@ -87675,6 +87814,7 @@ const useClipboardProps = groupRef => {
87675
87814
  applyToGroup("", e);
87676
87815
  };
87677
87816
  const onPaste = e => {
87817
+ callerProps.onPaste?.(e);
87678
87818
  if (!isFromDurationField(e)) {
87679
87819
  return;
87680
87820
  }
@@ -90240,7 +90380,10 @@ function WheelUI(props) {
90240
90380
  // action explicitly on settle. A caller can still override actionEvent.
90241
90381
  actionEvent: props.actionEvent ?? "custom"
90242
90382
  }, {
90243
- controlType: "input"
90383
+ controlType: "input",
90384
+ // The host is the hidden input carrying the value: the container is
90385
+ // what is focused and spun.
90386
+ listenersOn: "root"
90244
90387
  });
90245
90388
  const uiStateController = getUIStateControllerById(controlHostProps.id);
90246
90389
  const {