@jsenv/navi 0.27.61 → 0.27.63

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.
@@ -32244,6 +32244,7 @@ const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
32244
32244
  // Numeric signal types must not fall through to the native type="number"
32245
32245
  // (which adds spinner buttons and has poor UX) — they map to navi_number instead.
32246
32246
  const VALIDITY_TYPE_TO_INPUT_TYPE = {
32247
+ boolean: "checkbox",
32247
32248
  number: "navi_number",
32248
32249
  integer: "navi_number",
32249
32250
  percentage: "navi_percentage",
@@ -33102,7 +33103,9 @@ const InputSearchUI = ({
33102
33103
  const InputEmail = props => {
33103
33104
  const Next = useNextResolver();
33104
33105
  return jsx(Next, {
33105
- ui: jsx(InputEmailUI, {}),
33106
+ ui: jsx(InputEmailUI, {
33107
+ icon: props.icon
33108
+ }),
33106
33109
  ...props
33107
33110
  });
33108
33111
  };
@@ -33652,6 +33655,22 @@ const css$x = /* css */`
33652
33655
  outline: none;
33653
33656
  -webkit-tap-highlight-color: var(--navi-control-tap-highlight-color);
33654
33657
 
33658
+ &::placeholder {
33659
+ color: var(--x-placeholder-color);
33660
+ }
33661
+ /* Webkit is putting a slight blue bckground on autofilled input */
33662
+ /* For now we override with out custom background color */
33663
+ /* Ideally we'll later provide a custom data attribute with ability to see styles when autofilled */
33664
+ &:-webkit-autofill {
33665
+ -webkit-box-shadow: 0 0 0 1000px var(--x-background-color) inset;
33666
+ }
33667
+ /* Webkit is putting some nasty styles after automplete that look as follow */
33668
+ /* input:-internal-autofill-selected { color: FieldText !important; } */
33669
+ /* Fortunately we can override it as follow */
33670
+ &:-internal-autofill-selected {
33671
+ -webkit-text-fill-color: var(--x-color) !important;
33672
+ }
33673
+
33655
33674
  &[type="search"] {
33656
33675
  -webkit-appearance: textfield;
33657
33676
 
@@ -33769,16 +33788,6 @@ const css$x = /* css */`
33769
33788
  }
33770
33789
  }
33771
33790
  }
33772
-
33773
- .navi_input .navi_control_input::placeholder {
33774
- color: var(--x-placeholder-color);
33775
- }
33776
- .navi_input .navi_control_input:-internal-autofill-selected {
33777
- /* Webkit is putting some nasty styles after automplete that look as follow */
33778
- /* input:-internal-autofill-selected { color: FieldText !important; } */
33779
- /* Fortunately we can override it as follow */
33780
- -webkit-text-fill-color: var(--x-color) !important;
33781
- }
33782
33791
  `;
33783
33792
  const InputHeadlessResolver = props => {
33784
33793
  const Next = useNextResolver();
@@ -35117,7 +35126,10 @@ const useClipboardProps = groupRef => {
35117
35126
  if (!target.matches) {
35118
35127
  return false;
35119
35128
  }
35120
- if (!target.matches(".navi_duration_part")) {
35129
+ if (!target.matches(`[navi-control-host="input"`)) {
35130
+ return false;
35131
+ }
35132
+ if (!target.closest(".navi_duration_part")) {
35121
35133
  return false;
35122
35134
  }
35123
35135
  return true;
@@ -35535,29 +35547,6 @@ const transferFocus = (containerEl, debugFocus, e, fallback) => {
35535
35547
  }
35536
35548
  };
35537
35549
 
35538
- const useCleanup = () => {
35539
- const cleanupMethodsRef = useRef(null);
35540
- let cleanupMethods = cleanupMethodsRef.current;
35541
- if (!cleanupMethods) {
35542
- const [publish, subscribe, clear] = createPubSub();
35543
- const cleanup = () => {
35544
- publish();
35545
- clear();
35546
- };
35547
- const registerCleanup = (cb) => {
35548
- subscribe(cb);
35549
- };
35550
- cleanupMethodsRef.current = cleanupMethods = [registerCleanup, cleanup];
35551
- }
35552
- useLayoutEffect(() => {
35553
- return () => {
35554
- const [, cleanup] = cleanupMethods;
35555
- cleanup();
35556
- };
35557
- }, []);
35558
- return cleanupMethods;
35559
- };
35560
-
35561
35550
  installImportMetaCssBuild(import.meta);const css$r = /* css */`
35562
35551
  .navi_dialog {
35563
35552
  &[open] {
@@ -35579,14 +35568,12 @@ installImportMetaCssBuild(import.meta);const css$r = /* css */`
35579
35568
  const Dialog = props => {
35580
35569
  import.meta.css = [css$r, "@jsenv/navi/src/popup/dialog.jsx"];
35581
35570
  const {
35582
- // requestOpen,
35583
- requestClose,
35571
+ openController,
35584
35572
  anchorRef,
35585
35573
  children,
35586
35574
  scrollTrap,
35587
35575
  pointerTrap,
35588
35576
  centerInVisualViewport: centerInVisualViewportProp,
35589
- closeRequestHandler,
35590
35577
  ...rest
35591
35578
  } = props;
35592
35579
  const defaultRef = useRef();
@@ -35595,16 +35582,20 @@ const Dialog = props => {
35595
35582
  const debugFocus = useDebugFocus();
35596
35583
  const autoFocusProps = useAutoFocus(ref, props.autoFocus);
35597
35584
 
35598
- // Tracks actual DOM state (open/closed), updated only by open() and close().
35599
- // Intentionally NOT synced to openProp on every render so the useEffect guard
35600
- // below can detect whether a prop change requires action.
35601
- const openedRef = useRef(false);
35602
- const [addCleanup, cleanup] = useCleanup();
35603
- const open = e => {
35585
+ // Sync the DOM open and return how to sync it back closed, fresh on every
35586
+ // render so it closes over the latest props (scrollTrap, etc.). The
35587
+ // controller (owned by picker_custom.jsx) decides *when* this runs.
35588
+ // openEffect runs outside of render (triggered by openController.open()), so
35589
+ // it cannot call hooks — cleanup is a plain pub/sub.
35590
+ openController.openEffect = e => {
35591
+ const dialogEl = ref.current;
35592
+ if (!dialogEl) {
35593
+ return undefined;
35594
+ }
35595
+ const [cleanup, addCleanup] = createPubSub(true);
35604
35596
  const anchor = anchorRef?.current ?? null;
35605
35597
  const effectiveAnchor = anchor || document.documentElement;
35606
35598
  debugPopup(`"${e.type}" on ${getElementSignature(e.target)} -> openDialog`);
35607
- const dialogEl = ref.current;
35608
35599
  const {
35609
35600
  width,
35610
35601
  height
@@ -35652,22 +35643,15 @@ const Dialog = props => {
35652
35643
  dialogEl.style.removeProperty("--dialog-top-inset");
35653
35644
  });
35654
35645
  }
35655
- openedRef.current = true;
35656
- dispatchCustomEvent(dialogEl, "navi_open", {
35657
- event: e,
35658
- focusedBeforeOpen
35659
- });
35660
- };
35661
- const close = e => {
35662
- debugPopup(`"${e.type}" on ${getElementSignature(e.target)} -> closeDialog`);
35663
- const dialogEl = ref.current;
35664
- markAutofocusRestoreOnClose(dialogEl);
35665
- dialogEl.close();
35666
- cleanup();
35667
- openedRef.current = false;
35668
- dispatchCustomEvent(dialogEl, "navi_close", {
35669
- event: e
35670
- });
35646
+ // Picker's openController.open() reads this back synchronously right
35647
+ // after openEffect() returns (see picker_custom.jsx useOpenController).
35648
+ e.detail.focusedBeforeOpen = focusedBeforeOpen;
35649
+ return () => {
35650
+ debugPopup(`"${e.type}" on ${getElementSignature(e.target)} -> closeDialog`);
35651
+ markAutofocusRestoreOnClose(dialogEl);
35652
+ dialogEl.close();
35653
+ cleanup();
35654
+ };
35671
35655
  };
35672
35656
  return jsx(Box, {
35673
35657
  ...rest,
@@ -35685,58 +35669,17 @@ const Dialog = props => {
35685
35669
  const rect = ref.current.getBoundingClientRect();
35686
35670
  const isBackdrop = e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom;
35687
35671
  if (isBackdrop) {
35688
- requestClose(e, {
35672
+ openController.requestClose(e, {
35689
35673
  isCancel: true
35690
35674
  });
35691
35675
  }
35692
35676
  }
35693
35677
  },
35694
35678
  onCancel: e => {
35695
- requestClose(e, {
35679
+ openController.requestClose(e, {
35696
35680
  isCancel: true
35697
35681
  });
35698
35682
  },
35699
- onnavi_request_open: e => {
35700
- const dialogEl = ref.current;
35701
- if (!dialogEl) {
35702
- return;
35703
- }
35704
- if (openedRef.current) {
35705
- return;
35706
- }
35707
- open(e);
35708
- },
35709
- onnavi_request_close: e => {
35710
- const dialogEl = ref.current;
35711
- if (!dialogEl) {
35712
- return;
35713
- }
35714
- if (!openedRef.current) {
35715
- return;
35716
- }
35717
- if (closeRequestHandler) {
35718
- let denied = false;
35719
- const closePermission = {
35720
- deny: () => {
35721
- denied = true;
35722
- },
35723
- allow: () => {
35724
- denied = false;
35725
- }
35726
- };
35727
- closeRequestHandler(e, closePermission);
35728
- if (denied) {
35729
- if (e.type === "cancel") {
35730
- e.preventDefault();
35731
- }
35732
- closePermission.allow = () => {
35733
- close(e);
35734
- };
35735
- return;
35736
- }
35737
- }
35738
- close(e);
35739
- },
35740
35683
  children: children
35741
35684
  });
35742
35685
  };
@@ -35767,7 +35710,7 @@ installImportMetaCssBuild(import.meta);const css$q = /* css */`
35767
35710
  const Popover = props => {
35768
35711
  import.meta.css = [css$q, "@jsenv/navi/src/popup/popover.jsx"];
35769
35712
  const {
35770
- requestClose,
35713
+ openController,
35771
35714
  anchorRef,
35772
35715
  scrollTrap,
35773
35716
  pointerTrap,
@@ -35779,7 +35722,6 @@ const Popover = props => {
35779
35722
  positionYFixed,
35780
35723
  spacing = 0,
35781
35724
  viewportSpacing = 0,
35782
- closeRequestHandler,
35783
35725
  ...rest
35784
35726
  } = props;
35785
35727
  const defaultRef = useRef();
@@ -35790,14 +35732,18 @@ const Popover = props => {
35790
35732
  const debugFocus = useDebugFocus();
35791
35733
  const autoFocusProps = useAutoFocus(ref, props.autoFocus);
35792
35734
 
35793
- // Tracks actual DOM state (open/closed), updated only by open() and close().
35794
- // Intentionally NOT synced to openProp on every render so the useEffect guard
35795
- // below can detect whether a prop change requires action.
35796
- const openedRef = useRef(false);
35797
- const [addCleanup, cleanup] = useCleanup();
35798
- const open = e => {
35799
- debugPopup(e, `openPopover()`);
35735
+ // Sync the DOM open and return how to sync it back closed, fresh on every
35736
+ // render so it closes over the latest props (scrollTrap, etc.). The
35737
+ // controller (owned by picker_custom.jsx) decides *when* this runs.
35738
+ // openEffect runs outside of render (triggered by openController.open()), so
35739
+ // it cannot call hooks — cleanup is a plain pub/sub.
35740
+ openController.openEffect = e => {
35800
35741
  const popoverEl = ref.current;
35742
+ if (!popoverEl) {
35743
+ return undefined;
35744
+ }
35745
+ const [cleanup, addCleanup] = createPubSub(true);
35746
+ debugPopup(e, `openPopover()`);
35801
35747
  const focusedBeforeOpen = getFocusedBeforeTransfer(e);
35802
35748
  popoverEl.showPopover();
35803
35749
  transferFocus(popoverEl, debugFocus, e, focusedBeforeOpen);
@@ -35873,22 +35819,15 @@ const Popover = props => {
35873
35819
  addCleanup(() => {
35874
35820
  rectEffect.disconnect();
35875
35821
  });
35876
- openedRef.current = true;
35877
- dispatchCustomEvent(popoverEl, "navi_open", {
35878
- event: e,
35879
- focusedBeforeOpen
35880
- });
35881
- };
35882
- const close = e => {
35883
- debugPopup(e, `closePopover()`);
35884
- const popoverEl = ref.current;
35885
- markAutofocusRestoreOnClose(popoverEl);
35886
- popoverEl.hidePopover();
35887
- cleanup();
35888
- openedRef.current = false;
35889
- dispatchCustomEvent(popoverEl, "navi_close", {
35890
- event: e
35891
- });
35822
+ // Picker's openController.open() reads this back synchronously right
35823
+ // after openEffect() returns (see picker_custom.jsx useOpenController).
35824
+ e.detail.focusedBeforeOpen = focusedBeforeOpen;
35825
+ return () => {
35826
+ debugPopup(e, `closePopover()`);
35827
+ markAutofocusRestoreOnClose(popoverEl);
35828
+ popoverEl.hidePopover();
35829
+ cleanup();
35830
+ };
35892
35831
  };
35893
35832
  return jsxs(Box, {
35894
35833
  id: id,
@@ -35898,44 +35837,6 @@ const Popover = props => {
35898
35837
  ref: ref,
35899
35838
  baseClassName: "navi_popover",
35900
35839
  pseudoClasses: POPOVER_PSEUDO_CLASSES,
35901
- onnavi_request_open: e => {
35902
- const popoverEl = ref.current;
35903
- if (!popoverEl) {
35904
- return;
35905
- }
35906
- if (openedRef.current) {
35907
- return;
35908
- }
35909
- open(e);
35910
- },
35911
- onnavi_request_close: e => {
35912
- const popoverEl = ref.current;
35913
- if (!popoverEl) {
35914
- return;
35915
- }
35916
- if (!openedRef.current) {
35917
- return;
35918
- }
35919
- if (closeRequestHandler) {
35920
- let denied = false;
35921
- const closePermission = {
35922
- deny: () => {
35923
- denied = true;
35924
- },
35925
- allow: () => {
35926
- denied = false;
35927
- }
35928
- };
35929
- closeRequestHandler(e, closePermission);
35930
- if (denied) {
35931
- closePermission.allow = () => {
35932
- close(e);
35933
- };
35934
- return;
35935
- }
35936
- }
35937
- close(e);
35938
- },
35939
35840
  children: [jsx("div", {
35940
35841
  className: "navi_popover_backdrop",
35941
35842
  "aria-hidden": "true",
@@ -35947,7 +35848,7 @@ const Popover = props => {
35947
35848
  e.preventDefault();
35948
35849
  return;
35949
35850
  }
35950
- requestClose(e, {
35851
+ openController.requestClose(e, {
35951
35852
  isCancel: true
35952
35853
  });
35953
35854
  }
@@ -36253,7 +36154,6 @@ const PickerCustom = props => {
36253
36154
  // In "dialog" mode, enterExpanded() pushes a history entry so the back button closes.
36254
36155
  // In "popover" mode, it replaces the current history state (no history entry added).
36255
36156
  const pickerNavType = mode === "dialog" ? "push" : "replace";
36256
- const expandedRef = useRef(false);
36257
36157
  const [expanded, enterExpanded, leaveExpanded] = useNavState(popupId, {
36258
36158
  type: pickerNavType,
36259
36159
  // onLeave fires only when the state key disappears externally (back button/gesture most of the time).
@@ -36265,95 +36165,108 @@ const PickerCustom = props => {
36265
36165
  });
36266
36166
  }
36267
36167
  });
36268
- // expandedRef tracks actual open/close state, updated only by onOpen/onClose.
36269
- // NOT synced to expanded on every render so the useLayoutEffect guard below works.
36270
- const valueAtOpenRef = useRef(null);
36271
- const activeElementAtOpenRef = useRef(null);
36272
- const onOpen = e => {
36273
- expandedRef.current = true;
36274
- enterExpanded();
36275
- const focusedBeforeOpen = e.detail.focusedBeforeOpen;
36276
- activeElementAtOpenRef.current = focusedBeforeOpen;
36277
- debugFocus(e, "picked opened, store element focused", focusedBeforeOpen);
36278
- const valueAtOpen = getPickerInputUIState(ref.current);
36279
- valueAtOpenRef.current = valueAtOpen;
36280
- debugPopup(e, `picker opened, store value at open`, valueAtOpen);
36281
- };
36282
- const restoreFocus = e => {
36283
- const activeElementAtOpen = activeElementAtOpenRef.current;
36284
- activeElementAtOpenRef.current = null;
36285
- const focusoutEvent = findEvent(e, "focusout");
36286
- if (focusoutEvent) {
36287
- debugFocus(e, `closed by focusout -> let focus go away`);
36288
- return;
36289
- }
36290
- const mousedownEvent = findEvent(e, "mousedown");
36291
- if (mousedownEvent) {
36292
- debugFocus(e, "closed by mousedown -> prevent browser focus (mousedown.preventDefault())");
36293
- mousedownEvent.preventDefault();
36294
- }
36295
- debugFocus(e, `restore focus to previously focused element`, activeElementAtOpen);
36296
- activeElementAtOpen.focus({
36297
- preventScroll: true
36298
- });
36299
- return;
36300
- };
36301
- const onClose = e => {
36302
- const mousedownEvent = findEvent(e, "mousedown");
36303
- if (mousedownEvent) {
36304
- debugPopup(e, `closed by mousedown -> disable next click`);
36305
- disableClickFor();
36306
- } else {
36307
- const spaceEvent = findEvent(e, e => e.type === "keydown" && e.key === " ");
36308
- if (spaceEvent) {
36309
- // space would trigger a click on the picker button causing it to re-open immediatly after closing
36310
- debugPopup(e, `closed by space key -> prevent browser click`);
36311
- // browser won't try to dispatch click
36312
- // and our "space_to_open" will see e.defaultPrevented too and won't try to open picker
36313
- spaceEvent.preventDefault();
36314
- }
36315
- }
36316
- expandedRef.current = false;
36317
- leaveExpanded({
36318
- isBack: e.detail.isCancel
36319
- });
36320
- // Reset so the next opening re-evaluates screen size
36321
- defaultModeRef.current = null;
36322
- restoreFocus(e);
36323
- };
36324
36168
  const disableClickFor = useIgnoreClickForMousedown(ref, e => {
36325
36169
  debugPopup(e, `click ignored`);
36326
36170
  });
36171
+ // openController centralizes open/close decision-making (validation,
36172
+ // focus and value bookkeeping) for the picker. The returned
36173
+ // { onRequestClose, onClose } pair is the picker's reaction to close
36174
+ // requests — see createOpenController below for the full contract.
36175
+ const openController = useOpenController(openEvent => {
36176
+ enterExpanded();
36177
+ const focusedBeforeOpen = openEvent.detail.focusedBeforeOpen;
36178
+ debugFocus(openEvent, "picked opened, store element focused", focusedBeforeOpen);
36179
+ const valueAtOpen = getPickerInputUIState(ref.current);
36180
+ debugPopup(openEvent, `picker opened, store value at open`, valueAtOpen);
36181
+ return {
36182
+ onRequestClose: requestCloseEvent => {
36183
+ if (requestCloseEvent.detail.isCancel) {
36184
+ // Cancelling always succeeds — nothing to validate.
36185
+ return;
36186
+ }
36187
+ const pickerEl = ref.current;
36188
+ const inputEl = getPickerInput(pickerEl);
36189
+ dispatchRequestAction(inputEl, {
36190
+ event: requestCloseEvent,
36191
+ name: "picker request close",
36192
+ prevented: () => {
36193
+ requestCloseEvent.preventDefault();
36194
+ },
36195
+ // Always report validation when the picker tries to close so the
36196
+ // user sees what is wrong, even if the picker has no action prop.
36197
+ reportOnInvalid: true,
36198
+ onInvalid: () => {
36199
+ requestCloseEvent.preventDefault();
36200
+ }
36201
+ });
36202
+ },
36203
+ onClose: closeEvent => {
36204
+ if (closeEvent.detail.isCancel) {
36205
+ const pickerEl = ref.current;
36206
+ const inputEl = getPickerInput(pickerEl);
36207
+ debugPopup(closeEvent, `picker cancel, restoring value at open ${JSON.stringify(valueAtOpen)}`);
36208
+ dispatchRequestSetUIState(inputEl, valueAtOpen, {
36209
+ event: closeEvent
36210
+ });
36211
+ }
36212
+ {
36213
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
36214
+ if (mousedownEvent) {
36215
+ debugPopup(closeEvent, `closed by mousedown -> disable next click`);
36216
+ disableClickFor();
36217
+ } else {
36218
+ const spaceEvent = findEvent(closeEvent, e => e.type === "keydown" && e.key === " ");
36219
+ if (spaceEvent) {
36220
+ // space would trigger a click on the picker button causing it to re-open immediatly after closing
36221
+ debugPopup(closeEvent, `closed by space key -> prevent browser click`);
36222
+ // browser won't try to dispatch click
36223
+ // and our "space_to_open" will see e.defaultPrevented too and won't try to open picker
36224
+ spaceEvent.preventDefault();
36225
+ }
36226
+ }
36227
+ }
36228
+ {
36229
+ const focusoutEvent = findEvent(closeEvent, "focusout");
36230
+ if (focusoutEvent) {
36231
+ debugFocus(closeEvent, `closed by focusout -> let focus go away`);
36232
+ } else {
36233
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
36234
+ if (mousedownEvent) {
36235
+ debugFocus(closeEvent, "closed by mousedown -> prevent browser focus (mousedown.preventDefault())");
36236
+ mousedownEvent.preventDefault();
36237
+ }
36238
+ debugFocus(closeEvent, `restore focus to previously focused element`, focusedBeforeOpen);
36239
+ focusedBeforeOpen.focus({
36240
+ preventScroll: true
36241
+ });
36242
+ }
36243
+ }
36244
+ leaveExpanded({
36245
+ isBack: closeEvent.detail.isCancel
36246
+ });
36247
+ // Reset so the next opening re-evaluates screen size
36248
+ defaultModeRef.current = null;
36249
+ }
36250
+ };
36251
+ });
36327
36252
  const requestOpen = (e, detail) => {
36328
36253
  // scroll <button> of the picker into view when opening it
36329
36254
  const pickerEl = ref.current;
36330
36255
  pickerEl.scrollIntoView({
36331
36256
  block: "nearest"
36332
36257
  });
36333
- const popupEl = popupRef.current;
36334
- return dispatchCustomEvent(popupEl, "navi_request_open", {
36335
- event: e,
36336
- ...detail
36337
- });
36338
- };
36339
- const requestClose = (e = new CustomEvent("programmatic", {
36340
- detail: {}
36341
- }), detail) => {
36342
- const popupEl = popupRef.current;
36343
- return dispatchCustomEvent(popupEl, "navi_request_close", {
36344
- event: e,
36345
- ...detail
36346
- });
36258
+ openController.open(e, detail);
36347
36259
  };
36260
+ const requestClose = openController.requestClose;
36348
36261
  const open = Boolean(expanded);
36349
36262
  useLayoutEffect(() => {
36350
36263
  if (open === undefined) {
36351
36264
  return;
36352
36265
  }
36353
36266
  // Skip when the popup is already in the desired state.
36354
- // expandedRef tracks actual open/close (updated by onOpen/onClose, not by renders)
36355
- // so it is the authoritative check against feedback loops.
36356
- if (open === expandedRef.current) {
36267
+ // openController.opened tracks actual open/close (updated by onopen/onclose,
36268
+ // not by renders) so it is the authoritative check against feedback loops.
36269
+ if (open === openController.opened) {
36357
36270
  return;
36358
36271
  }
36359
36272
  // open_prop_change means the parent is driving the open state directly
@@ -36388,7 +36301,7 @@ const PickerCustom = props => {
36388
36301
  // requestClose(e);
36389
36302
  },
36390
36303
  "onnavi_request_open": e => {
36391
- if (expandedRef.current) {
36304
+ if (openController.opened) {
36392
36305
  return;
36393
36306
  }
36394
36307
  requestInteraction({
@@ -36403,7 +36316,9 @@ const PickerCustom = props => {
36403
36316
  requestInteraction({
36404
36317
  event: e,
36405
36318
  allowed: () => {
36406
- requestClose(e);
36319
+ requestClose(e, {
36320
+ isCancel: e.detail.isCancel
36321
+ });
36407
36322
  }
36408
36323
  });
36409
36324
  },
@@ -36414,41 +36329,7 @@ const PickerCustom = props => {
36414
36329
  });
36415
36330
  Object.assign(popupProps, {
36416
36331
  anchorRef: props.ref,
36417
- requestClose,
36418
- closeRequestHandler: (requestCloseEvent, closePermission) => {
36419
- const isCancel = requestCloseEvent.detail.isCancel;
36420
- if (isCancel) {
36421
- const pickerEl = ref.current;
36422
- const inputEl = getPickerInput(pickerEl);
36423
- const valueAtOpen = valueAtOpenRef.current;
36424
- debugPopup(requestCloseEvent, `picker cancel, restoring value at open ${JSON.stringify(valueAtOpen)}`);
36425
- dispatchRequestSetUIState(inputEl, valueAtOpen, {
36426
- event: requestCloseEvent
36427
- });
36428
- return;
36429
- }
36430
- const pickerEl = ref.current;
36431
- const inputEl = getPickerInput(pickerEl);
36432
- dispatchRequestAction(inputEl, {
36433
- event: requestCloseEvent,
36434
- name: "picker close",
36435
- prevented: () => {
36436
- closePermission.deny();
36437
- },
36438
- // Always report validation when the picker tries to close so the
36439
- // user sees what is wrong, even if the picker has no action prop.
36440
- reportOnInvalid: true,
36441
- onInvalid: () => {
36442
- closePermission.deny();
36443
- }
36444
- });
36445
- },
36446
- onnavi_open: e => {
36447
- onOpen(e);
36448
- },
36449
- onnavi_close: e => {
36450
- onClose(e);
36451
- }
36332
+ openController
36452
36333
  });
36453
36334
  {
36454
36335
  const onKeyDownShortcuts = createOnKeyDownForShortcuts({
@@ -36505,7 +36386,7 @@ const PickerCustom = props => {
36505
36386
  };
36506
36387
  },
36507
36388
  "escape": e => {
36508
- if (!expandedRef.current) {
36389
+ if (!openController.opened) {
36509
36390
  return null;
36510
36391
  }
36511
36392
  return {
@@ -36529,7 +36410,7 @@ const PickerCustom = props => {
36529
36410
  if (isWithinPopup(e.target)) {
36530
36411
  return null;
36531
36412
  }
36532
- if (expandedRef.current) {
36413
+ if (openController.opened) {
36533
36414
  return {
36534
36415
  name: "mousedown to close picker",
36535
36416
  allowed: () => requestClose(e, {
@@ -36625,8 +36506,7 @@ const PickerContentInsidePopover = props => {
36625
36506
  name: "blur",
36626
36507
  category: "interaction",
36627
36508
  allowed: () => {
36628
- dispatchCustomEvent(popoverEl, "navi_request_close", {
36629
- event: e,
36509
+ popupProps.openController.requestClose(e, {
36630
36510
  isCancel: true
36631
36511
  });
36632
36512
  }
@@ -36651,9 +36531,7 @@ const PickerContentInsidePopover = props => {
36651
36531
  if (e.button !== 0) {
36652
36532
  return;
36653
36533
  }
36654
- const popupEl = popupProps.ref.current;
36655
- dispatchCustomEvent(popupEl, "navi_request_close", {
36656
- event: e,
36534
+ popupProps.openController.requestClose(e, {
36657
36535
  isCancel: true
36658
36536
  });
36659
36537
  },
@@ -36738,6 +36616,142 @@ const useIgnoreClickForMousedown = (elementRef, onIgnore) => {
36738
36616
  return disableClickFor;
36739
36617
  };
36740
36618
 
36619
+ // Created once per picker instance: openHandler is wrapped in a stable callback
36620
+ // so the controller identity never changes across renders, even though
36621
+ // Dialog/Popover read fresh closures (scrollTrap, etc.) via
36622
+ // openController.openEffect on every render.
36623
+ const useOpenController = openHandler => {
36624
+ const stableOpenHandler = useStableCallback(openHandler);
36625
+ const controllerRef = useRef(null);
36626
+ if (!controllerRef.current) {
36627
+ controllerRef.current = createOpenController(stableOpenHandler);
36628
+ }
36629
+ // Unmount safety net: if Dialog/Popover unmounts while still open (parent
36630
+ // removes it from the tree without going through requestClose()), there is
36631
+ // no choice to leave open — close it for real.
36632
+ useLayoutEffect(() => {
36633
+ return () => {
36634
+ controllerRef.current.close();
36635
+ };
36636
+ }, []);
36637
+ return controllerRef.current;
36638
+ };
36639
+
36640
+ /**
36641
+ * Owns open/close decision-making for a picker popup (Dialog or Popover):
36642
+ * guards against duplicate requests and notifies the picker's own reactions.
36643
+ *
36644
+ * `controller.openEffect` is implemented by the controlled element (Dialog or
36645
+ * Popover), reassigned on every render so it always closes over the latest
36646
+ * props (scrollTrap, anchorRef, etc.). It performs whatever DOM side effects
36647
+ * are needed to make the element actually open (`showModal()`/`showPopover()`,
36648
+ * focus transfer, positioning, traps...) and returns its cleanup —
36649
+ * the matching side effects to sync back to closed (`close()`/
36650
+ * `hidePopover()`, releasing traps...). That cleanup is kept private to the
36651
+ * controller (not exposed as a property) and invoked when the popup actually
36652
+ * closes, however that happens.
36653
+ *
36654
+ * Dialog/Popover also call `openController.requestClose(e, { isCancel })` for
36655
+ * their own internal triggers (backdrop click, Escape).
36656
+ *
36657
+ * `openHandler` is the picker's own business logic, passed once to
36658
+ * `createOpenController`. Its return value is `{ onRequestClose, onClose }`,
36659
+ * in the spirit of CloseWatcher
36660
+ * (https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher) but with
36661
+ * clearer naming than its cancel/close pair:
36662
+ * - `onRequestClose(e)`: about to close — call `e.preventDefault()` to stay
36663
+ * open. Validation lives here.
36664
+ * - `onClose(e)`: actually closing, not preventable — final reactions live here.
36665
+ *
36666
+ * The controller exposes matching action methods:
36667
+ * - `open()`: requests opening — runs `openEffect`, then `openHandler`.
36668
+ * - `requestClose()`: requests closing — calls `onRequestClose` then `onClose`,
36669
+ * stopping after the first if denied. The popup may choose to stay open.
36670
+ * - `close()`: closes for real — calls only `onClose`, skipping
36671
+ * `onRequestClose` entirely. Used when there really is no choice (e.g. the
36672
+ * popup unmounting).
36673
+ */
36674
+ const createOpenController = openHandler => {
36675
+ let closeHandlers = null; // { onRequestClose, onClose } returned by openHandler
36676
+ let openEffectCleanup = null; // function returned by openEffect, undoes its DOM side effects
36677
+ const performClose = closeEvent => {
36678
+ controller.opened = false;
36679
+ // Sync the DOM closed first (releasing the focus trap) — only then run
36680
+ // the picker's own reaction (onClose may restore focus to an element
36681
+ // outside the popup, which the focus trap would otherwise fight while
36682
+ // still active).
36683
+ openEffectCleanup?.(closeEvent);
36684
+ openEffectCleanup = null;
36685
+ closeHandlers?.onClose?.(closeEvent);
36686
+ closeHandlers = null;
36687
+ };
36688
+ const controller = {
36689
+ opened: false,
36690
+ openEffect: null,
36691
+ open: (e, detail) => {
36692
+ if (controller.opened || !controller.openEffect) {
36693
+ return;
36694
+ }
36695
+ const requestOpenEvent = new CustomEvent("navi_request_open", {
36696
+ detail: {
36697
+ event: e,
36698
+ ...detail
36699
+ },
36700
+ cancelable: true
36701
+ });
36702
+ chainEvent(requestOpenEvent, e);
36703
+ controller.opened = true;
36704
+ // openEffect may populate requestOpenEvent.detail (e.g. focusedBeforeOpen)
36705
+ // by mutating it — openHandler reads it right after, synchronously.
36706
+ openEffectCleanup = controller.openEffect(requestOpenEvent) || null;
36707
+ closeHandlers = openHandler(requestOpenEvent) || null;
36708
+ },
36709
+ requestClose: (e = new CustomEvent("programmatic", {
36710
+ detail: {}
36711
+ }), detail) => {
36712
+ if (!controller.opened) {
36713
+ return;
36714
+ }
36715
+ const requestCloseEvent = new CustomEvent("navi_request_close", {
36716
+ detail: {
36717
+ event: e,
36718
+ ...detail
36719
+ },
36720
+ cancelable: true
36721
+ });
36722
+ chainEvent(requestCloseEvent, e);
36723
+ closeHandlers?.onRequestClose?.(requestCloseEvent);
36724
+ if (requestCloseEvent.defaultPrevented) {
36725
+ // The native <dialog> "cancel" event (Escape key) closes the dialog
36726
+ // by default; prevent that default so denial actually keeps it open.
36727
+ const nativeCancelEvent = findEvent(requestCloseEvent, "cancel");
36728
+ if (nativeCancelEvent) {
36729
+ nativeCancelEvent.preventDefault();
36730
+ }
36731
+ return;
36732
+ }
36733
+ performClose(requestCloseEvent);
36734
+ },
36735
+ close: (e = new CustomEvent("programmatic", {
36736
+ detail: {}
36737
+ }), detail) => {
36738
+ if (!controller.opened) {
36739
+ return;
36740
+ }
36741
+ const closeEvent = new CustomEvent("navi_close", {
36742
+ detail: {
36743
+ event: e,
36744
+ ...detail
36745
+ }
36746
+ });
36747
+ chainEvent(closeEvent, e);
36748
+ // Skips onRequestClose entirely — there is no choice here.
36749
+ performClose(closeEvent);
36750
+ }
36751
+ };
36752
+ return controller;
36753
+ };
36754
+
36741
36755
  const PickerNaviMinute = props => {
36742
36756
  const Next = useNextResolver();
36743
36757
  const {