@jsenv/navi 0.27.62 → 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.
@@ -33658,13 +33658,16 @@ const css$x = /* css */`
33658
33658
  &::placeholder {
33659
33659
  color: var(--x-placeholder-color);
33660
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 */
33661
33664
  &:-webkit-autofill {
33662
33665
  -webkit-box-shadow: 0 0 0 1000px var(--x-background-color) inset;
33663
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 */
33664
33670
  &:-internal-autofill-selected {
33665
- /* Webkit is putting some nasty styles after automplete that look as follow */
33666
- /* input:-internal-autofill-selected { color: FieldText !important; } */
33667
- /* Fortunately we can override it as follow */
33668
33671
  -webkit-text-fill-color: var(--x-color) !important;
33669
33672
  }
33670
33673
 
@@ -35123,7 +35126,10 @@ const useClipboardProps = groupRef => {
35123
35126
  if (!target.matches) {
35124
35127
  return false;
35125
35128
  }
35126
- 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")) {
35127
35133
  return false;
35128
35134
  }
35129
35135
  return true;
@@ -35541,29 +35547,6 @@ const transferFocus = (containerEl, debugFocus, e, fallback) => {
35541
35547
  }
35542
35548
  };
35543
35549
 
35544
- const useCleanup = () => {
35545
- const cleanupMethodsRef = useRef(null);
35546
- let cleanupMethods = cleanupMethodsRef.current;
35547
- if (!cleanupMethods) {
35548
- const [publish, subscribe, clear] = createPubSub();
35549
- const cleanup = () => {
35550
- publish();
35551
- clear();
35552
- };
35553
- const registerCleanup = (cb) => {
35554
- subscribe(cb);
35555
- };
35556
- cleanupMethodsRef.current = cleanupMethods = [registerCleanup, cleanup];
35557
- }
35558
- useLayoutEffect(() => {
35559
- return () => {
35560
- const [, cleanup] = cleanupMethods;
35561
- cleanup();
35562
- };
35563
- }, []);
35564
- return cleanupMethods;
35565
- };
35566
-
35567
35550
  installImportMetaCssBuild(import.meta);const css$r = /* css */`
35568
35551
  .navi_dialog {
35569
35552
  &[open] {
@@ -35585,14 +35568,12 @@ installImportMetaCssBuild(import.meta);const css$r = /* css */`
35585
35568
  const Dialog = props => {
35586
35569
  import.meta.css = [css$r, "@jsenv/navi/src/popup/dialog.jsx"];
35587
35570
  const {
35588
- // requestOpen,
35589
- requestClose,
35571
+ openController,
35590
35572
  anchorRef,
35591
35573
  children,
35592
35574
  scrollTrap,
35593
35575
  pointerTrap,
35594
35576
  centerInVisualViewport: centerInVisualViewportProp,
35595
- closeRequestHandler,
35596
35577
  ...rest
35597
35578
  } = props;
35598
35579
  const defaultRef = useRef();
@@ -35601,16 +35582,20 @@ const Dialog = props => {
35601
35582
  const debugFocus = useDebugFocus();
35602
35583
  const autoFocusProps = useAutoFocus(ref, props.autoFocus);
35603
35584
 
35604
- // Tracks actual DOM state (open/closed), updated only by open() and close().
35605
- // Intentionally NOT synced to openProp on every render so the useEffect guard
35606
- // below can detect whether a prop change requires action.
35607
- const openedRef = useRef(false);
35608
- const [addCleanup, cleanup] = useCleanup();
35609
- 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);
35610
35596
  const anchor = anchorRef?.current ?? null;
35611
35597
  const effectiveAnchor = anchor || document.documentElement;
35612
35598
  debugPopup(`"${e.type}" on ${getElementSignature(e.target)} -> openDialog`);
35613
- const dialogEl = ref.current;
35614
35599
  const {
35615
35600
  width,
35616
35601
  height
@@ -35658,22 +35643,15 @@ const Dialog = props => {
35658
35643
  dialogEl.style.removeProperty("--dialog-top-inset");
35659
35644
  });
35660
35645
  }
35661
- openedRef.current = true;
35662
- dispatchCustomEvent(dialogEl, "navi_open", {
35663
- event: e,
35664
- focusedBeforeOpen
35665
- });
35666
- };
35667
- const close = e => {
35668
- debugPopup(`"${e.type}" on ${getElementSignature(e.target)} -> closeDialog`);
35669
- const dialogEl = ref.current;
35670
- markAutofocusRestoreOnClose(dialogEl);
35671
- dialogEl.close();
35672
- cleanup();
35673
- openedRef.current = false;
35674
- dispatchCustomEvent(dialogEl, "navi_close", {
35675
- event: e
35676
- });
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
+ };
35677
35655
  };
35678
35656
  return jsx(Box, {
35679
35657
  ...rest,
@@ -35691,58 +35669,17 @@ const Dialog = props => {
35691
35669
  const rect = ref.current.getBoundingClientRect();
35692
35670
  const isBackdrop = e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom;
35693
35671
  if (isBackdrop) {
35694
- requestClose(e, {
35672
+ openController.requestClose(e, {
35695
35673
  isCancel: true
35696
35674
  });
35697
35675
  }
35698
35676
  }
35699
35677
  },
35700
35678
  onCancel: e => {
35701
- requestClose(e, {
35679
+ openController.requestClose(e, {
35702
35680
  isCancel: true
35703
35681
  });
35704
35682
  },
35705
- onnavi_request_open: e => {
35706
- const dialogEl = ref.current;
35707
- if (!dialogEl) {
35708
- return;
35709
- }
35710
- if (openedRef.current) {
35711
- return;
35712
- }
35713
- open(e);
35714
- },
35715
- onnavi_request_close: e => {
35716
- const dialogEl = ref.current;
35717
- if (!dialogEl) {
35718
- return;
35719
- }
35720
- if (!openedRef.current) {
35721
- return;
35722
- }
35723
- if (closeRequestHandler) {
35724
- let denied = false;
35725
- const closePermission = {
35726
- deny: () => {
35727
- denied = true;
35728
- },
35729
- allow: () => {
35730
- denied = false;
35731
- }
35732
- };
35733
- closeRequestHandler(e, closePermission);
35734
- if (denied) {
35735
- if (e.type === "cancel") {
35736
- e.preventDefault();
35737
- }
35738
- closePermission.allow = () => {
35739
- close(e);
35740
- };
35741
- return;
35742
- }
35743
- }
35744
- close(e);
35745
- },
35746
35683
  children: children
35747
35684
  });
35748
35685
  };
@@ -35773,7 +35710,7 @@ installImportMetaCssBuild(import.meta);const css$q = /* css */`
35773
35710
  const Popover = props => {
35774
35711
  import.meta.css = [css$q, "@jsenv/navi/src/popup/popover.jsx"];
35775
35712
  const {
35776
- requestClose,
35713
+ openController,
35777
35714
  anchorRef,
35778
35715
  scrollTrap,
35779
35716
  pointerTrap,
@@ -35785,7 +35722,6 @@ const Popover = props => {
35785
35722
  positionYFixed,
35786
35723
  spacing = 0,
35787
35724
  viewportSpacing = 0,
35788
- closeRequestHandler,
35789
35725
  ...rest
35790
35726
  } = props;
35791
35727
  const defaultRef = useRef();
@@ -35796,14 +35732,18 @@ const Popover = props => {
35796
35732
  const debugFocus = useDebugFocus();
35797
35733
  const autoFocusProps = useAutoFocus(ref, props.autoFocus);
35798
35734
 
35799
- // Tracks actual DOM state (open/closed), updated only by open() and close().
35800
- // Intentionally NOT synced to openProp on every render so the useEffect guard
35801
- // below can detect whether a prop change requires action.
35802
- const openedRef = useRef(false);
35803
- const [addCleanup, cleanup] = useCleanup();
35804
- const open = e => {
35805
- 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 => {
35806
35741
  const popoverEl = ref.current;
35742
+ if (!popoverEl) {
35743
+ return undefined;
35744
+ }
35745
+ const [cleanup, addCleanup] = createPubSub(true);
35746
+ debugPopup(e, `openPopover()`);
35807
35747
  const focusedBeforeOpen = getFocusedBeforeTransfer(e);
35808
35748
  popoverEl.showPopover();
35809
35749
  transferFocus(popoverEl, debugFocus, e, focusedBeforeOpen);
@@ -35879,22 +35819,15 @@ const Popover = props => {
35879
35819
  addCleanup(() => {
35880
35820
  rectEffect.disconnect();
35881
35821
  });
35882
- openedRef.current = true;
35883
- dispatchCustomEvent(popoverEl, "navi_open", {
35884
- event: e,
35885
- focusedBeforeOpen
35886
- });
35887
- };
35888
- const close = e => {
35889
- debugPopup(e, `closePopover()`);
35890
- const popoverEl = ref.current;
35891
- markAutofocusRestoreOnClose(popoverEl);
35892
- popoverEl.hidePopover();
35893
- cleanup();
35894
- openedRef.current = false;
35895
- dispatchCustomEvent(popoverEl, "navi_close", {
35896
- event: e
35897
- });
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
+ };
35898
35831
  };
35899
35832
  return jsxs(Box, {
35900
35833
  id: id,
@@ -35904,44 +35837,6 @@ const Popover = props => {
35904
35837
  ref: ref,
35905
35838
  baseClassName: "navi_popover",
35906
35839
  pseudoClasses: POPOVER_PSEUDO_CLASSES,
35907
- onnavi_request_open: e => {
35908
- const popoverEl = ref.current;
35909
- if (!popoverEl) {
35910
- return;
35911
- }
35912
- if (openedRef.current) {
35913
- return;
35914
- }
35915
- open(e);
35916
- },
35917
- onnavi_request_close: e => {
35918
- const popoverEl = ref.current;
35919
- if (!popoverEl) {
35920
- return;
35921
- }
35922
- if (!openedRef.current) {
35923
- return;
35924
- }
35925
- if (closeRequestHandler) {
35926
- let denied = false;
35927
- const closePermission = {
35928
- deny: () => {
35929
- denied = true;
35930
- },
35931
- allow: () => {
35932
- denied = false;
35933
- }
35934
- };
35935
- closeRequestHandler(e, closePermission);
35936
- if (denied) {
35937
- closePermission.allow = () => {
35938
- close(e);
35939
- };
35940
- return;
35941
- }
35942
- }
35943
- close(e);
35944
- },
35945
35840
  children: [jsx("div", {
35946
35841
  className: "navi_popover_backdrop",
35947
35842
  "aria-hidden": "true",
@@ -35953,7 +35848,7 @@ const Popover = props => {
35953
35848
  e.preventDefault();
35954
35849
  return;
35955
35850
  }
35956
- requestClose(e, {
35851
+ openController.requestClose(e, {
35957
35852
  isCancel: true
35958
35853
  });
35959
35854
  }
@@ -36259,7 +36154,6 @@ const PickerCustom = props => {
36259
36154
  // In "dialog" mode, enterExpanded() pushes a history entry so the back button closes.
36260
36155
  // In "popover" mode, it replaces the current history state (no history entry added).
36261
36156
  const pickerNavType = mode === "dialog" ? "push" : "replace";
36262
- const expandedRef = useRef(false);
36263
36157
  const [expanded, enterExpanded, leaveExpanded] = useNavState(popupId, {
36264
36158
  type: pickerNavType,
36265
36159
  // onLeave fires only when the state key disappears externally (back button/gesture most of the time).
@@ -36271,95 +36165,108 @@ const PickerCustom = props => {
36271
36165
  });
36272
36166
  }
36273
36167
  });
36274
- // expandedRef tracks actual open/close state, updated only by onOpen/onClose.
36275
- // NOT synced to expanded on every render so the useLayoutEffect guard below works.
36276
- const valueAtOpenRef = useRef(null);
36277
- const activeElementAtOpenRef = useRef(null);
36278
- const onOpen = e => {
36279
- expandedRef.current = true;
36280
- enterExpanded();
36281
- const focusedBeforeOpen = e.detail.focusedBeforeOpen;
36282
- activeElementAtOpenRef.current = focusedBeforeOpen;
36283
- debugFocus(e, "picked opened, store element focused", focusedBeforeOpen);
36284
- const valueAtOpen = getPickerInputUIState(ref.current);
36285
- valueAtOpenRef.current = valueAtOpen;
36286
- debugPopup(e, `picker opened, store value at open`, valueAtOpen);
36287
- };
36288
- const restoreFocus = e => {
36289
- const activeElementAtOpen = activeElementAtOpenRef.current;
36290
- activeElementAtOpenRef.current = null;
36291
- const focusoutEvent = findEvent(e, "focusout");
36292
- if (focusoutEvent) {
36293
- debugFocus(e, `closed by focusout -> let focus go away`);
36294
- return;
36295
- }
36296
- const mousedownEvent = findEvent(e, "mousedown");
36297
- if (mousedownEvent) {
36298
- debugFocus(e, "closed by mousedown -> prevent browser focus (mousedown.preventDefault())");
36299
- mousedownEvent.preventDefault();
36300
- }
36301
- debugFocus(e, `restore focus to previously focused element`, activeElementAtOpen);
36302
- activeElementAtOpen.focus({
36303
- preventScroll: true
36304
- });
36305
- return;
36306
- };
36307
- const onClose = e => {
36308
- const mousedownEvent = findEvent(e, "mousedown");
36309
- if (mousedownEvent) {
36310
- debugPopup(e, `closed by mousedown -> disable next click`);
36311
- disableClickFor();
36312
- } else {
36313
- const spaceEvent = findEvent(e, e => e.type === "keydown" && e.key === " ");
36314
- if (spaceEvent) {
36315
- // space would trigger a click on the picker button causing it to re-open immediatly after closing
36316
- debugPopup(e, `closed by space key -> prevent browser click`);
36317
- // browser won't try to dispatch click
36318
- // and our "space_to_open" will see e.defaultPrevented too and won't try to open picker
36319
- spaceEvent.preventDefault();
36320
- }
36321
- }
36322
- expandedRef.current = false;
36323
- leaveExpanded({
36324
- isBack: e.detail.isCancel
36325
- });
36326
- // Reset so the next opening re-evaluates screen size
36327
- defaultModeRef.current = null;
36328
- restoreFocus(e);
36329
- };
36330
36168
  const disableClickFor = useIgnoreClickForMousedown(ref, e => {
36331
36169
  debugPopup(e, `click ignored`);
36332
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
+ });
36333
36252
  const requestOpen = (e, detail) => {
36334
36253
  // scroll <button> of the picker into view when opening it
36335
36254
  const pickerEl = ref.current;
36336
36255
  pickerEl.scrollIntoView({
36337
36256
  block: "nearest"
36338
36257
  });
36339
- const popupEl = popupRef.current;
36340
- return dispatchCustomEvent(popupEl, "navi_request_open", {
36341
- event: e,
36342
- ...detail
36343
- });
36344
- };
36345
- const requestClose = (e = new CustomEvent("programmatic", {
36346
- detail: {}
36347
- }), detail) => {
36348
- const popupEl = popupRef.current;
36349
- return dispatchCustomEvent(popupEl, "navi_request_close", {
36350
- event: e,
36351
- ...detail
36352
- });
36258
+ openController.open(e, detail);
36353
36259
  };
36260
+ const requestClose = openController.requestClose;
36354
36261
  const open = Boolean(expanded);
36355
36262
  useLayoutEffect(() => {
36356
36263
  if (open === undefined) {
36357
36264
  return;
36358
36265
  }
36359
36266
  // Skip when the popup is already in the desired state.
36360
- // expandedRef tracks actual open/close (updated by onOpen/onClose, not by renders)
36361
- // so it is the authoritative check against feedback loops.
36362
- 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) {
36363
36270
  return;
36364
36271
  }
36365
36272
  // open_prop_change means the parent is driving the open state directly
@@ -36394,7 +36301,7 @@ const PickerCustom = props => {
36394
36301
  // requestClose(e);
36395
36302
  },
36396
36303
  "onnavi_request_open": e => {
36397
- if (expandedRef.current) {
36304
+ if (openController.opened) {
36398
36305
  return;
36399
36306
  }
36400
36307
  requestInteraction({
@@ -36409,7 +36316,9 @@ const PickerCustom = props => {
36409
36316
  requestInteraction({
36410
36317
  event: e,
36411
36318
  allowed: () => {
36412
- requestClose(e);
36319
+ requestClose(e, {
36320
+ isCancel: e.detail.isCancel
36321
+ });
36413
36322
  }
36414
36323
  });
36415
36324
  },
@@ -36420,41 +36329,7 @@ const PickerCustom = props => {
36420
36329
  });
36421
36330
  Object.assign(popupProps, {
36422
36331
  anchorRef: props.ref,
36423
- requestClose,
36424
- closeRequestHandler: (requestCloseEvent, closePermission) => {
36425
- const isCancel = requestCloseEvent.detail.isCancel;
36426
- if (isCancel) {
36427
- const pickerEl = ref.current;
36428
- const inputEl = getPickerInput(pickerEl);
36429
- const valueAtOpen = valueAtOpenRef.current;
36430
- debugPopup(requestCloseEvent, `picker cancel, restoring value at open ${JSON.stringify(valueAtOpen)}`);
36431
- dispatchRequestSetUIState(inputEl, valueAtOpen, {
36432
- event: requestCloseEvent
36433
- });
36434
- return;
36435
- }
36436
- const pickerEl = ref.current;
36437
- const inputEl = getPickerInput(pickerEl);
36438
- dispatchRequestAction(inputEl, {
36439
- event: requestCloseEvent,
36440
- name: "picker close",
36441
- prevented: () => {
36442
- closePermission.deny();
36443
- },
36444
- // Always report validation when the picker tries to close so the
36445
- // user sees what is wrong, even if the picker has no action prop.
36446
- reportOnInvalid: true,
36447
- onInvalid: () => {
36448
- closePermission.deny();
36449
- }
36450
- });
36451
- },
36452
- onnavi_open: e => {
36453
- onOpen(e);
36454
- },
36455
- onnavi_close: e => {
36456
- onClose(e);
36457
- }
36332
+ openController
36458
36333
  });
36459
36334
  {
36460
36335
  const onKeyDownShortcuts = createOnKeyDownForShortcuts({
@@ -36511,7 +36386,7 @@ const PickerCustom = props => {
36511
36386
  };
36512
36387
  },
36513
36388
  "escape": e => {
36514
- if (!expandedRef.current) {
36389
+ if (!openController.opened) {
36515
36390
  return null;
36516
36391
  }
36517
36392
  return {
@@ -36535,7 +36410,7 @@ const PickerCustom = props => {
36535
36410
  if (isWithinPopup(e.target)) {
36536
36411
  return null;
36537
36412
  }
36538
- if (expandedRef.current) {
36413
+ if (openController.opened) {
36539
36414
  return {
36540
36415
  name: "mousedown to close picker",
36541
36416
  allowed: () => requestClose(e, {
@@ -36631,8 +36506,7 @@ const PickerContentInsidePopover = props => {
36631
36506
  name: "blur",
36632
36507
  category: "interaction",
36633
36508
  allowed: () => {
36634
- dispatchCustomEvent(popoverEl, "navi_request_close", {
36635
- event: e,
36509
+ popupProps.openController.requestClose(e, {
36636
36510
  isCancel: true
36637
36511
  });
36638
36512
  }
@@ -36657,9 +36531,7 @@ const PickerContentInsidePopover = props => {
36657
36531
  if (e.button !== 0) {
36658
36532
  return;
36659
36533
  }
36660
- const popupEl = popupProps.ref.current;
36661
- dispatchCustomEvent(popupEl, "navi_request_close", {
36662
- event: e,
36534
+ popupProps.openController.requestClose(e, {
36663
36535
  isCancel: true
36664
36536
  });
36665
36537
  },
@@ -36744,6 +36616,142 @@ const useIgnoreClickForMousedown = (elementRef, onIgnore) => {
36744
36616
  return disableClickFor;
36745
36617
  };
36746
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
+
36747
36755
  const PickerNaviMinute = props => {
36748
36756
  const Next = useNextResolver();
36749
36757
  const {