@jsenv/navi 0.27.60 → 0.27.62

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.
@@ -14696,28 +14696,37 @@ const setupBrowserIntegrationViaHistory = ({
14696
14696
  };
14697
14697
 
14698
14698
  let abortController = null;
14699
- const handleRoutingTask = (
14700
- url,
14701
- {
14699
+ const handleRoutingTask = (url, options) => {
14700
+ const isSameUrl = url === window.location.href;
14701
+ const {
14702
14702
  reason,
14703
14703
  navigationType, // "load", "reload", "replace", "push", "traverse"
14704
14704
  state,
14705
- },
14706
- ) => {
14707
- const isSameUrl = url === window.location.href;
14705
+ } = options;
14708
14706
 
14709
14707
  if (navigationType === "push" || navigationType === "replace") {
14710
- // Merge onto the current state so existing useNavState entries (e.g. an
14711
- // open dialog/popover) survive URL changes triggered by signal updates.
14712
- // "traverse" is intentionally excluded: it restores the exact historical
14713
- // state from the history entry, which is the source of truth for back/forward.
14714
- const currentState = getDocumentState() || {};
14715
14708
  markUrlAsVisited(url);
14716
- const effectiveState = {
14717
- ...currentState,
14718
- ...(state || {}),
14709
+ // undefined inherit current state (link click, neutral navigation)
14710
+ // null → explicit reset (no nav-state keys carried over)
14711
+ // {...} → explicit state from enter()/leave(), already built from currentState
14712
+ // When state is given it's responsability of the caller to ensure it inherits document state (or not, you want it 99% of the time)
14713
+ let effectiveState;
14714
+ const sharedState = {
14719
14715
  jsenv_visited_urls: Array.from(visitedUrlSet),
14720
14716
  };
14717
+ if (state === undefined) {
14718
+ effectiveState = {
14719
+ ...(getDocumentState() || {}),
14720
+ ...sharedState,
14721
+ };
14722
+ } else if (state === null) {
14723
+ effectiveState = sharedState;
14724
+ } else if (state) {
14725
+ effectiveState = {
14726
+ ...state,
14727
+ ...sharedState,
14728
+ };
14729
+ }
14721
14730
  if (navigationType === "push") {
14722
14731
  window.history.pushState(effectiveState, null, url);
14723
14732
  } else {
@@ -14803,7 +14812,6 @@ const setupBrowserIntegrationViaHistory = ({
14803
14812
  handleRoutingTask(href, {
14804
14813
  reason: `"click" on a[href="${href}"]`,
14805
14814
  navigationType: "push",
14806
- state: null,
14807
14815
  });
14808
14816
  },
14809
14817
  { capture: true },
@@ -14828,7 +14836,7 @@ const setupBrowserIntegrationViaHistory = ({
14828
14836
  });
14829
14837
  });
14830
14838
 
14831
- const navTo = async (url, { replace, state = null } = {}) => {
14839
+ const navTo = async (url, { replace, state } = {}) => {
14832
14840
  handleRoutingTask(url, {
14833
14841
  reason: `navTo called with "${url}"`,
14834
14842
  navigationType: replace ? "replace" : "push",
@@ -14999,41 +15007,33 @@ const NO_OP = () => {};
14999
15007
  const NO_ID_GIVEN = [undefined, NO_OP, NO_OP];
15000
15008
  const useNavStateBasic = (
15001
15009
  id,
15002
- initialValue,
15003
- { debug, type = "replace", onLeave } = {},
15010
+ { debug, type = "replace", onLeave, defaultValue } = {},
15004
15011
  ) => {
15005
15012
  // Hooks must be called unconditionally — before the !id early return.
15006
15013
  const state = documentStateSignal.value;
15007
- const valueInState = id
15008
- ? state !== null
15009
- ? state[id]
15010
- : undefined
15011
- : undefined;
15014
+ // Key presence is the flag — the value may be anything, including undefined.
15015
+ const keyInState = Boolean(id && state && Object.hasOwn(state, id));
15012
15016
  const onLeaveRef = useRef(onLeave);
15013
15017
  onLeaveRef.current = onLeave;
15014
- const prevValueInStateRef = useRef(valueInState);
15018
+ const prevKeyInStateRef = useRef(keyInState);
15015
15019
  // enteredRef tracks whether enter() was called without a matching leave() yet.
15016
15020
  // It lets the effect distinguish an external disappearance (back button → fire onLeave)
15017
15021
  // from a programmatic one (leave() already set it to false before the state updates).
15018
15022
  const enteredRef = useRef(false);
15019
15023
  useEffect(() => {
15020
- const prevValue = prevValueInStateRef.current;
15021
- prevValueInStateRef.current = valueInState;
15022
- if (
15023
- prevValue !== undefined &&
15024
- valueInState === undefined &&
15025
- enteredRef.current
15026
- ) {
15024
+ const prevKeyInState = prevKeyInStateRef.current;
15025
+ prevKeyInStateRef.current = keyInState;
15026
+ if (prevKeyInState && !keyInState && enteredRef.current) {
15027
15027
  enteredRef.current = false;
15028
15028
  onLeaveRef.current?.();
15029
15029
  }
15030
- }, [valueInState]);
15030
+ }, [keyInState]);
15031
15031
 
15032
15032
  if (!id) {
15033
15033
  return NO_ID_GIVEN;
15034
15034
  }
15035
15035
 
15036
- const currentValue = valueInState !== undefined ? valueInState : initialValue;
15036
+ const currentValue = keyInState ? state[id] : defaultValue;
15037
15037
 
15038
15038
  if (debug) {
15039
15039
  console.debug(`useNavState(${id}) current value is ${currentValue}`);
@@ -15045,30 +15045,36 @@ const useNavStateBasic = (
15045
15045
  // extra data with the entry when needed.
15046
15046
  const enter = (value = "on") => {
15047
15047
  enteredRef.current = true;
15048
- const currentState = browserIntegration.getDocumentState() || {};
15049
- if (currentState[id] === value) {
15048
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
15049
+ if (Object.hasOwn(currentStateCopy, id) && currentStateCopy[id] === value) {
15050
15050
  return;
15051
15051
  }
15052
- const newState = { ...currentState, [id]: value };
15052
+ currentStateCopy[id] = value;
15053
15053
  navTo(window.location.href, {
15054
15054
  replace: type !== "push",
15055
- state: newState,
15055
+ state: currentStateCopy,
15056
15056
  });
15057
15057
  };
15058
15058
 
15059
15059
  // leave(): navigate AWAY FROM this state (navBack in push mode, replace in replace mode).
15060
- const leave = () => {
15060
+ // isBack: when true (cancel close in push mode), call history.back() to restore the
15061
+ // pre-open state — discards any in-progress edits.
15062
+ // When false (confirmed close), replace the pushed entry instead: preserves the
15063
+ // current URL state (e.g. a new picker value) while removing the popup key.
15064
+ const leave = ({ isBack } = {}) => {
15061
15065
  enteredRef.current = false;
15062
- const currentState = browserIntegration.getDocumentState() || {};
15063
- if (!Object.hasOwn(currentState, id)) {
15066
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
15067
+ if (!Object.hasOwn(currentStateCopy, id)) {
15064
15068
  return;
15065
15069
  }
15066
- if (type === "push") {
15070
+ if (type === "push" && isBack) {
15067
15071
  browserIntegration.navBack();
15068
15072
  } else {
15069
- const newState = { ...currentState };
15070
- delete newState[id];
15071
- navTo(window.location.href, { replace: true, state: newState });
15073
+ delete currentStateCopy[id];
15074
+ navTo(window.location.href, {
15075
+ replace: true,
15076
+ state: currentStateCopy,
15077
+ });
15072
15078
  }
15073
15079
  };
15074
15080
 
@@ -15082,9 +15088,6 @@ const useNavStateBasic = (
15082
15088
  * @param {string} id
15083
15089
  * Unique key used to store the value in document state. Must be stable across renders.
15084
15090
  *
15085
- * @param {*} initialValue
15086
- * Value returned when `id` is absent from document state (e.g. before enter() is called).
15087
- *
15088
15091
  * @param {object} [options]
15089
15092
  * @param {"push"|"replace"} [options.type="replace"]
15090
15093
  * Controls how enter() adds the state to browser history.
@@ -15093,9 +15096,11 @@ const useNavStateBasic = (
15093
15096
  * @param {() => void} [options.onLeave]
15094
15097
  * Called when the state key disappears **externally** — e.g. the user presses the browser
15095
15098
  * back button. Not called when leave() is invoked programmatically.
15099
+ * @param {*} [options.defaultValue]
15100
+ * Value returned when `id` is absent from document state. Defaults to `undefined`.
15096
15101
  *
15097
15102
  * @returns {[value, enter, leave]}
15098
- * - `value`: current value from document state, or `initialValue` when the key is absent.
15103
+ * - `value`: current value from document state, or `defaultValue` when the key is absent.
15099
15104
  * - `enter(value = "on")`: navigate TO this state (stores `value` under `id`).
15100
15105
  * Calling without an argument stores `"on"` — the presence of the key is enough to match;
15101
15106
  * the value allows associating extra data when needed.
@@ -22710,6 +22715,23 @@ const useUIStateController = (
22710
22715
  if (e.type === "facade_propagate_up") {
22711
22716
  notifyParentAboutChildUIAction(e, { stateChanged: true });
22712
22717
  }
22718
+ // Exception: state_prop_change can only fire on a control with its own
22719
+ // controlled state/value prop (see hasStateProp above) — groups never
22720
+ // cascade state down into such children (they're explicitly skipped,
22721
+ // see shouldPropagateStateToChild/hasStateProp checks), so this change
22722
+ // can never be an echo of the parent's own cascade. The loop risk this
22723
+ // suppression exists for only applies when the parent itself just pushed
22724
+ // this value down, which requires the parent to be controlled (have its
22725
+ // own state/value prop). When the parent is "stateless" (uncontrolled),
22726
+ // notifying it is always safe and necessary — otherwise its aggregated
22727
+ // state silently drifts out of sync with this child.
22728
+ if (
22729
+ e.type === "state_prop_change" &&
22730
+ parentUIStateController &&
22731
+ !parentUIStateController.hasStateProp
22732
+ ) {
22733
+ notifyParentAboutChildUIAction(e, { stateChanged: true });
22734
+ }
22713
22735
  return true;
22714
22736
  }
22715
22737
  notifyParentAboutChildUIAction(e, { stateChanged: true });
@@ -32222,6 +32244,7 @@ const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
32222
32244
  // Numeric signal types must not fall through to the native type="number"
32223
32245
  // (which adds spinner buttons and has poor UX) — they map to navi_number instead.
32224
32246
  const VALIDITY_TYPE_TO_INPUT_TYPE = {
32247
+ boolean: "checkbox",
32225
32248
  number: "navi_number",
32226
32249
  integer: "navi_number",
32227
32250
  percentage: "navi_percentage",
@@ -33080,7 +33103,9 @@ const InputSearchUI = ({
33080
33103
  const InputEmail = props => {
33081
33104
  const Next = useNextResolver();
33082
33105
  return jsx(Next, {
33083
- ui: jsx(InputEmailUI, {}),
33106
+ ui: jsx(InputEmailUI, {
33107
+ icon: props.icon
33108
+ }),
33084
33109
  ...props
33085
33110
  });
33086
33111
  };
@@ -33630,6 +33655,19 @@ const css$x = /* css */`
33630
33655
  outline: none;
33631
33656
  -webkit-tap-highlight-color: var(--navi-control-tap-highlight-color);
33632
33657
 
33658
+ &::placeholder {
33659
+ color: var(--x-placeholder-color);
33660
+ }
33661
+ &:-webkit-autofill {
33662
+ -webkit-box-shadow: 0 0 0 1000px var(--x-background-color) inset;
33663
+ }
33664
+ &:-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
+ -webkit-text-fill-color: var(--x-color) !important;
33669
+ }
33670
+
33633
33671
  &[type="search"] {
33634
33672
  -webkit-appearance: textfield;
33635
33673
 
@@ -33747,16 +33785,6 @@ const css$x = /* css */`
33747
33785
  }
33748
33786
  }
33749
33787
  }
33750
-
33751
- .navi_input .navi_control_input::placeholder {
33752
- color: var(--x-placeholder-color);
33753
- }
33754
- .navi_input .navi_control_input:-internal-autofill-selected {
33755
- /* Webkit is putting some nasty styles after automplete that look as follow */
33756
- /* input:-internal-autofill-selected { color: FieldText !important; } */
33757
- /* Fortunately we can override it as follow */
33758
- -webkit-text-fill-color: var(--x-color) !important;
33759
- }
33760
33788
  `;
33761
33789
  const InputHeadlessResolver = props => {
33762
33790
  const Next = useNextResolver();
@@ -35557,7 +35585,8 @@ installImportMetaCssBuild(import.meta);const css$r = /* css */`
35557
35585
  const Dialog = props => {
35558
35586
  import.meta.css = [css$r, "@jsenv/navi/src/popup/dialog.jsx"];
35559
35587
  const {
35560
- open: openProp = false,
35588
+ // requestOpen,
35589
+ requestClose,
35561
35590
  anchorRef,
35562
35591
  children,
35563
35592
  scrollTrap,
@@ -35635,7 +35664,7 @@ const Dialog = props => {
35635
35664
  focusedBeforeOpen
35636
35665
  });
35637
35666
  };
35638
- const close = (e, detail = {}) => {
35667
+ const close = e => {
35639
35668
  debugPopup(`"${e.type}" on ${getElementSignature(e.target)} -> closeDialog`);
35640
35669
  const dialogEl = ref.current;
35641
35670
  markAutofocusRestoreOnClose(dialogEl);
@@ -35643,70 +35672,9 @@ const Dialog = props => {
35643
35672
  cleanup();
35644
35673
  openedRef.current = false;
35645
35674
  dispatchCustomEvent(dialogEl, "navi_close", {
35646
- event: e,
35647
- ...detail
35675
+ event: e
35648
35676
  });
35649
35677
  };
35650
- const onRequestOpen = e => {
35651
- const dialogEl = ref.current;
35652
- if (!dialogEl) {
35653
- return;
35654
- }
35655
- if (openedRef.current) {
35656
- return;
35657
- }
35658
- open(e);
35659
- };
35660
- const onRequestClose = (e, detail = {}) => {
35661
- const dialogEl = ref.current;
35662
- if (!dialogEl) {
35663
- return;
35664
- }
35665
- if (!openedRef.current) {
35666
- return;
35667
- }
35668
- if (closeRequestHandler) {
35669
- let denied = false;
35670
- const closePermission = {
35671
- deny: () => {
35672
- denied = true;
35673
- },
35674
- allow: () => {
35675
- denied = false;
35676
- }
35677
- };
35678
- closeRequestHandler(e, closePermission, detail);
35679
- if (denied) {
35680
- if (e.type === "cancel") {
35681
- e.preventDefault();
35682
- }
35683
- closePermission.allow = () => {
35684
- close(e, detail);
35685
- };
35686
- return;
35687
- }
35688
- }
35689
- close(e, detail);
35690
- };
35691
- useLayoutEffect(() => {
35692
- if (openProp === undefined) {
35693
- return;
35694
- }
35695
- // Skip when the popover is already in the desired state.
35696
- // This avoids a feedback loop: our own close/open dispatches navi_close/navi_open,
35697
- // the parent updates the prop, and the effect would fire again for a change we
35698
- // already handled. Comparing against openedRef is the authoritative check because
35699
- // it reflects the actual DOM state, not the React render cycle.
35700
- if (openProp === openedRef.current) {
35701
- return;
35702
- }
35703
- const e = new CustomEvent("open_prop_change");
35704
- if (openProp) {
35705
- onRequestOpen(e);
35706
- } else {
35707
- onRequestClose(e);
35708
- }
35709
- }, [openProp]);
35710
35678
  return jsx(Box, {
35711
35679
  ...rest,
35712
35680
  ...autoFocusProps,
@@ -35723,20 +35691,57 @@ const Dialog = props => {
35723
35691
  const rect = ref.current.getBoundingClientRect();
35724
35692
  const isBackdrop = e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom;
35725
35693
  if (isBackdrop) {
35726
- onRequestClose(e, {
35727
- isClickOutside: true
35694
+ requestClose(e, {
35695
+ isCancel: true
35728
35696
  });
35729
35697
  }
35730
35698
  }
35731
35699
  },
35732
35700
  onCancel: e => {
35733
- onRequestClose(e);
35701
+ requestClose(e, {
35702
+ isCancel: true
35703
+ });
35734
35704
  },
35735
35705
  onnavi_request_open: e => {
35736
- onRequestOpen(e);
35706
+ const dialogEl = ref.current;
35707
+ if (!dialogEl) {
35708
+ return;
35709
+ }
35710
+ if (openedRef.current) {
35711
+ return;
35712
+ }
35713
+ open(e);
35737
35714
  },
35738
35715
  onnavi_request_close: e => {
35739
- onRequestClose(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);
35740
35745
  },
35741
35746
  children: children
35742
35747
  });
@@ -35768,7 +35773,7 @@ installImportMetaCssBuild(import.meta);const css$q = /* css */`
35768
35773
  const Popover = props => {
35769
35774
  import.meta.css = [css$q, "@jsenv/navi/src/popup/popover.jsx"];
35770
35775
  const {
35771
- open: openProp = false,
35776
+ requestClose,
35772
35777
  anchorRef,
35773
35778
  scrollTrap,
35774
35779
  pointerTrap,
@@ -35880,7 +35885,7 @@ const Popover = props => {
35880
35885
  focusedBeforeOpen
35881
35886
  });
35882
35887
  };
35883
- const close = (e, detail = {}) => {
35888
+ const close = e => {
35884
35889
  debugPopup(e, `closePopover()`);
35885
35890
  const popoverEl = ref.current;
35886
35891
  markAutofocusRestoreOnClose(popoverEl);
@@ -35888,69 +35893,9 @@ const Popover = props => {
35888
35893
  cleanup();
35889
35894
  openedRef.current = false;
35890
35895
  dispatchCustomEvent(popoverEl, "navi_close", {
35891
- event: e,
35892
- ...detail
35896
+ event: e
35893
35897
  });
35894
35898
  };
35895
- const onRequestOpen = e => {
35896
- const popoverEl = ref.current;
35897
- if (!popoverEl) {
35898
- return;
35899
- }
35900
- if (openedRef.current) {
35901
- return;
35902
- }
35903
- open(e);
35904
- };
35905
- const onRequestClose = (e, detail = {}) => {
35906
- const popoverEl = ref.current;
35907
- if (!popoverEl) {
35908
- return;
35909
- }
35910
- if (!openedRef.current) {
35911
- return;
35912
- }
35913
- if (closeRequestHandler) {
35914
- let denied = false;
35915
- const closePermission = {
35916
- deny: () => {
35917
- denied = true;
35918
- },
35919
- allow: () => {
35920
- denied = false;
35921
- }
35922
- };
35923
- closeRequestHandler(e, closePermission, detail);
35924
- if (denied) {
35925
- closePermission.allow = () => {
35926
- close(e, detail);
35927
- };
35928
- return;
35929
- }
35930
- }
35931
- close(e, detail);
35932
- };
35933
- useLayoutEffect(() => {
35934
- if (openProp === undefined) {
35935
- return;
35936
- }
35937
- // Skip when the popover is already in the desired state.
35938
- // This avoids a feedback loop: our own close/open dispatches navi_close/navi_open,
35939
- // the parent updates the prop, and the effect would fire again for a change we
35940
- // already handled. Comparing against openedRef is the authoritative check because
35941
- // it reflects the actual DOM state, not the React render cycle.
35942
- if (openProp === openedRef.current) {
35943
- return;
35944
- }
35945
- const e = new CustomEvent("open_prop_change", {
35946
- detail: {}
35947
- });
35948
- if (openProp) {
35949
- onRequestOpen(e);
35950
- } else {
35951
- onRequestClose(e);
35952
- }
35953
- }, [openProp]);
35954
35899
  return jsxs(Box, {
35955
35900
  id: id,
35956
35901
  popover: "manual",
@@ -35960,10 +35905,42 @@ const Popover = props => {
35960
35905
  baseClassName: "navi_popover",
35961
35906
  pseudoClasses: POPOVER_PSEUDO_CLASSES,
35962
35907
  onnavi_request_open: e => {
35963
- onRequestOpen(e);
35908
+ const popoverEl = ref.current;
35909
+ if (!popoverEl) {
35910
+ return;
35911
+ }
35912
+ if (openedRef.current) {
35913
+ return;
35914
+ }
35915
+ open(e);
35964
35916
  },
35965
35917
  onnavi_request_close: e => {
35966
- onRequestClose(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);
35967
35944
  },
35968
35945
  children: [jsx("div", {
35969
35946
  className: "navi_popover_backdrop",
@@ -35976,8 +35953,8 @@ const Popover = props => {
35976
35953
  e.preventDefault();
35977
35954
  return;
35978
35955
  }
35979
- onRequestClose(e, {
35980
- isClickOutside: true
35956
+ requestClose(e, {
35957
+ isCancel: true
35981
35958
  });
35982
35959
  }
35983
35960
  }), children]
@@ -36266,8 +36243,7 @@ const PickerCustom = props => {
36266
36243
  const popupRef = useRef(null);
36267
36244
  popupProps.ref = popupRef;
36268
36245
  // aria-controls + id
36269
- const autoId = useId();
36270
- const popupId = `picker_popup_${autoId}`;
36246
+ const popupId = `${props.id}_picker_popup`;
36271
36247
  {
36272
36248
  Object.assign(pickerProps, {
36273
36249
  "aria-controls": popupId
@@ -36284,7 +36260,7 @@ const PickerCustom = props => {
36284
36260
  // In "popover" mode, it replaces the current history state (no history entry added).
36285
36261
  const pickerNavType = mode === "dialog" ? "push" : "replace";
36286
36262
  const expandedRef = useRef(false);
36287
- const [expanded, enterExpanded, leaveExpanded] = useNavState(popupId, false, {
36263
+ const [expanded, enterExpanded, leaveExpanded] = useNavState(popupId, {
36288
36264
  type: pickerNavType,
36289
36265
  // onLeave fires only when the state key disappears externally (back button/gesture most of the time).
36290
36266
  onLeave: () => {
@@ -36295,7 +36271,8 @@ const PickerCustom = props => {
36295
36271
  });
36296
36272
  }
36297
36273
  });
36298
- expandedRef.current = Boolean(expanded);
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.
36299
36276
  const valueAtOpenRef = useRef(null);
36300
36277
  const activeElementAtOpenRef = useRef(null);
36301
36278
  const onOpen = e => {
@@ -36343,7 +36320,9 @@ const PickerCustom = props => {
36343
36320
  }
36344
36321
  }
36345
36322
  expandedRef.current = false;
36346
- leaveExpanded();
36323
+ leaveExpanded({
36324
+ isBack: e.detail.isCancel
36325
+ });
36347
36326
  // Reset so the next opening re-evaluates screen size
36348
36327
  defaultModeRef.current = null;
36349
36328
  restoreFocus(e);
@@ -36351,7 +36330,7 @@ const PickerCustom = props => {
36351
36330
  const disableClickFor = useIgnoreClickForMousedown(ref, e => {
36352
36331
  debugPopup(e, `click ignored`);
36353
36332
  });
36354
- const requestOpen = e => {
36333
+ const requestOpen = (e, detail) => {
36355
36334
  // scroll <button> of the picker into view when opening it
36356
36335
  const pickerEl = ref.current;
36357
36336
  pickerEl.scrollIntoView({
@@ -36359,18 +36338,47 @@ const PickerCustom = props => {
36359
36338
  });
36360
36339
  const popupEl = popupRef.current;
36361
36340
  return dispatchCustomEvent(popupEl, "navi_request_open", {
36362
- event: e
36341
+ event: e,
36342
+ ...detail
36363
36343
  });
36364
36344
  };
36365
- const requestClose = (e = new CustomEvent("programmatic"), {
36366
- isCancel = false
36367
- } = {}) => {
36345
+ const requestClose = (e = new CustomEvent("programmatic", {
36346
+ detail: {}
36347
+ }), detail) => {
36368
36348
  const popupEl = popupRef.current;
36369
36349
  return dispatchCustomEvent(popupEl, "navi_request_close", {
36370
36350
  event: e,
36371
- isCancel
36351
+ ...detail
36372
36352
  });
36373
36353
  };
36354
+ const open = Boolean(expanded);
36355
+ useLayoutEffect(() => {
36356
+ if (open === undefined) {
36357
+ return;
36358
+ }
36359
+ // 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) {
36363
+ return;
36364
+ }
36365
+ // open_prop_change means the parent is driving the open state directly
36366
+ // (e.g. back-button navigation flipped openProp to false before onLeave fires).
36367
+ // Always treat it as cancel — the user's in-progress edit should be discarded.
36368
+ if (open) {
36369
+ requestOpen(new CustomEvent("open_by_prop", {
36370
+ detail: {}
36371
+ }), {
36372
+ isCancel: true
36373
+ });
36374
+ } else {
36375
+ requestClose(new CustomEvent("close_by_prop", {
36376
+ detail: {}
36377
+ }), {
36378
+ isCancel: true
36379
+ });
36380
+ }
36381
+ }, [open]);
36374
36382
  const requestInteraction = options => {
36375
36383
  dispatchRequestInteraction(ref.current, options);
36376
36384
  };
@@ -36412,16 +36420,9 @@ const PickerCustom = props => {
36412
36420
  });
36413
36421
  Object.assign(popupProps, {
36414
36422
  anchorRef: props.ref,
36415
- open: Boolean(expanded),
36416
- closeRequestHandler: (requestCloseEvent, closePermission, {
36417
- isClickOutside
36418
- } = {}) => {
36419
- const cancelEvent = findEvent(requestCloseEvent, eInChain => eInChain.type === "navi_request_close" && eInChain.detail.isCancel);
36420
- // open_prop_change means the parent is driving the open state directly
36421
- // (e.g. back-button navigation flipped openProp to false before onLeave fires).
36422
- // Always treat it as cancel — the user's in-progress edit should be discarded.
36423
- const isPropDrivenClose = requestCloseEvent.type === "open_prop_change";
36424
- const isCancel = isClickOutside || Boolean(cancelEvent) || isPropDrivenClose;
36423
+ requestClose,
36424
+ closeRequestHandler: (requestCloseEvent, closePermission) => {
36425
+ const isCancel = requestCloseEvent.detail.isCancel;
36425
36426
  if (isCancel) {
36426
36427
  const pickerEl = ref.current;
36427
36428
  const inputEl = getPickerInput(pickerEl);