@jsenv/navi 0.29.18 → 0.29.20

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.
@@ -7990,12 +7990,40 @@ const requestPseudoStateCheck = (element, detail) => {
7990
7990
  );
7991
7991
  }
7992
7992
  };
7993
- const NAVI_PSEUDO_STATE_CUSTOM_EVENT = "navi_pseudo_state";
7994
- const dispatchPseudoStateCustomEvent = (element, value, oldValue) => {
7995
- dispatchInternalCustomEvent(element, NAVI_PSEUDO_STATE_CUSTOM_EVENT, {
7996
- pseudoState: value,
7997
- oldPseudoState: oldValue,
7998
- });
7993
+ /**
7994
+ * Called back whenever `element`'s pseudo state changes.
7995
+ *
7996
+ * Kept beside the element rather than announced to the DOM: an element that
7997
+ * changes state has at most one or two interested parties, each known by name
7998
+ * (the box drawing it, the accent color reading its computed style), and a
7999
+ * CustomEvent costs its allocation and its capture/bubble walk whether anyone
8000
+ * listens or not — paid on every element, on every state change. A lookup that
8001
+ * finds nothing costs nothing.
8002
+ *
8003
+ * @param {Element} element
8004
+ * @param {(pseudoState: object, oldPseudoState: object) => void} callback
8005
+ * @returns {() => void} teardown
8006
+ */
8007
+ const subscribeToPseudoState = (element, callback) => {
8008
+ let subscriberSet = pseudoStateSubscriberSetWeakMap.get(element);
8009
+ if (!subscriberSet) {
8010
+ subscriberSet = new Set();
8011
+ pseudoStateSubscriberSetWeakMap.set(element, subscriberSet);
8012
+ }
8013
+ subscriberSet.add(callback);
8014
+ return () => {
8015
+ subscriberSet.delete(callback);
8016
+ };
8017
+ };
8018
+ const pseudoStateSubscriberSetWeakMap = new WeakMap();
8019
+ const notifyPseudoStateSubscribers = (element, value, oldValue) => {
8020
+ const subscriberSet = pseudoStateSubscriberSetWeakMap.get(element);
8021
+ if (!subscriberSet) {
8022
+ return;
8023
+ }
8024
+ for (const subscriber of subscriberSet) {
8025
+ subscriber(value, oldValue);
8026
+ }
7999
8027
  };
8000
8028
 
8001
8029
  const PSEUDO_CLASSES = {};
@@ -8809,7 +8837,7 @@ const initPseudoStyles = (
8809
8837
  const onStateChange = (value, oldValue) => {
8810
8838
  effect?.(value, oldValue);
8811
8839
  if (elementListeningPseudoState) {
8812
- dispatchPseudoStateCustomEvent(
8840
+ notifyPseudoStateSubscribers(
8813
8841
  elementListeningPseudoState,
8814
8842
  value,
8815
8843
  oldValue,
@@ -8892,11 +8920,12 @@ const initPseudoStyles = (
8892
8920
  onStateChange(state, oldState);
8893
8921
  };
8894
8922
 
8895
- element.addEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, (event) => {
8896
- const oldState = event.detail.oldPseudoState;
8897
- state = event.detail.pseudoState;
8898
- onStateChange(state, oldState);
8899
- });
8923
+ addTeardown(
8924
+ subscribeToPseudoState(element, (pseudoState, oldPseudoState) => {
8925
+ state = pseudoState;
8926
+ onStateChange(state, oldPseudoState);
8927
+ }),
8928
+ );
8900
8929
  element.addEventListener("navi_pseudo_state_request_check", () => {
8901
8930
  checkPseudoClasses();
8902
8931
  });
@@ -9014,18 +9043,7 @@ const updateStyle = (element, style, preventInitialTransition) => {
9014
9043
  styleKeySetToApply = new Set(styleKeySet);
9015
9044
  styleKeySetToApply.delete("transition");
9016
9045
  }
9017
- requestAnimationFrame(() => {
9018
- if (elementTransitionWeakMap.has(element)) {
9019
- const transitionToRestore = elementTransitionWeakMap.get(element);
9020
- if (transitionToRestore === undefined) {
9021
- element.style.transition = "";
9022
- } else {
9023
- element.style.transition = transitionToRestore;
9024
- }
9025
- elementTransitionWeakMap.delete(element);
9026
- }
9027
- elementRenderedWeakSet.add(element);
9028
- });
9046
+ afterFirstFrame(element);
9029
9047
  }
9030
9048
 
9031
9049
  // Apply all styles normally (excluding transition during anti-flicker)
@@ -9056,6 +9074,39 @@ const updateStyle = (element, style, preventInitialTransition) => {
9056
9074
  styleKeySetWeakMap.set(element, styleKeySet);
9057
9075
  };
9058
9076
 
9077
+ // One frame for every element waiting for its first one, not one frame each.
9078
+ // What this owes each element — put back the transition suppressed above, and
9079
+ // remember it has now been painted once — is a few microseconds, while asking
9080
+ // the browser for a frame costs more than that, and a page mounting thousands of
9081
+ // boxes asks thousands of times in the same tick. A Set, so an element styled
9082
+ // twice before the frame arrives is still one entry.
9083
+ const elementSetWaitingFirstFrame = new Set();
9084
+ let firstFrameScheduled = false;
9085
+ const afterFirstFrame = (element) => {
9086
+ elementSetWaitingFirstFrame.add(element);
9087
+ if (firstFrameScheduled) {
9088
+ return;
9089
+ }
9090
+ firstFrameScheduled = true;
9091
+ requestAnimationFrame(() => {
9092
+ firstFrameScheduled = false;
9093
+ const elements = [...elementSetWaitingFirstFrame];
9094
+ elementSetWaitingFirstFrame.clear();
9095
+ for (const element of elements) {
9096
+ if (elementTransitionWeakMap.has(element)) {
9097
+ const transitionToRestore = elementTransitionWeakMap.get(element);
9098
+ if (transitionToRestore === undefined) {
9099
+ element.style.transition = "";
9100
+ } else {
9101
+ element.style.transition = transitionToRestore;
9102
+ }
9103
+ elementTransitionWeakMap.delete(element);
9104
+ }
9105
+ elementRenderedWeakSet.add(element);
9106
+ }
9107
+ });
9108
+ };
9109
+
9059
9110
  /**
9060
9111
  * Keeps a DOM element in sync with `syncElement(el)` whenever syncElement changes.
9061
9112
  * - If element is already mounted: runs syncElement immediately during render.
@@ -11465,9 +11516,12 @@ const useAccentColorAttributes = (
11465
11516
  }
11466
11517
  };
11467
11518
  updateAttributes();
11468
- el.addEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttributes);
11519
+ const unsubscribeFromPseudoState = subscribeToPseudoState(
11520
+ el,
11521
+ updateAttributes,
11522
+ );
11469
11523
  return () => {
11470
- el.removeEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttributes);
11524
+ unsubscribeFromPseudoState();
11471
11525
  el.removeAttribute(LIGHT_ACCENT_ATTRIBUTE);
11472
11526
  el.removeAttribute(VERY_LIGHT_ACCENT_ATTRIBUTE);
11473
11527
  el.removeAttribute(DARK_CONTRAST_ATTRIBUTE);
@@ -12491,10 +12545,17 @@ const createAction = (callback, rootOptions = {}) => {
12491
12545
  action.debug(`${action}.prerun(${stringifyForDisplay(options)})`);
12492
12546
  return dispatchSingleAction(action, "prerun", options);
12493
12547
  };
12548
+ /**
12549
+ * Requests the action's data. An action that is already RUNNING or
12550
+ * COMPLETED already has it, so the request is a no-op there: use `rerun()`
12551
+ * to force a fresh run ("refresh", "check now", any explicit user intent to
12552
+ * go back to the network).
12553
+ */
12494
12554
  const run = (options) => {
12495
12555
  action.debug(`${action}.run(${stringifyForDisplay(options)})`);
12496
12556
  return dispatchSingleAction(action, "run", options);
12497
12557
  };
12558
+ /** Resets the action and runs it again, whatever state it is in. */
12498
12559
  const rerun = (options) => {
12499
12560
  action.debug(`${action}.rerun(${stringifyForDisplay(options)})`);
12500
12561
  return dispatchSingleAction(action, "rerun", options);
@@ -17891,6 +17952,10 @@ const useExecuteAction = (
17891
17952
  */
17892
17953
 
17893
17954
  const CLICK_TO_EXPAND_SELECTOR = "summary, [aria-expanded]";
17955
+ // A popup is written inside whatever opened it, but it is not part of it on
17956
+ // screen: a control inside a popup must not be read as a click on the region
17957
+ // the popup happens to be nested in.
17958
+ const POPUP_SELECTOR = "[navi-control='popover'], [navi-control='dialog']";
17894
17959
 
17895
17960
  /**
17896
17961
  * Cancels `event` when the control consumed a click that a surrounding
@@ -17916,13 +17981,29 @@ const preventClickToExpand = (element, event) => {
17916
17981
  }
17917
17982
  // From the parent: a control that opens something carries its own
17918
17983
  // `aria-expanded` and would find itself.
17919
- const clickToExpandRegion = parentElement.closest(CLICK_TO_EXPAND_SELECTOR);
17984
+ const clickToExpandRegion = findClickToExpandRegion(parentElement);
17920
17985
  if (!clickToExpandRegion) {
17921
17986
  return;
17922
17987
  }
17923
17988
  event.preventDefault();
17924
17989
  };
17925
17990
 
17991
+ const findClickToExpandRegion = (element) => {
17992
+ let ancestor = element;
17993
+ while (ancestor) {
17994
+ // Tested first: a popup carries `aria-expanded` of its own, so it would
17995
+ // otherwise pass for the region containing its own content.
17996
+ if (ancestor.matches(POPUP_SELECTOR)) {
17997
+ return null;
17998
+ }
17999
+ if (ancestor.matches(CLICK_TO_EXPAND_SELECTOR)) {
18000
+ return ancestor;
18001
+ }
18002
+ ancestor = ancestor.parentElement;
18003
+ }
18004
+ return null;
18005
+ };
18006
+
17926
18007
  const clickDefaultActionIsInert = (element, event) => {
17927
18008
  if (!isInertOnClick(element)) {
17928
18009
  return false;
@@ -22470,6 +22551,20 @@ const useControlProps = (props, {
22470
22551
  uiStateController
22471
22552
  }];
22472
22553
  };
22554
+ /**
22555
+ * Whether the caller has told this control what it holds. Three ways, and only
22556
+ * three — the controlled `value`, the uncontrolled `defaultValue`, a bound
22557
+ * `signal` — the same three createControlInfo below reads, in that precedence
22558
+ * order, to seed the state.
22559
+ *
22560
+ * None of them and the control starts with nothing: whatever it ends up holding
22561
+ * has to come from somewhere else — a parent form distributing its own value,
22562
+ * or, for a picker, the control sitting in its popup (see
22563
+ * useUIFacadeStateController). Anyone who needs to know whether a control can
22564
+ * answer for itself before anything mounts asks this rather than re-listing the
22565
+ * props, which is how `signal` came to be forgotten.
22566
+ */
22567
+ const isControlValueGivenByProps = props => Object.hasOwn(props, "value") || Object.hasOwn(props, "defaultValue") || Object.hasOwn(props, "signal");
22473
22568
  const createControlInfo = (props, {
22474
22569
  controlType
22475
22570
  }) => {
@@ -29754,37 +29849,14 @@ const getActionResultProperties = (action) => {
29754
29849
  return actionResultPropertiesMap.get(action);
29755
29850
  };
29756
29851
 
29757
- /*
29758
- * Default autorerun behavior explanation:
29759
- * GET: false (RECOMMENDED)
29760
- * What happens:
29761
- * - GET actions are reset by DELETE operations (not rerun)
29762
- * - DELETE operation on the displayed item would display nothing in the UI (action is in IDLE state)
29763
- * - PUT/PATCH operations update UI via signals, no rerun needed
29764
- * - This approach minimizes unnecessary API calls
29765
- *
29766
- * How to handle:
29767
- * - Applications can provide custom UI for deleted items (e.g., "Item not found")
29768
- * - Or redirect users to appropriate pages (e.g., back to list view)
29769
- *
29770
- * Alternative (NOT RECOMMENDED):
29771
- * - Use GET: ["DELETE"] to rerun and display 404 error received from backend
29772
- * - Poor UX: users expect immediate feedback, not loading + error state
29773
- *
29774
- * GET_MANY: ["POST"]
29775
- * - POST: New items may or may not appear in lists (depends on filters, pagination, etc.)
29776
- * Backend determines visibility better than client-side logic
29777
- * - DELETE: Excluded by default because:
29778
- * • UI handles deletions via store signals (selectAll filters out deleted items)
29779
- * • DELETE operations rarely change list content beyond item removal
29780
- * • Avoids unnecessary API calls (can be overridden if needed)
29781
- */
29852
+ // PUT/PATCH results update the UI through the store, and DELETE resets the GET
29853
+ // instead of rerunning it, so a GET rerun would only ever cost a request.
29854
+ // A POST is the one case the client cannot decide alone: whether a new item
29855
+ // belongs to a list depends on filters/pagination the backend owns.
29856
+ // Rationale in full, plus when to override: docs/list_refresh.md
29782
29857
  const defaultRerunOn = {
29783
29858
  GET: false,
29784
- GET_MANY: [
29785
- "POST",
29786
- // "DELETE"
29787
- ],
29859
+ GET_MANY: ["POST"],
29788
29860
  };
29789
29861
 
29790
29862
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
@@ -46296,7 +46368,7 @@ const PickerCustom = props => {
46296
46368
  // opens anything. Told a value — even an empty one — the picker owns it
46297
46369
  // and pushes it down instead, leaving the popup free to build its
46298
46370
  // content only when it is first opened (see popup_content_mount.js).
46299
- mountWhenClosed: !Object.hasOwn(props, "value") && !Object.hasOwn(props, "defaultValue"),
46371
+ mountWhenClosed: !isControlValueGivenByProps(props),
46300
46372
  // Not on pickerProps (the trigger): commands.js's own
46301
46373
  // resolveClosestExpandable() does `el.closest("[aria-expanded]")` to
46302
46374
  // find where to dispatch navi_request_open/navi_request_close — and