@jsenv/navi 0.28.0 → 0.28.2

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.
@@ -2,7 +2,7 @@
2
2
  * AI reading this file: read ../docs/AI_INSTRUCTIONS.md for context on
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
- import { installImportMetaCssBuild, windowWidthSignal } from "./jsenv_navi_side_effects.js";
5
+ import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal } from "./jsenv_navi_side_effects.js";
6
6
  import { isValidElement, createContext, h, toChildArray, render, Fragment, cloneElement } from "preact";
7
7
  import { useErrorBoundary, useLayoutEffect, useEffect, useContext, useMemo, useRef, useState, useCallback, useId } from "preact/hooks";
8
8
  import { jsxs, jsx, Fragment as Fragment$1 } from "preact/jsx-runtime";
@@ -6821,6 +6821,7 @@ const VISUAL_PROPS = {
6821
6821
  scrollbarWidth: PASS_THROUGH,
6822
6822
  scrollbarGutter: PASS_THROUGH,
6823
6823
  scrollMarginBlock: PASS_THROUGH,
6824
+ scrollMarginInline: PASS_THROUGH,
6824
6825
  scrollMargin: PASS_THROUGH,
6825
6826
  };
6826
6827
  const CONTENT_PROPS = {
@@ -7076,7 +7077,37 @@ const sizeSpacingKeySet = new Set(Object.keys(SIZE_MAP));
7076
7077
  const isSizeSpacingKey = (key) => {
7077
7078
  return sizeSpacingKeySet.has(key);
7078
7079
  };
7080
+ // Viewport-relative units, resolved to pixels here because a JS consumer (popup
7081
+ // positioning) needs an actual number, not a length only CSS can evaluate.
7082
+ // "vvw"/"vvh" are navi's own: the *visual* viewport, which — unlike vw/dvw —
7083
+ // shrinks when the mobile virtual keyboard opens (see layout/responsive.js), so
7084
+ // they are what a popup meant to stay clear of the keyboard should use.
7085
+ const VIEWPORT_UNIT_SIGNALS = {
7086
+ vvw: visualViewportWidthSignal,
7087
+ vvh: visualViewportHeightSignal,
7088
+ vw: windowWidthSignal,
7089
+ vh: windowHeightSignal,
7090
+ dvw: windowWidthSignal,
7091
+ dvh: windowHeightSignal,
7092
+ };
7093
+ const VIEWPORT_LENGTH_REGEX = /^(-?\d+(?:\.\d+)?)(vvw|vvh|dvw|dvh|vw|vh)$/;
7094
+ const resolveViewportLength = (size) => {
7095
+ if (typeof size !== "string") {
7096
+ return null;
7097
+ }
7098
+ const match = VIEWPORT_LENGTH_REGEX.exec(size);
7099
+ if (!match) {
7100
+ return null;
7101
+ }
7102
+ const [, amount, unit] = match;
7103
+ return (parseFloat(amount) / 100) * VIEWPORT_UNIT_SIGNALS[unit].value;
7104
+ };
7105
+
7079
7106
  const resolveSpacingSize = (size, element, property = "padding") => {
7107
+ const viewportLength = resolveViewportLength(size);
7108
+ if (viewportLength !== null) {
7109
+ return viewportLength;
7110
+ }
7080
7111
  return normalizeStyle(SIZE_MAP[size] || size, property, "js", element);
7081
7112
  };
7082
7113
 
@@ -21286,9 +21317,11 @@ const createControlInteraction = (
21286
21317
  // Check managed controls — a non-interactable child blocks the parent,
21287
21318
  // UNLESS the child's failing constraint has `ignoredByParents: true`
21288
21319
  // (e.g. a disabled child inside a group should not prevent the group from acting).
21320
+ // Only the children that are reachable alongside the parent take part: a
21321
+ // picker's popup content is excluded, see getInteractionBlockingControls.
21289
21322
  failingManagedInteraction = null;
21290
21323
  if (!interactionFailedConstraintInfo) {
21291
- for (const mc of controller.getManagedControls()) {
21324
+ for (const mc of controller.getInteractionBlockingControls()) {
21292
21325
  const mci = mc.rules.interaction;
21293
21326
  if (!mci) {
21294
21327
  continue;
@@ -21691,8 +21724,12 @@ const createDisplayedEvent = (ancestor) => {
21691
21724
  *
21692
21725
  * @param {import("preact/hooks").Ref<HTMLElement>} focusableElementRef
21693
21726
  * Ref to the element to focus.
21694
- * @param {boolean} autoFocus
21695
- * When false the hook is a no-op.
21727
+ * @param {boolean|"fallback"|"restore"} autoFocus
21728
+ * When false the hook is a no-op. `"fallback"` claims focus only when nothing
21729
+ * more specific already did. `"restore"` never claims focus on open; it only
21730
+ * gets focus back from an ancestor that closed while it was focused (see
21731
+ * focus_transfer.js) — typically a text input that must not pop the mobile
21732
+ * keyboard open every time, but should stay where the user left it.
21696
21733
  * @param {object} [options]
21697
21734
  * @param {boolean} [options.preventScroll]
21698
21735
  * Passed as `preventScroll` to `element.focus()`. Defaults to true to suppress
@@ -21715,6 +21752,11 @@ const useAutoFocus = (
21715
21752
  if (!autoFocus) {
21716
21753
  return () => {};
21717
21754
  }
21755
+ if (autoFocus === "restore") {
21756
+ // "restore" never claims focus on its own; the only way it gets focus is
21757
+ // an ancestor reopening and handing it back (see focus_transfer.js).
21758
+ return () => {};
21759
+ }
21718
21760
  const focusableElement = focusableElementRef.current;
21719
21761
  if (!focusableElement) {
21720
21762
  return () => {};
@@ -22750,6 +22792,7 @@ const useRenderScope = (init, update) => {
22750
22792
  * props: Object;
22751
22793
  * ref: Ref; // Used to dispatch DOM events
22752
22794
  * getManagedControls(): UIStateController[]; // Returns controls whose validity is managed by this controller
22795
+ * getInteractionBlockingControls(): UIStateController[]; // Subset of the above whose busy state also blocks interacting with this controller
22753
22796
  * }
22754
22797
  * ```
22755
22798
  */
@@ -22867,6 +22910,10 @@ const useUIStateController = (
22867
22910
  }
22868
22911
  return [];
22869
22912
  },
22913
+ // A facade child lives inside the control's own popup, so it is out of
22914
+ // reach until that popup opens. Letting it block interaction would make
22915
+ // a picker whose content is loading impossible to open at all.
22916
+ getInteractionBlockingControls: () => [],
22870
22917
  onUIAction: (e, { skipCommand } = {}) => {
22871
22918
  if (controlType === "button" && controller.controlHostProps.name) {
22872
22919
  const buttonName = controller.controlHostProps.name;
@@ -23798,6 +23845,11 @@ const useUIGroupStateController = (
23798
23845
  if (!cascadeValidationToChildren) return [];
23799
23846
  return childUIStateControllerArray.slice();
23800
23847
  },
23848
+ // Group children sit next to the group itself: a busy one really does
23849
+ // prevent the group from acting as a whole.
23850
+ getInteractionBlockingControls: () => {
23851
+ return controller.getManagedControls();
23852
+ },
23801
23853
  subscribe: subscribeUIState,
23802
23854
  };
23803
23855
  const rules = createControlRules(controller, {
@@ -24033,6 +24085,13 @@ const useUIFacadeStateController = (props, realUIStateController) => {
24033
24085
  }
24034
24086
  return child.getManagedControls();
24035
24087
  },
24088
+ getInteractionBlockingControls: () => {
24089
+ const child = firstChildControllerRef.current;
24090
+ if (!child) {
24091
+ return [];
24092
+ }
24093
+ return child.getInteractionBlockingControls();
24094
+ },
24036
24095
  onChildUIAction: (child, e, { stateChanged, silent = false }) => {
24037
24096
  if (!stateChanged) {
24038
24097
  return;
@@ -24040,6 +24099,18 @@ const useUIFacadeStateController = (props, realUIStateController) => {
24040
24099
  if (child !== firstChildControllerRef.current) {
24041
24100
  return;
24042
24101
  }
24102
+ if (
24103
+ silent &&
24104
+ child.uiState === undefined &&
24105
+ s.realUIStateController.uiState !== undefined
24106
+ ) {
24107
+ // A silent sync means the child's own structure changed (children
24108
+ // mounted/unmounted), not that the user acted. A child that ends up
24109
+ // with no value there is one that currently *cannot* express one —
24110
+ // a <List loading> holds no items yet — which must not read as the
24111
+ // user clearing the picker, nor fire its uiAction.
24112
+ return;
24113
+ }
24043
24114
  updatingRef.current = true;
24044
24115
  // Use a different event type for silent (mount/unmount) syncs so that
24045
24116
  // the picker's setUIState does not fire navi_change or action pipelines.
@@ -28646,6 +28717,7 @@ installImportMetaCssBuild(import.meta);const css$L = /* css */`
28646
28717
  --link-text-decoration-hover: var(--link-text-decoration);
28647
28718
  --link-cursor: pointer;
28648
28719
  --link-loading-outline-size: 1px;
28720
+ --link-outline-width: 2px;
28649
28721
 
28650
28722
  --link-current-indicator-size: 2px;
28651
28723
  --link-current-indicator-spacing: 0;
@@ -28781,6 +28853,10 @@ installImportMetaCssBuild(import.meta);const css$L = /* css */`
28781
28853
  --x-link-color: var(--link-color);
28782
28854
  }
28783
28855
  }
28856
+ &[data-anchor] {
28857
+ /* Usually better to have some spacing between the anchor and the scroll top */
28858
+ scroll-margin-block: calc(1em + var(--link-outline-width) + 1px);
28859
+ }
28784
28860
  /* Hover */
28785
28861
  &[data-hover] {
28786
28862
  --x-link-background: var(--x-link-background-hover);
@@ -28788,7 +28864,7 @@ installImportMetaCssBuild(import.meta);const css$L = /* css */`
28788
28864
  --x-link-text-decoration: var(--x-link-text-decoration-hover);
28789
28865
  }
28790
28866
  &[data-focus-visible] {
28791
- outline-width: 2px;
28867
+ outline-width: var(--link-outline-width);
28792
28868
  }
28793
28869
  /* Pressed */
28794
28870
  &[data-pressed] {
@@ -35947,37 +36023,62 @@ const renderSafe = (value) => {
35947
36023
 
35948
36024
  const PickerContext = createContext();
35949
36025
 
35950
- // The resolved popup mode of the surrounding Picker: "popover" or "dialog"
35951
- // (frozen for the lifetime of an opening — see usePopupMode). Provided around the
35952
- // picker's popup content so that content can render differently per mode.
35953
- const PickerModeContext = createContext(undefined);
35954
-
35955
36026
  /**
35956
- * Read the mode ("popover" | "dialog") of the Picker whose popup this is rendered
35957
- * inside. Only meaningful for a Picker's popup content (its children); returns
35958
- * undefined anywhere else.
35959
- *
35960
- * @returns {"popover" | "dialog" | undefined}
36027
+ * Decides which element receives focus when a container (popover, dialog, …)
36028
+ * opens, and gives it back to where it came from when the container closes.
36029
+ *
36030
+ * The [navi-autofocus] attribute (written by use_auto_focus.js) tunes where
36031
+ * focus lands. Candidates are tried in this order:
36032
+ * 1. The element that held focus when the container was last closed, if it
36033
+ * opted into that with "fallback" or "restore"
36034
+ * 2. [navi-autofocus] with any other value ("" for a plain `autoFocus`)
36035
+ * 3. The first focusable element
36036
+ * 4. [navi-autofocus="fallback"], the container itself included
36037
+ * 5. The element focused before the container opened
36038
+ *
36039
+ * [navi-autofocus="restore"] appears in step 1 only: it never claims focus on
36040
+ * a fresh open, it only gets it back.
35961
36041
  */
35962
- const usePickerMode = () => useContext(PickerModeContext);
35963
36042
 
35964
- /**
35965
- * Mirrors what browsers do when navigating to a page:
35966
- * 1. Focus the first element with [navi-autofocus] (but not [navi-autofocus="fallback"]) inside the container
35967
- * 2. Fall back to the first focusable element
35968
- * 3. Fall back to the first element with [navi-autofocus="fallback"]
35969
- * Does nothing if no candidate is found.
35970
- */
36043
+ // The element that held focus when a container closed is marked with
36044
+ // [navi-autofocus-last-focused], and its container with
36045
+ // [navi-autofocus-restore]. Both carry the same generated id: containers can
36046
+ // nest (a popover inside a dialog), so the id is what tells a reopening
36047
+ // container which mark among its descendants is its own.
36048
+ let restoreIdCounter = 0;
36049
+
36050
+ const isRestorableAutofocus = (el) => {
36051
+ const value = el.getAttribute("navi-autofocus");
36052
+ return value === "fallback" || value === "restore";
36053
+ };
36054
+
36055
+ const clearAutofocusRestore = (containerEl) => {
36056
+ const restoreId = containerEl.getAttribute("navi-autofocus-restore");
36057
+ if (restoreId === null) {
36058
+ return null;
36059
+ }
36060
+ containerEl.removeAttribute("navi-autofocus-restore");
36061
+ const selector = `[navi-autofocus-last-focused="${restoreId}"]`;
36062
+ const lastFocused = containerEl.matches(selector)
36063
+ ? containerEl
36064
+ : containerEl.querySelector(selector);
36065
+ if (lastFocused) {
36066
+ lastFocused.removeAttribute("navi-autofocus-last-focused");
36067
+ }
36068
+ return lastFocused;
36069
+ };
36070
+
35971
36071
  const markAutofocusRestoreOnClose = (containerEl) => {
36072
+ clearAutofocusRestore(containerEl);
35972
36073
  const focused = document.activeElement;
35973
36074
  if (
35974
36075
  focused &&
35975
- containerEl.contains(focused) &&
35976
- focused.getAttribute("navi-autofocus") === "fallback"
36076
+ (containerEl === focused || containerEl.contains(focused)) &&
36077
+ isRestorableAutofocus(focused)
35977
36078
  ) {
35978
- containerEl.setAttribute("navi-autofocus-restore", "");
35979
- } else {
35980
- containerEl.removeAttribute("navi-autofocus-restore");
36079
+ const restoreId = `${++restoreIdCounter}`;
36080
+ containerEl.setAttribute("navi-autofocus-restore", restoreId);
36081
+ focused.setAttribute("navi-autofocus-last-focused", restoreId);
35981
36082
  }
35982
36083
  };
35983
36084
 
@@ -35999,19 +36100,14 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
35999
36100
  transferFocus: (transferEvent, containerEl) => {
36000
36101
  let target;
36001
36102
  let reason;
36002
- if (containerEl.hasAttribute("navi-autofocus-restore")) {
36003
- containerEl.removeAttribute("navi-autofocus-restore");
36004
- const naviAutoFocusFallback = containerEl.querySelector(
36005
- "[navi-autofocus='fallback']",
36006
- );
36007
- if (naviAutoFocusFallback) {
36008
- reason = "navi-autofocus fallback (restore)";
36009
- target = naviAutoFocusFallback;
36010
- }
36103
+ const lastFocused = clearAutofocusRestore(containerEl);
36104
+ if (lastFocused) {
36105
+ reason = "element focused when closed (restore)";
36106
+ target = lastFocused;
36011
36107
  }
36012
36108
  if (!target) {
36013
36109
  const naviAutoFocus = containerEl.querySelector(
36014
- "[navi-autofocus]:not([navi-autofocus='fallback'])",
36110
+ `[navi-autofocus]:not([navi-autofocus="fallback"]):not([navi-autofocus="restore"])`,
36015
36111
  );
36016
36112
  if (naviAutoFocus) {
36017
36113
  reason = "navi-autofocus";
@@ -36020,7 +36116,7 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
36020
36116
  }
36021
36117
  if (!target) {
36022
36118
  const focusable = findFocusable(containerEl, {
36023
- exclude: (el) => el.getAttribute("navi-autofocus") === "fallback",
36119
+ exclude: isRestorableAutofocus,
36024
36120
  });
36025
36121
  if (focusable) {
36026
36122
  reason = "first focusable element";
@@ -36028,14 +36124,16 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
36028
36124
  }
36029
36125
  }
36030
36126
  if (!target) {
36031
- // querySelector only searches descendants — but the container itself may
36032
- // carry [navi-autofocus="fallback"] (a focusable popup root with nothing
36033
- // else to focus). It's fine to focus the container in that case.
36034
- const naviAutoFocusFallback = containerEl.matches(
36035
- `[navi-autofocus="fallback"]`,
36036
- )
36037
- ? containerEl
36038
- : containerEl.querySelector(`[navi-autofocus="fallback"]`);
36127
+ // A [navi-autofocus="fallback"] INSIDE the container (e.g. a search
36128
+ // input) wins over the container itself. The container is only the
36129
+ // fallback-of-the-fallback: a focusable popup root gets focus solely
36130
+ // when nothing inside it already carries the fallback. (matches() covers
36131
+ // the container-only case since querySelector searches descendants only.)
36132
+ const naviAutoFocusFallback =
36133
+ containerEl.querySelector(`[navi-autofocus="fallback"]`) ||
36134
+ (containerEl.matches(`[navi-autofocus="fallback"]`)
36135
+ ? containerEl
36136
+ : null);
36039
36137
  if (naviAutoFocusFallback) {
36040
36138
  reason = "navi-autofocus fallback";
36041
36139
  target = naviAutoFocusFallback;
@@ -36157,26 +36255,32 @@ const createOpenController = (
36157
36255
  // open() ignores the request — no need to know *which* element triggers
36158
36256
  // it. A bubble-phase listener (runs after everything else, once the click
36159
36257
  // reaches document) clears the flag if nothing consumed it, meaning this
36160
- // click never resulted in an open() call. A microtask is a last-resort
36161
- // safety net in case the click never reaches document at all (e.g. some
36162
- // ancestor called stopPropagation()).
36258
+ // click never resulted in an open() call. A timeout is a last-resort safety
36259
+ // net in case the click never reaches document at all (e.g. some ancestor
36260
+ // called stopPropagation()) — a *task*, never a microtask: a microtask
36261
+ // checkpoint runs between two listeners of the same trusted event dispatch,
36262
+ // so it would clear the flag before the bubble-phase handler this is meant
36263
+ // to block ever runs, which is precisely the case it exists for.
36163
36264
  const armSuppressNextOpenRequest = () => {
36164
36265
  disarmSuppressNextOpenRequest?.();
36266
+ let safetyTimeout = null;
36165
36267
  const onCaptureClick = () => {
36166
36268
  document.removeEventListener("click", onCaptureClick, {
36167
36269
  capture: true,
36168
36270
  });
36169
36271
  suppressNextOpenRequest = true;
36170
36272
  document.addEventListener("click", onBubbleClick);
36171
- queueMicrotask(() => {
36273
+ safetyTimeout = setTimeout(() => {
36172
36274
  suppressNextOpenRequest = false;
36173
36275
  });
36174
36276
  };
36175
36277
  const onBubbleClick = () => {
36176
36278
  document.removeEventListener("click", onBubbleClick);
36279
+ clearTimeout(safetyTimeout);
36177
36280
  suppressNextOpenRequest = false;
36178
36281
  };
36179
36282
  disarmSuppressNextOpenRequest = () => {
36283
+ clearTimeout(safetyTimeout);
36180
36284
  document.removeEventListener("click", onCaptureClick, {
36181
36285
  capture: true,
36182
36286
  });
@@ -36464,6 +36568,61 @@ const useOpenControllerByProps = (props) => {
36464
36568
  return openController;
36465
36569
  };
36466
36570
 
36571
+ /**
36572
+ * Where the "popover or dialog?" answer lives, for both the components that
36573
+ * decide it and the content that renders inside one.
36574
+ *
36575
+ * `useResolvedPopupMode` is the decision (screen size + maxWidth heuristic),
36576
+ * called by whatever renders the popup — `Popup` itself, or `picker_custom.jsx`
36577
+ * which needs the answer for its own mode-dependent history/ARIA handling on
36578
+ * top of picking a renderer. `usePopupMode` is the read side: any content
36579
+ * rendered inside a popup can call it to lay itself out differently in a
36580
+ * dropdown than in a full-screen modal.
36581
+ */
36582
+
36583
+ const PopupModeContext = createContext(undefined);
36584
+
36585
+ /**
36586
+ * Read the mode of the popup this is rendered inside — a `Popup`, or a
36587
+ * `Picker`'s own popup. Returns undefined outside of any popup content.
36588
+ *
36589
+ * @returns {"popover" | "dialog" | undefined}
36590
+ */
36591
+ const usePopupMode = () => useContext(PopupModeContext);
36592
+
36593
+ /**
36594
+ * Resolves which of Popover/Dialog a popup should be. Frozen for the component
36595
+ * instance's lifetime, so a screen resize never switches an already-mounted
36596
+ * popup from one to the other mid-session.
36597
+ *
36598
+ * @param {"dialog"|"popover"} [modeProp] - Forces one mode; `undefined` to
36599
+ * resolve automatically.
36600
+ * @param {string} [maxWidth] - A small enough value is treated as "compact",
36601
+ * staying a popover even on a small screen.
36602
+ * @returns {["dialog"|"popover", () => void]} The resolved mode, and a
36603
+ * `resetMode` function a caller can call (e.g. on close) to force the *next*
36604
+ * call to re-resolve from scratch instead of keeping the frozen value —
36605
+ * `Popup` itself never needs this (it has no notion of open/close of its
36606
+ * own), `picker_custom.jsx` does (re-evaluates screen size on every fresh
36607
+ * open).
36608
+ */
36609
+ const useResolvedPopupMode = (modeProp, maxWidth) => {
36610
+ const defaultModeRef = useRef(null);
36611
+ if (defaultModeRef.current === null) {
36612
+ defaultModeRef.current = resolvePopupMode(modeProp, maxWidth);
36613
+ }
36614
+ const resetMode = () => {
36615
+ defaultModeRef.current = null;
36616
+ };
36617
+ return [defaultModeRef.current, resetMode];
36618
+ };
36619
+ const resolvePopupMode = (modeProp, maxWidth) => {
36620
+ const isSmallScreen = windowWidthSignal.peek() <= 600;
36621
+ const maxWidthPx = parseFloat(maxWidth);
36622
+ const isCompact = isFinite(maxWidthPx) && maxWidthPx < 150;
36623
+ return modeProp ?? (isSmallScreen && !isCompact ? "dialog" : "popover");
36624
+ };
36625
+
36467
36626
  /**
36468
36627
  * Entry/exit animation CSS shared by Popover and Dialog.
36469
36628
  *
@@ -36561,7 +36720,8 @@ const popupCss = /* css */ `
36561
36720
  this list contains (no shared transition-property to clobber, no
36562
36721
  propertyName to filter). */
36563
36722
  &[navi-animation] {
36564
- transition-property: display, overlay, opacity, translate, scale, box-shadow;
36723
+ transition-property:
36724
+ display, overlay, opacity, translate, scale, box-shadow;
36565
36725
  transition-duration:
36566
36726
  var(--popup-animation-duration), var(--popup-animation-duration),
36567
36727
  var(--popup-opacity-duration), var(--popup-translate-duration),
@@ -36924,15 +37084,24 @@ installImportMetaCssBuild(import.meta);/**
36924
37084
  const css$v = /* css */`
36925
37085
  @layer navi {
36926
37086
  .navi_dialog {
36927
- /* min gap between dialog edges and viewport */
36928
- /* not named margin because it's not implemented with margins (which are needed for centering) */
36929
- --dialog-viewport-spacing: 3dvw;
37087
+ /* Min gap between the dialog and the edges of its container. Written
37088
+ from the marginWithContainer prop (see below) hence --x-, not a knob
37089
+ to set from CSS — so the size caps here and the placement can never
37090
+ disagree. The literal is only what a dialog painted before that ever
37091
+ runs falls back to. Not named "margin" because it isn't implemented
37092
+ with margins (those are needed for centering).
37093
+
37094
+ Capping the *size* here rather than only offsetting the position is
37095
+ what makes a centered dialog follow the mobile virtual keyboard for
37096
+ free: --navi-vvw/--navi-vvh track the visual viewport, so the browser
37097
+ reflows the dialog itself as the keyboard opens. */
37098
+ --x-dialog-viewport-spacing: 3vvw;
36930
37099
 
36931
37100
  --dialog-maxmax-width: calc(
36932
- var(--navi-vvw) - 2 * var(--dialog-viewport-spacing)
37101
+ var(--navi-vvw) - 2 * var(--x-dialog-viewport-spacing)
36933
37102
  );
36934
37103
  --dialog-maxmax-height: calc(
36935
- var(--navi-vvh) - 2 * var(--dialog-viewport-spacing)
37104
+ var(--navi-vvh) - 2 * var(--x-dialog-viewport-spacing)
36936
37105
  );
36937
37106
 
36938
37107
  --dialog-border-radius: var(--navi-popup-border-radius);
@@ -37165,8 +37334,14 @@ const css$v = /* css */`
37165
37334
  * `bottom-end`/`bottom-left`/`bottom-right`, `left`/`left-start`/
37166
37335
  * `left-end`, or `center` — optionally wrapped in `inset(...)` (e.g.
37167
37336
  * `inset(top)`) for the overlapping variant.
37168
- * @param {string|number} [props.marginWithContainer=0] - Extra spacing kept
37169
- * between the dialog and the edges of its container.
37337
+ * @param {string|number} [props.marginWithContainer="3vvw"] - Minimum gap kept
37338
+ * between the dialog and the edges of its container, whatever its
37339
+ * `positionArea`: it both caps the dialog's own size (via
37340
+ * `--x-dialog-viewport-spacing`, written from this prop) and offsets a docked
37341
+ * one from the edge it docks to. Accepts a spacing token ("s", "m"…), a
37342
+ * number of pixels, or a viewport length — "vvw"/"vvh" being the visual
37343
+ * viewport, which shrinks when the mobile keyboard opens. Pass 0 for a dialog
37344
+ * meant to sit flush (a side panel).
37170
37345
  * @param {"close"|"cancel"|"capture"|"none"} [props.pointerInteractionOutsideEffect="close"]
37171
37346
  * - `"close"` closes the dialog on an outside click. `"capture"`/`"none"`
37172
37347
  * both just absorb the click without closing (visually dimmed backdrop vs.
@@ -37197,9 +37372,10 @@ const css$v = /* css */`
37197
37372
  * @param {number} [props.tabIndex=-1] - Set on the dialog element itself so
37198
37373
  * `autoFocus="fallback"` below has somewhere to land when the dialog has
37199
37374
  * no other focusable descendant of its own.
37200
- * @param {boolean|"fallback"} [props.autoFocus="fallback"] - See
37201
- * `use_auto_focus.js` — `"fallback"` focuses the dialog itself if it has
37202
- * no other focusable descendant.
37375
+ * @param {boolean|"fallback"|"restore"} [props.autoFocus="fallback"] - See
37376
+ * `focus_transfer.js` — `"fallback"` focuses the dialog itself if it has
37377
+ * no other focusable descendant, `"restore"` keeps it out of the opening
37378
+ * focus chain unless it held focus when the dialog closed.
37203
37379
  * @param {boolean} [props.open] - Controlled open state.
37204
37380
  * @param {boolean} [props.defaultOpen] - Uncontrolled, mount-only initial
37205
37381
  * open state — plays no entrance animation (nothing was ever shown as
@@ -37312,7 +37488,10 @@ const useDialogProps = props => {
37312
37488
  // Same grammar as Popover's own positionArea — see this file's top
37313
37489
  // comment and popup_shared.js's parsePositionArea.
37314
37490
  positionArea = "center",
37315
- marginWithContainer = 0,
37491
+ // A dialog docked against an edge must keep the same gap its own size cap
37492
+ // already guarantees a centered one — so this drives both (see
37493
+ // --x-dialog-viewport-spacing above). Pass 0 to sit flush (side_panel.jsx).
37494
+ marginWithContainer = "3vvw",
37316
37495
  // "close" (default) closes on an outside click. "capture"/"none" both
37317
37496
  // just absorb it without closing — for the via-attribute renderer,
37318
37497
  // showModal() already makes the rest of the page inert, so there's
@@ -37349,6 +37528,21 @@ const useDialogProps = props => {
37349
37528
  const debugFocus = useDebugFocus();
37350
37529
  const debugInteraction = useDebugInteraction();
37351
37530
  const autoFocusProps = useAutoFocus(ref, autoFocus);
37531
+ // positionDialog lives in openEffect's closure — created once, when the
37532
+ // dialog opens. Reading the placement props through a ref instead of that
37533
+ // closure is what lets a change while open take effect on the spot (see the
37534
+ // reposition effect below) rather than only on the next opening.
37535
+ const positionPropsRef = useRef(null);
37536
+ positionPropsRef.current = {
37537
+ positionArea,
37538
+ marginWithContainer
37539
+ };
37540
+ const repositionRef = useRef(null);
37541
+ useEffect(() => {
37542
+ repositionRef.current?.(new CustomEvent("position_props_change", {
37543
+ detail: {}
37544
+ }));
37545
+ }, [positionArea, marginWithContainer]);
37352
37546
  const positionAreaParseResult = parsePositionArea(positionArea);
37353
37547
  if (!positionAreaParseResult) {
37354
37548
  console.warn(`Dialog: invalid positionArea="${positionArea}"`);
@@ -37492,10 +37686,27 @@ const useDialogProps = props => {
37492
37686
  // custom renderer. applyNewPosition sets --container-position-remaining-height/-width
37493
37687
  // from the result, same as popover.jsx.
37494
37688
  const positionDialog = triggerEvent => {
37689
+ const {
37690
+ positionArea,
37691
+ marginWithContainer
37692
+ } = positionPropsRef.current;
37693
+ let marginWithContainerInPixels = resolveSpacingSize(marginWithContainer);
37694
+ if (typeof marginWithContainerInPixels !== "number") {
37695
+ // A value only CSS could evaluate (a spacing token resolving to a var(),
37696
+ // a percentage…) — the placement below needs a real number, and letting
37697
+ // it through would put the dialog at NaN.
37698
+ console.warn(`Dialog: marginWithContainer="${marginWithContainer}" cannot be resolved to pixels. Use a number or a viewport length ("3vvw", "2vvh").`);
37699
+ marginWithContainerInPixels = 0;
37700
+ }
37701
+ // The size caps read the same gap in CSS as the placement below applies
37702
+ // in pixels, so a docked dialog and a centered one keep the same
37703
+ // distance from the edges. Written resolved (not as the raw prop) so a
37704
+ // spacing token stays valid inside the caps' own calc().
37705
+ dialogEl.style.setProperty("--x-dialog-viewport-spacing", `${marginWithContainerInPixels}px`);
37495
37706
  const pickOptions = {
37496
37707
  positionArea,
37497
37708
  container: positionedAncestor,
37498
- marginWithContainer: resolveSpacingSize(marginWithContainer),
37709
+ marginWithContainer: marginWithContainerInPixels,
37499
37710
  event: triggerEvent
37500
37711
  };
37501
37712
  let position = pickPositionRelativeTo(dialogEl, null, pickOptions);
@@ -37541,7 +37752,19 @@ const useDialogProps = props => {
37541
37752
  skipElementResize: true
37542
37753
  });
37543
37754
  rectEffect.observeSize(dialogEl);
37755
+ // Exposed for the placement-props effect below, which needs to re-place an
37756
+ // already-open dialog.
37757
+ repositionRef.current = repositionEvent => {
37758
+ // data-position-*-current pins an open dialog to the side it first
37759
+ // resolved to, so a resize never makes it jump (pickPositionRelativeTo
37760
+ // reads it back and prefers it over the requested area). A new placement
37761
+ // request is precisely the case where that memory must not win.
37762
+ dialogEl.removeAttribute("data-position-x-current");
37763
+ dialogEl.removeAttribute("data-position-y-current");
37764
+ positionDialog(repositionEvent);
37765
+ };
37544
37766
  addCleanup(() => {
37767
+ repositionRef.current = null;
37545
37768
  rectEffect.disconnect();
37546
37769
  });
37547
37770
  // A descendant anchored to something inside this dialog (a Callout, a
@@ -37833,7 +38056,10 @@ let openLocalPopoverCount = 0;
37833
38056
  const css$u = /* css */`
37834
38057
  @layer navi {
37835
38058
  .navi_popover {
37836
- --popover-max-height: 300px; /* soft: user-configurable preferred max-height */
38059
+ /* soft: user-configurable preferred max-height. Kept as a *default*
38060
+ rather than a value so an outer component can bridge its own prop into
38061
+ --popover-max-height without having to restate 300px (see picker). */
38062
+ --popover-max-height-default: 300px;
37837
38063
  --popover-maxmax-height: calc(0.95 * var(--navi-vvh));
37838
38064
  --popover-maxmax-width: calc(0.95 * var(--navi-vvw));
37839
38065
 
@@ -37884,7 +38110,7 @@ const css$u = /* css */`
37884
38110
  var(--popover-maxmax-width)
37885
38111
  );
37886
38112
  --x-popover-max-height: min(
37887
- var(--popover-max-height),
38113
+ var(--popover-max-height, var(--popover-max-height-default)),
37888
38114
  var(--container-position-remaining-height, var(--popover-maxmax-height)),
37889
38115
  var(--popover-maxmax-height)
37890
38116
  );
@@ -38135,9 +38361,10 @@ const css$u = /* css */`
38135
38361
  * @param {number} [props.tabIndex=-1] - Set on the popover element itself
38136
38362
  * so `autoFocus="fallback"` below has somewhere to land when the popover
38137
38363
  * has no other focusable descendant of its own.
38138
- * @param {boolean|"fallback"} [props.autoFocus="fallback"] - See
38139
- * `use_auto_focus.js` — `"fallback"` focuses the popover itself if it has
38140
- * no other focusable descendant.
38364
+ * @param {boolean|"fallback"|"restore"} [props.autoFocus="fallback"] - See
38365
+ * `focus_transfer.js` — `"fallback"` focuses the popover itself if it has
38366
+ * no other focusable descendant, `"restore"` keeps it out of the opening
38367
+ * focus chain unless it held focus when the popover closed.
38141
38368
  * @param {boolean} [props.open] - Controlled open state.
38142
38369
  * @param {boolean} [props.defaultOpen] - Uncontrolled, mount-only initial
38143
38370
  * open state — plays no entrance animation (nothing was ever shown as
@@ -38313,6 +38540,24 @@ const usePopoverProps = props => {
38313
38540
  // (see resolveAutoAnimationKind).
38314
38541
  const isAutoAnimation = animation === true || animation === "auto";
38315
38542
  const hasBackdrop = pointerInteractionOutsideEffect !== "none";
38543
+ // positionPopover lives in openEffect's closure — created once, when the
38544
+ // popover opens. Reading the placement props through a ref instead of that
38545
+ // closure is what lets a change while open take effect on the spot (see the
38546
+ // reposition effect below) rather than only on the next opening.
38547
+ const positionPropsRef = useRef(null);
38548
+ positionPropsRef.current = {
38549
+ positionArea,
38550
+ positionAreaFixed,
38551
+ positionAreaWhenAnchorIsInvalid,
38552
+ marginWithAnchor,
38553
+ marginWithContainer
38554
+ };
38555
+ const repositionRef = useRef(null);
38556
+ useEffect(() => {
38557
+ repositionRef.current?.(new CustomEvent("position_props_change", {
38558
+ detail: {}
38559
+ }));
38560
+ }, [positionArea, positionAreaFixed, positionAreaWhenAnchorIsInvalid, marginWithAnchor, marginWithContainer]);
38316
38561
  // The custom renderer's own starting-hidden state is a stylesheet default
38317
38562
  // now (&:not([popover]) { display: none } on .navi_popover/
38318
38563
  // .navi_popover_backdrop above) rather than set here imperatively — a
@@ -38509,6 +38754,13 @@ const usePopoverProps = props => {
38509
38754
  // via-attribute renderer (see its own computation above).
38510
38755
  const effectiveAnchor = hasAnchorElement ? anchorElement : positionedAncestor;
38511
38756
  const positionPopover = positionEvent => {
38757
+ const {
38758
+ positionArea,
38759
+ positionAreaFixed,
38760
+ positionAreaWhenAnchorIsInvalid,
38761
+ marginWithAnchor,
38762
+ marginWithContainer
38763
+ } = positionPropsRef.current;
38512
38764
  let position;
38513
38765
  if (hasAnchorElement) {
38514
38766
  const {
@@ -38676,6 +38928,21 @@ const usePopoverProps = props => {
38676
38928
  // while open (e.g. an expand/collapse toggle inside it) — not just when
38677
38929
  // the anchor itself moves/resizes/re-anchors.
38678
38930
  rectEffect.observeSize(popoverEl);
38931
+ // Exposed for the placement-props effect above, which needs to re-place an
38932
+ // already-open popover.
38933
+ repositionRef.current = repositionEvent => {
38934
+ // data-position-*-current pins an open popover to the side it first
38935
+ // resolved to, so scrolling or a content resize never makes it jump
38936
+ // (pickPositionRelativeTo reads it back and prefers it over the
38937
+ // requested area). A new placement request is precisely the case where
38938
+ // that memory must not win — drop it before re-resolving.
38939
+ popoverEl.removeAttribute("data-position-x-current");
38940
+ popoverEl.removeAttribute("data-position-y-current");
38941
+ positionPopover(repositionEvent);
38942
+ };
38943
+ addCleanup(() => {
38944
+ repositionRef.current = null;
38945
+ });
38679
38946
  // A descendant anchored to something inside this popover (a Callout, a
38680
38947
  // further-nested Popover) needing to know about this popover's own
38681
38948
  // left/top repositioning transition — not just that the target changed
@@ -39002,12 +39269,12 @@ const resolvePositionAreaAndAnimationKind = ({
39002
39269
  installImportMetaCssBuild(import.meta);/**
39003
39270
  * A lightweight version of picker_custom.jsx's own Popover/Dialog switch —
39004
39271
  * no picker concepts (value/action tracking, keyboard letter/arrow-to-open
39005
- * shortcuts, history-driven expanded state, anchor-clone "attached" mode):
39272
+ * shortcuts, history-driven expanded state):
39006
39273
  * just picks between rendering a Popover or a Dialog and applies the shared
39007
39274
  * "popup box" look (padding, background, border-radius, box-shadow) to
39008
39275
  * whichever one it renders.
39009
39276
  *
39010
- * Mode resolution (`usePopupMode` below) is shared with picker_custom.jsx,
39277
+ * Mode resolution (`useResolvedPopupMode`, popup_mode.jsx) is shared with picker_custom.jsx,
39011
39278
  * not just mirrored — the picker needs the resolved mode itself (for its own
39012
39279
  * mode-dependent history/ARIA handling), not just to pick which of Popover/
39013
39280
  * Dialog to render, so it calls the same hook directly instead of
@@ -39150,7 +39417,12 @@ const Popup = props => {
39150
39417
  positionAreaFixed,
39151
39418
  ...rest
39152
39419
  } = props;
39153
- const [mode] = usePopupMode(modeProp, maxWidth);
39420
+ const [mode] = useResolvedPopupMode(modeProp, maxWidth);
39421
+ // So the content can lay itself out per mode — see usePopupMode.
39422
+ const childrenWithMode = jsx(PopupModeContext.Provider, {
39423
+ value: mode,
39424
+ children: children
39425
+ });
39154
39426
  if (mode === "dialog") {
39155
39427
  const expandXResolved = expand || expandX;
39156
39428
  const expandYResolved = expand || expandY;
@@ -39161,7 +39433,7 @@ const Popup = props => {
39161
39433
  className: withPropsClassName("navi_popup", className),
39162
39434
  "data-expand-x": expandXResolved ? "" : undefined,
39163
39435
  "data-expand-y": expandYResolved ? "" : undefined,
39164
- children: children
39436
+ children: childrenWithMode
39165
39437
  });
39166
39438
  }
39167
39439
  return jsx(Popover, {
@@ -39173,54 +39445,10 @@ const Popup = props => {
39173
39445
  focusCapture: focusCapture,
39174
39446
  positionAreaFixed: positionAreaFixed,
39175
39447
  className: withPropsClassName("navi_popup", className),
39176
- children: children
39448
+ children: childrenWithMode
39177
39449
  });
39178
39450
  };
39179
39451
 
39180
- /**
39181
- * Frozen for the component instance's lifetime — mirrors `Popup`'s own
39182
- * mode-resolution timing (a screen resize while already mounted doesn't
39183
- * switch between Popover and Dialog mid-session).
39184
- *
39185
- * @param {"dialog"|"popover"} [modeProp]
39186
- * @param {string} [maxWidth]
39187
- * @returns {["dialog"|"popover", () => void]} The resolved mode, and a
39188
- * `resetMode` function a caller can call (e.g. on close) to force the
39189
- * *next* call to re-resolve from scratch instead of keeping the frozen
39190
- * value — `Popup` itself never needs this (it has no notion of
39191
- * open/close of its own), `picker_custom.jsx` does (re-evaluates screen
39192
- * size on every fresh open).
39193
- */
39194
- const usePopupMode = (modeProp, maxWidth) => {
39195
- const defaultModeRef = useRef(null);
39196
- if (defaultModeRef.current === null) {
39197
- defaultModeRef.current = resolvePopupMode(modeProp, maxWidth);
39198
- }
39199
- const resetMode = () => {
39200
- defaultModeRef.current = null;
39201
- };
39202
- return [defaultModeRef.current, resetMode];
39203
- };
39204
- /**
39205
- * Same small-screen/`maxWidth`-compact heuristic `Popup` uses internally,
39206
- * exported so `picker_custom.jsx` (which needs the resolved mode itself,
39207
- * for its own mode-dependent history/ARIA handling — not just to pick which
39208
- * of Popover/Dialog to render, the way `Popup` only ever needs it) doesn't
39209
- * have to duplicate it.
39210
- *
39211
- * @param {"dialog"|"popover"} [modeProp] - Forces one mode; `undefined` to
39212
- * resolve automatically.
39213
- * @param {string} [maxWidth] - A small enough value is treated as
39214
- * "compact", staying a popover even on a small screen.
39215
- * @returns {"dialog"|"popover"}
39216
- */
39217
- const resolvePopupMode = (modeProp, maxWidth) => {
39218
- const isSmallScreen = windowWidthSignal.peek() <= 600;
39219
- const maxWidthPx = parseFloat(maxWidth);
39220
- const isCompact = isFinite(maxWidthPx) && maxWidthPx < 150;
39221
- return modeProp ?? (isSmallScreen && !isCompact ? "dialog" : "popover");
39222
- };
39223
-
39224
39452
  installImportMetaCssBuild(import.meta);const css$s = /* css */`
39225
39453
  .navi_picker {
39226
39454
  /* Sizing ceilings (maxmax), background, box-shadow, outline, padding,
@@ -39228,8 +39456,7 @@ installImportMetaCssBuild(import.meta);const css$s = /* css */`
39228
39456
  themselves — nothing to redefine here. Only the picker's own look
39229
39457
  (border color/radius/width, background) needs bridging into the vars
39230
39458
  Popover/Dialog actually consume, plus a couple of genuinely
39231
- picker-specific bits below (anchor-width min-width, the anchor clone,
39232
- the nested list). */
39459
+ picker-specific bits below (anchor-width min-width, the nested list). */
39233
39460
 
39234
39461
  /* popover */
39235
39462
  &[aria-haspopup="listbox"] {
@@ -39240,52 +39467,17 @@ installImportMetaCssBuild(import.meta);const css$s = /* css */`
39240
39467
  --popover-background-color: var(--picker-background-color);
39241
39468
  --popover-outline-width: var(--picker-outline-width);
39242
39469
  --popover-outline-color: var(--picker-outline-color);
39470
+ /* No fallback on purpose: when the picker's own popoverMaxHeight prop
39471
+ is unset this declaration is invalid at computed-value time, which
39472
+ leaves --popover-max-height unset and lets the popover fall back to
39473
+ --popover-max-height-default. */
39474
+ --popover-max-height: var(--picker-popover-max-height);
39243
39475
 
39244
39476
  /* At least as wide as the trigger — unless popupWidthFitContent, then
39245
39477
  let the content (e.g. a Wheel) size the popover (see picker.jsx). */
39246
39478
  min-width: var(--picker-popover-min-width, var(--anchor-width, 0px));
39247
39479
  cursor: default; /* Reset pointer cursor within the select */
39248
39480
 
39249
- /* The anchor placeholder is a non-interactive visual clone of the
39250
- trigger. It makes the popover wrap both the trigger area and the list
39251
- under a single border/shadow. CSS order places it before the list
39252
- when the popover is below the trigger, and after when above. */
39253
- .navi_picker_anchor_clone {
39254
- display: flex;
39255
- /* To make clone same height as original we need to force it because context can impact height */
39256
- /* Like siblings with a bigger height in a flex container */
39257
- /* We subtract the border sizes as anchor-height includes borders in the dimensions */
39258
- min-height: var(--anchor-inner-height);
39259
- /* Mirror the trigger's padding so the clone looks identical */
39260
- padding-top: var(--x-picker-padding-top);
39261
- padding-right: var(--x-picker-padding-right);
39262
- padding-bottom: var(--x-picker-padding-bottom);
39263
- padding-left: var(--x-picker-padding-left);
39264
- flex-shrink: 0;
39265
- flex-direction: column;
39266
- justify-content: center;
39267
- gap: var(--navi-s);
39268
- order: -1; /* before the list — popover is below the trigger */
39269
- background: var(--x-picker-background-color);
39270
- border-bottom: var(--picker-border-width) solid
39271
- var(--x-picker-border-color);
39272
-
39273
- &:hover {
39274
- --x-picker-background-color: var(--picker-background-color-hover);
39275
- --x-picker-border-color: var(--picker-border-color-hover);
39276
- }
39277
- }
39278
-
39279
- &[data-position-y-current="top"],
39280
- &[data-position-y-current="inset-bottom"] {
39281
- .navi_picker_anchor_clone {
39282
- order: 1; /* after the list — popover is above the trigger */
39283
- border-top: var(--picker-border-width) solid
39284
- var(--x-picker-border-color);
39285
- border-bottom: none;
39286
- }
39287
- }
39288
-
39289
39481
  /* The list scrolls inside the popover */
39290
39482
  .navi_list_container {
39291
39483
  width: 100%;
@@ -39299,16 +39491,15 @@ installImportMetaCssBuild(import.meta);const css$s = /* css */`
39299
39491
  }
39300
39492
 
39301
39493
  &[aria-expanded="true"] {
39302
- &[navi-popover-mode="overlay"],
39303
- &[navi-popover-mode="attached"] {
39494
+ &[navi-popover-mode="overlay"] {
39304
39495
  /* When sizes uses float AND the border uses border-radius it's possible it's possible to see some pixels
39305
39496
  of the underlying select borders. We hide them to ensure this cannot happen. */
39306
39497
  border-color: transparent;
39307
39498
  }
39308
39499
 
39309
39500
  /* Popover itself has no opinion on its content's own layout (plain
39310
- div, block by default) — the picker's content (anchor clone +
39311
- list) needs to stack vertically. */
39501
+ div, block by default) — the picker's content needs to stack
39502
+ vertically. */
39312
39503
  .navi_popover {
39313
39504
  display: flex;
39314
39505
  flex-direction: column;
@@ -39456,11 +39647,11 @@ const PickerCustom = props => {
39456
39647
  const controlId = useContext(ControlIdContext);
39457
39648
  props.id = props.id || controlId || idDefault;
39458
39649
  // Same small-screen/maxWidth-compact heuristic Popup itself uses (see
39459
- // popup.jsx's own usePopupMode) — frozen for the lifetime of an opening
39650
+ // popup_mode.jsx's own useResolvedPopupMode) — frozen for the lifetime of an opening
39460
39651
  // (computed when closed, stable while open, so a screen resize mid-session
39461
39652
  // doesn't switch between Popover and Dialog), with resetMode called from
39462
39653
  // this picker's own onClose below to re-evaluate on the *next* open.
39463
- const [mode, resetMode] = usePopupMode(modeProp, props.maxWidth);
39654
+ const [mode, resetMode] = useResolvedPopupMode(modeProp, props.maxWidth);
39464
39655
  const pickerProps = {
39465
39656
  ...props
39466
39657
  };
@@ -39801,6 +39992,7 @@ const PickerContentInsidePopup = props => {
39801
39992
  // defaulting the now-correctly-named prop to `true` would be a real,
39802
39993
  // unintended behavior change riding along with the rename.
39803
39994
  focusCapture,
39995
+ positionArea,
39804
39996
  popoverMode = "nearby",
39805
39997
  popoverSpacing = popoverMode === "nearby" ? 5 : 0,
39806
39998
  marginWithContainer = 10,
@@ -39846,11 +40038,11 @@ const PickerContentInsidePopup = props => {
39846
40038
  }
39847
40039
  });
39848
40040
  },
39849
- children: jsxs(Popup, {
40041
+ children: jsx(Popup, {
39850
40042
  ...popupProps,
39851
40043
  mode: mode,
39852
40044
  animation: animation,
39853
- positionArea: isPopover ? popoverMode === "nearby" ? "bottom-start" : "inset(top-left)" : undefined,
40045
+ positionArea: isPopover ? positionArea ?? (popoverMode === "nearby" ? "bottom-start" : "inset(top-left)") : positionArea,
39854
40046
  marginWithAnchor: isPopover ? popoverSpacing : undefined,
39855
40047
  marginWithContainer: isPopover ? marginWithContainer : undefined,
39856
40048
  scrollCapture: scrollCapture === "dialog" ? !isPopover : scrollCapture === "popover" ? isPopover : scrollCapture,
@@ -39858,21 +40050,10 @@ const PickerContentInsidePopup = props => {
39858
40050
  focusCapture: isPopover ? focusCapture : undefined,
39859
40051
  expandX: !isPopover ? expandX : undefined,
39860
40052
  expandY: !isPopover ? expandY : undefined,
39861
- children: [isPopover && popoverMode === "attached" ? jsx("div", {
39862
- className: "navi_picker_anchor_clone",
39863
- onMouseDown: e => {
39864
- if (e.button !== 0) {
39865
- return;
39866
- }
39867
- popupProps.openController.requestClose(e, {
39868
- isCancel: true
39869
- });
39870
- },
39871
- children: props.trigger
39872
- }) : null, jsx(PickerModeContext.Provider, {
40053
+ children: jsx(PopupModeContext.Provider, {
39873
40054
  value: mode,
39874
40055
  children: children
39875
- })]
40056
+ })
39876
40057
  })
39877
40058
  });
39878
40059
  };
@@ -40512,6 +40693,84 @@ const toDate = (value, parseString) => {
40512
40693
  return null;
40513
40694
  };
40514
40695
 
40696
+ const LoadingDotsSvg = () => {
40697
+ return jsxs("svg", {
40698
+ viewBox: "0 0 200 200",
40699
+ width: "100%",
40700
+ height: "100%",
40701
+ xmlns: "http://www.w3.org/2000/svg",
40702
+ children: [jsx("rect", {
40703
+ fill: "currentColor",
40704
+ stroke: "currentColor",
40705
+ "stroke-width": "15",
40706
+ width: "30",
40707
+ height: "30",
40708
+ x: "25",
40709
+ y: "85",
40710
+ children: jsx("animate", {
40711
+ attributeName: "opacity",
40712
+ calcMode: "spline",
40713
+ dur: "2",
40714
+ values: "1;0;1;",
40715
+ keySplines: ".5 0 .5 1;.5 0 .5 1",
40716
+ repeatCount: "indefinite",
40717
+ begin: "-.4"
40718
+ })
40719
+ }), jsx("rect", {
40720
+ fill: "currentColor",
40721
+ stroke: "currentColor",
40722
+ "stroke-width": "15",
40723
+ width: "30",
40724
+ height: "30",
40725
+ x: "85",
40726
+ y: "85",
40727
+ children: jsx("animate", {
40728
+ attributeName: "opacity",
40729
+ calcMode: "spline",
40730
+ dur: "2",
40731
+ values: "1;0;1;",
40732
+ keySplines: ".5 0 .5 1;.5 0 .5 1",
40733
+ repeatCount: "indefinite",
40734
+ begin: "-.2"
40735
+ })
40736
+ }), jsx("rect", {
40737
+ fill: "currentColor",
40738
+ stroke: "currentColor",
40739
+ "stroke-width": "15",
40740
+ width: "30",
40741
+ height: "30",
40742
+ x: "145",
40743
+ y: "85",
40744
+ children: jsx("animate", {
40745
+ attributeName: "opacity",
40746
+ calcMode: "spline",
40747
+ dur: "2",
40748
+ values: "1;0;1;",
40749
+ keySplines: ".5 0 .5 1;.5 0 .5 1",
40750
+ repeatCount: "indefinite",
40751
+ begin: "0"
40752
+ })
40753
+ })]
40754
+ });
40755
+ };
40756
+
40757
+ const LoadingIndicator = ({
40758
+ variant = "circle",
40759
+ ...props
40760
+ }) => {
40761
+ if (variant === "dots") {
40762
+ return jsx(Icon, {
40763
+ ...props,
40764
+ children: jsx(LoadingDotsSvg, {})
40765
+ });
40766
+ }
40767
+ return jsx(Icon, {
40768
+ circle: true,
40769
+ ...props,
40770
+ children: jsx(LoadingIndicatorFluid, {})
40771
+ });
40772
+ };
40773
+
40515
40774
  installImportMetaCssBuild(import.meta);const css$r = /* css */`
40516
40775
  @layer navi {
40517
40776
  .navi_separator {
@@ -41266,6 +41525,11 @@ const ListSelectable = props => {
41266
41525
  "navi-has-selected-background": selectedIndicator === "backgroundColor" ? "" : undefined,
41267
41526
  ...listControlRootProps,
41268
41527
  ...listControlProps,
41528
+ // "loading" is a control prop, so useControlgroupProps consumes it (into
41529
+ // aria-busy / the :-navi-loading pseudo state) and it does not survive
41530
+ // into the props below. ListUI needs it too — it is what makes the list
41531
+ // render skeleton rows instead of its (not yet known) items.
41532
+ loading: props.loading,
41269
41533
  name: undefined,
41270
41534
  value: undefined,
41271
41535
  defaultValue: undefined,
@@ -41789,6 +42053,16 @@ const css$p = /* css */`
41789
42053
  }
41790
42054
  &[data-expand-y] {
41791
42055
  --list-max-height: none;
42056
+
42057
+ /* expandY grows the container to fill its parent (flex-grow, applied by
42058
+ Box). The scroll container must then fill that grown height and take
42059
+ over the internal scroll — flex:1 fills it, min-height:0 lets it shrink
42060
+ below its content so overflow:auto scrolls instead of the content
42061
+ pushing past the container (which overflow:hidden would just clip). */
42062
+ .navi_list_scroll_container {
42063
+ min-height: 0;
42064
+ flex: 1;
42065
+ }
41792
42066
  }
41793
42067
  &[navi-nothing-to-display] {
41794
42068
  display: none;
@@ -41891,7 +42165,11 @@ const css$p = /* css */`
41891
42165
  scroll-margin-bottom: var(--x-list-scroll-spacing-bottom);
41892
42166
  scroll-margin-left: var(--x-list-scroll-spacing-left);
41893
42167
 
41894
- &[aria-hidden="true"] {
42168
+ /* The "invisible_and_inert" search no-match mode keeps items in the DOM
42169
+ (to preserve layout) but hides them — it sets BOTH aria-hidden and inert.
42170
+ Scope to that pair so the presentation placeholders that are only
42171
+ aria-hidden (skeleton rows, the loader) stay visible. */
42172
+ &[aria-hidden="true"][inert] {
41895
42173
  opacity: 0;
41896
42174
  }
41897
42175
 
@@ -42000,6 +42278,39 @@ const css$p = /* css */`
42000
42278
  user-select: none;
42001
42279
  }
42002
42280
  }
42281
+ /* Loading placeholders (see List's loading / loadingIndicator / skeletonTemplate).
42282
+ A skeleton row reuses <Text loading> for the shimmer bar; the loader row
42283
+ centers a spinner. */
42284
+ .navi_list_item_skeleton {
42285
+ pointer-events: none;
42286
+ }
42287
+ .navi_list_loader {
42288
+ display: flex;
42289
+ padding: 12px;
42290
+ align-items: center;
42291
+ justify-content: center;
42292
+ color: light-dark(#888, #aaa);
42293
+ }
42294
+ /* Error state (List error prop): an inline callout describing why the list
42295
+ failed to load, shown in place of the items. */
42296
+ .navi_list_error {
42297
+ display: flex;
42298
+ margin: 8px;
42299
+ padding: 10px 12px;
42300
+ align-items: flex-start;
42301
+ gap: 8px;
42302
+ color: light-dark(#b91c1c, #fca5a5);
42303
+ font-size: 0.9em;
42304
+ line-height: 1.4;
42305
+ background: light-dark(#fef2f2, rgba(127, 29, 29, 0.25));
42306
+ border: 1px solid light-dark(#fecaca, rgba(248, 113, 113, 0.4));
42307
+ border-radius: 6px;
42308
+ }
42309
+ .navi_list_error_icon {
42310
+ flex: none;
42311
+ font-size: 1em;
42312
+ line-height: 1.4;
42313
+ }
42003
42314
  [navi-virtual-filler="after"] {
42004
42315
  /* for some reason preact ends up puttin this element before the list items in some scenarios
42005
42316
  I've noticed that removing the ItemIndexToScrollOnMountRefContext.Provider
@@ -42090,6 +42401,11 @@ const ListUI = props => {
42090
42401
  columns,
42091
42402
  searchText,
42092
42403
  searchNoMatchMode = "remove",
42404
+ loading,
42405
+ loadingIndicator = "skeleton",
42406
+ loadingSkeletonCount = 3,
42407
+ skeletonTemplate,
42408
+ error,
42093
42409
  horizontal,
42094
42410
  spacing,
42095
42411
  ...rest
@@ -42161,9 +42477,63 @@ const ListUI = props => {
42161
42477
  const noMatchCount = tracker.noMatchCountSignal.value;
42162
42478
  const itemCount = tracker.countSignal.value;
42163
42479
  const allNoMatch = noMatchCount > 0 && noMatchCount === itemCount;
42480
+ const searching = Boolean(searchText);
42164
42481
  const fallbackDisabled = fallback !== undefined && !fallback;
42165
42482
  const searchFallbackDisabled = searchFallback !== undefined && !searchFallback;
42166
- const nothingToDisplay = allNoMatch && searchFallbackDisabled && searchNoMatchMode === "remove" || itemCount === 0 && fallbackDisabled;
42483
+ // No item is visible when the list is empty (filtering may happen outside the
42484
+ // list, dropping itemCount to 0) or when a search removed them all — only the
42485
+ // "remove" mode empties the view; "muted"/"below"/… keep items on screen.
42486
+ const noVisibleItems = itemCount === 0 || allNoMatch && searchNoMatchMode === "remove";
42487
+ // Which fallback message actually renders (mirrors SearchFallback / Fallback
42488
+ // below): during a search an empty/no-match result is a "no match" state (the
42489
+ // search fallback), otherwise an empty list is the "empty" state.
42490
+ const searchFallbackShown = (allNoMatch || searching && itemCount === 0) && !searchFallbackDisabled;
42491
+ const emptyFallbackShown = !searching && itemCount === 0 && !fallbackDisabled;
42492
+ // Hide the whole list — border included — when there is genuinely nothing to
42493
+ // show: no visible items AND no fallback message. Never while loading or in
42494
+ // error (the placeholder / error message ARE the content to display).
42495
+ const nothingToDisplay = !loading && !error && noVisibleItems && !searchFallbackShown && !emptyFallbackShown;
42496
+
42497
+ // Placeholder content replaces the real children: an error message when the
42498
+ // load failed (takes precedence), otherwise — while loading — skeleton rows
42499
+ // (default, count loadingSkeletonCount, look skeletonTemplate) or a single
42500
+ // centered loader when loadingIndicator="loader".
42501
+ let content = children;
42502
+ if (error) {
42503
+ content = jsxs(ListItem, {
42504
+ role: "presentation",
42505
+ baseClassName: "navi_list_item navi_list_error",
42506
+ children: [jsx("span", {
42507
+ className: "navi_list_error_icon",
42508
+ "aria-hidden": "true",
42509
+ children: "\u26A0"
42510
+ }), jsx("span", {
42511
+ children: error === true ? "Something went wrong." : error
42512
+ })]
42513
+ });
42514
+ } else if (loading) {
42515
+ if (loadingIndicator === "loader") {
42516
+ content = jsx(ListItem, {
42517
+ role: "presentation",
42518
+ "aria-hidden": "true",
42519
+ baseClassName: "navi_list_item navi_list_loader",
42520
+ children: jsx(LoadingIndicator, {})
42521
+ });
42522
+ } else {
42523
+ const template = skeletonTemplate ?? jsx(ListItem, {
42524
+ skeleton: true
42525
+ });
42526
+ const skeletons = [];
42527
+ let skeletonIndex = 0;
42528
+ while (skeletonIndex < loadingSkeletonCount) {
42529
+ skeletons.push(cloneElement(template, {
42530
+ key: `navi-list-skeleton-${skeletonIndex}`
42531
+ }));
42532
+ skeletonIndex++;
42533
+ }
42534
+ content = skeletons;
42535
+ }
42536
+ }
42167
42537
  return jsx(Box, {
42168
42538
  ...rest,
42169
42539
  ref: ref,
@@ -42177,6 +42547,8 @@ const ListUI = props => {
42177
42547
  expand: expand,
42178
42548
  "navi-zero-match": allNoMatch ? "" : undefined,
42179
42549
  "navi-nothing-to-display": nothingToDisplay ? "" : undefined,
42550
+ "navi-loading": loading ? "" : undefined,
42551
+ "navi-error": error ? "" : undefined,
42180
42552
  styleCSSVars: LIST_STYLE_CSS_VARS,
42181
42553
  pseudoClasses: LIST_PSEUDO_CLASSES,
42182
42554
  hasChildUsingForwardedProps: true,
@@ -42198,6 +42570,9 @@ const ListUI = props => {
42198
42570
  role: role,
42199
42571
  fallback: fallback,
42200
42572
  searchFallback: searchFallback,
42573
+ searching: searching,
42574
+ loading: loading,
42575
+ error: error,
42201
42576
  searchNoMatchMode: searchNoMatchMode,
42202
42577
  separator: separator,
42203
42578
  expandX: expandX || expand,
@@ -42208,7 +42583,7 @@ const ListUI = props => {
42208
42583
  renderWindow: renderWindow,
42209
42584
  virtualItemSizeSignal: virtualItemSizeSignal,
42210
42585
  pendingScrollRef: pendingScrollRef,
42211
- children: children
42586
+ children: content
42212
42587
  })
42213
42588
  });
42214
42589
  };
@@ -42238,6 +42613,11 @@ const ListFirstResolver = props => {
42238
42613
  * searchFallback?: import("ignore:preact").ComponentChildren,
42239
42614
  * searchText?: string,
42240
42615
  * searchNoMatchMode?: "remove" | "invisible_and_inert" | "muted" | "below",
42616
+ * loading?: boolean,
42617
+ * loadingIndicator?: "skeleton" | "loader",
42618
+ * loadingSkeletonCount?: number,
42619
+ * skeletonTemplate?: import("ignore:preact").ComponentChildren,
42620
+ * error?: boolean | import("ignore:preact").ComponentChildren,
42241
42621
  * separator?: boolean | import("ignore:preact").ComponentChildren,
42242
42622
  * lockSize?: boolean,
42243
42623
  * horizontal?: boolean,
@@ -42255,6 +42635,9 @@ const ListContent = ({
42255
42635
  role,
42256
42636
  fallback,
42257
42637
  searchFallback,
42638
+ searching,
42639
+ loading,
42640
+ error,
42258
42641
  searchNoMatchMode,
42259
42642
  separator,
42260
42643
  expandX,
@@ -42274,6 +42657,9 @@ const ListContent = ({
42274
42657
  role: role,
42275
42658
  fallback: fallback,
42276
42659
  searchFallback: searchFallback,
42660
+ searching: searching,
42661
+ loading: loading,
42662
+ error: error,
42277
42663
  searchNoMatchMode: searchNoMatchMode,
42278
42664
  separator: separator === true ? jsx(Separator, {
42279
42665
  margin: "0"
@@ -42772,6 +43158,9 @@ const UnorderedList = ({
42772
43158
  virtualItemSizeSignal,
42773
43159
  fallback,
42774
43160
  searchFallback,
43161
+ searching,
43162
+ loading,
43163
+ error,
42775
43164
  searchNoMatchMode,
42776
43165
  separator,
42777
43166
  horizontal,
@@ -42780,6 +43169,9 @@ const UnorderedList = ({
42780
43169
  children,
42781
43170
  ...rest
42782
43171
  }) => {
43172
+ // No empty/no-match message while loading or in error — the placeholder /
43173
+ // error message is the content, even though no items are tracked yet.
43174
+ const suppressFallback = loading || Boolean(error);
42783
43175
  return jsxs(Box, {
42784
43176
  as: "ul",
42785
43177
  flex: columns ? undefined : horizontal ? "x" : "y",
@@ -42791,11 +43183,13 @@ const UnorderedList = ({
42791
43183
  children: [jsx(BeforeFiller, {
42792
43184
  virtualItemSizeSignal: virtualItemSizeSignal,
42793
43185
  renderWindowStart: renderWindow.start
42794
- }), jsx(SearchFallback, {
43186
+ }), !suppressFallback && jsx(SearchFallback, {
42795
43187
  searchFallback: searchFallback,
43188
+ searching: searching,
42796
43189
  tracker: tracker
42797
- }), jsx(Fallback, {
43190
+ }), !suppressFallback && jsx(Fallback, {
42798
43191
  fallback: fallback,
43192
+ searching: searching,
42799
43193
  tracker: tracker
42800
43194
  }), jsx(SearchNoMatchModeContext.Provider, {
42801
43195
  value: searchNoMatchMode,
@@ -42820,16 +43214,18 @@ const UnorderedList = ({
42820
43214
  });
42821
43215
  };
42822
43216
 
42823
- // Show when all matchable items (those with a match prop) are non-matching.
42824
- // The match prop on List.Item signals participation in a matching system
42825
- // (search, filter, etc.). searchFallback appears when every such item has match=false.
43217
+ // The "no match" message. Shown when a search left nothing to display: either
43218
+ // every matchable item has match=false (in-list filtering), or the list is empty
43219
+ // during an active search (filtering done outside the list, so itemCount is 0).
42826
43220
  const SearchFallback = ({
42827
43221
  tracker,
42828
- searchFallback
43222
+ searchFallback,
43223
+ searching
42829
43224
  }) => {
42830
43225
  const itemCount = tracker.countSignal.value;
42831
43226
  const noMatchCount = tracker.noMatchCountSignal.value;
42832
- const showMatchFallback = noMatchCount > 0 && noMatchCount === itemCount;
43227
+ const allNoMatch = noMatchCount > 0 && noMatchCount === itemCount;
43228
+ const showMatchFallback = allNoMatch || searching && itemCount === 0;
42833
43229
  if (searchFallback === undefined) {
42834
43230
  searchFallback = naviI18n("list.no_match");
42835
43231
  }
@@ -42848,15 +43244,22 @@ const SearchFallback = ({
42848
43244
  children: searchFallback
42849
43245
  });
42850
43246
  };
43247
+ // The "empty list" message. Not shown during a search — an empty search result
43248
+ // is a "no match" state (SearchFallback), not an empty-list state.
42851
43249
  const Fallback = ({
42852
43250
  tracker,
42853
- fallback
43251
+ fallback,
43252
+ searching
42854
43253
  }) => {
42855
43254
  const itemCount = tracker.countSignal.value;
42856
- const showFallback = itemCount === 0;
43255
+ const showFallback = itemCount === 0 && !searching;
42857
43256
  if (fallback === undefined) {
42858
43257
  fallback = naviI18n("list.empty");
42859
43258
  }
43259
+ if (!fallback) {
43260
+ // explicitely disabled by user (<List fallback={false|null|''}>)
43261
+ return null;
43262
+ }
42860
43263
  if (!showFallback) {
42861
43264
  return null;
42862
43265
  }
@@ -42970,11 +43373,57 @@ const ListItemPresentation = props => {
42970
43373
  ...columnsOverrideProps
42971
43374
  });
42972
43375
  };
43376
+ // A <List.Item skeleton> — a non-interactive placeholder row shown while a list
43377
+ // is loading. It is presentation-only (not tracked, not selectable, aria-hidden)
43378
+ // and reuses <Text loading> for the shimmer. Box layout props (padding, spacing…)
43379
+ // pass through so a skeletonTemplate can match the real items' metrics; and when
43380
+ // children are provided they render as-is, so a template can reproduce a
43381
+ // multi-part item (e.g. title + subtitle) out of several <Text loading> bars.
43382
+ const ListItemSkeletonResolver = props => {
43383
+ const Next = useNextResolver();
43384
+ if (props.skeleton) {
43385
+ return jsx(ListItemSkeleton, {
43386
+ ...props
43387
+ });
43388
+ }
43389
+ return jsx(Next, {
43390
+ ...props
43391
+ });
43392
+ };
43393
+ const ListItemSkeleton = props => {
43394
+ // Without vertical padding the bars of consecutive rows touch and read as one
43395
+ // block; "s" is enough air for them to be seen as separate rows.
43396
+ // eslint-disable-next-line no-unused-vars
43397
+ const {
43398
+ skeleton,
43399
+ children,
43400
+ paddingY = "s",
43401
+ ...rest
43402
+ } = props;
43403
+ const columnsOverrideProps = useListItemColumnsOverrideProps(rest.style);
43404
+ return jsx(Box, {
43405
+ as: "li",
43406
+ role: "presentation",
43407
+ "aria-hidden": "true",
43408
+ paddingY: paddingY,
43409
+ ...rest,
43410
+ ...columnsOverrideProps,
43411
+ baseClassName: "navi_list_item navi_list_item_skeleton",
43412
+ children: children ?? jsx(Text, {
43413
+ loading: true
43414
+ })
43415
+ });
43416
+ };
42973
43417
  const ListItemUI = props => {
42974
- if (props.id === undefined) {
43418
+ // A stable id/index only matters when the item's identity must survive
43419
+ // reordering — i.e. it is selectable (selected/pointed state) or participates
43420
+ // in a matching system (search reorders items). A purely presentational,
43421
+ // static list doesn't need either, so don't nag about them there.
43422
+ const identityMatters = props.selectable || Boolean(props.matchInfo) || props.value !== undefined;
43423
+ if (identityMatters && props.id === undefined) {
42975
43424
  console.warn("ListItem is missing an explicit id prop. Provide a stable id so pointed/selected state survives search reordering.");
42976
43425
  }
42977
- if (props.index === undefined) {
43426
+ if (identityMatters && props.index === undefined) {
42978
43427
  console.warn("ListItem is missing an explicit index prop. Provide an index so item ordering is stable regardless of render order.");
42979
43428
  }
42980
43429
  const idDefault = useId();
@@ -42987,6 +43436,13 @@ const ListItemUI = props => {
42987
43436
  // (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
42988
43437
  // matchRanges }), so there is exactly one way to wire it up.
42989
43438
  const matchInfo = props.matchInfo;
43439
+ // Expose match on the tracked item: the tracker counts non-matching items via
43440
+ // `item.match === false` (drives noMatchCount → allNoMatch → the searchFallback
43441
+ // / hide-when-empty behavior). Without this a matchInfo-based search would
43442
+ // filter items out but never register them as "no match".
43443
+ if (matchInfo) {
43444
+ props.match = matchInfo.match;
43445
+ }
42990
43446
  // Derive filtered/hidden/muted from matchInfo.match + searchNoMatchMode context.
42991
43447
  if (matchInfo?.match === false) {
42992
43448
  if (searchNoMatchMode === "remove") {
@@ -43160,6 +43616,10 @@ const LIST_ITEM_PSEUDO_ELEMENTS = ["::highlight"];
43160
43616
  * selectable — when true, the item participates in selection (radio or checkbox
43161
43617
  * depending on whether the parent List has `multiple`). Requires
43162
43618
  * `value` and typically a <SelectableInput /> child.
43619
+ * skeleton — render a non-interactive placeholder row (a shimmering bar)
43620
+ * instead of a real item. Used as the List `skeletonTemplate`
43621
+ * while `loading`; Box layout props (padding…) pass through so the
43622
+ * placeholder can match the real items' metrics.
43163
43623
  * value — the JS value emitted by the list's action/uiAction when this item
43164
43624
  * is selected. Can be any type (string, number, object…).
43165
43625
  * selected — controlled selected state. Pass `selected === value` (single) or
@@ -43186,7 +43646,7 @@ const LIST_ITEM_PSEUDO_ELEMENTS = ["::highlight"];
43186
43646
  * CSS Highlight API.
43187
43647
  * ...rest — forwarded to the rendered <li> element
43188
43648
  */
43189
- const ListItem = createComponentResolver([ListItemFirstResolver, ListItemSelectableResolver, ListItemHeaderOrFooterResolver, ListItemPresentationResolver, ListItemUI]);
43649
+ const ListItem = createComponentResolver([ListItemFirstResolver, ListItemSkeletonResolver, ListItemSelectableResolver, ListItemHeaderOrFooterResolver, ListItemPresentationResolver, ListItemUI]);
43190
43650
  List.Item = ListItem;
43191
43651
 
43192
43652
  /**
@@ -44291,7 +44751,8 @@ const PickerButton = props => {
44291
44751
  // --anchor-width). Set true when the CONTENT should dictate the popover width
44292
44752
  // (e.g. a Wheel) instead of being stretched to the trigger — see
44293
44753
  // picker_custom.jsx.
44294
- popupWidthFitContent
44754
+ popupWidthFitContent,
44755
+ error
44295
44756
  } = props;
44296
44757
  const isSingleLine = maxLines === 1;
44297
44758
  const inputRef = useRef(null);
@@ -44308,6 +44769,7 @@ const PickerButton = props => {
44308
44769
  children
44309
44770
  } = inputProps;
44310
44771
  const loading = basePseudoState[":-navi-loading"];
44772
+ usePickerErrorCallout(uiStateController, error);
44311
44773
  return jsxs(Box, {
44312
44774
  as: "div",
44313
44775
  ref: ref,
@@ -44327,6 +44789,7 @@ const PickerButton = props => {
44327
44789
  ui: undefined,
44328
44790
  maxLines: undefined,
44329
44791
  popupWidthFitContent: undefined,
44792
+ error: undefined,
44330
44793
  dayLabel: undefined
44331
44794
  // This wrapper will receive keyboard event bubbling from the picker popup content
44332
44795
  // we re-dispatch on the input (to get escape to close for instance)
@@ -44451,6 +44914,33 @@ const PickerButton = props => {
44451
44914
  const isWithinPickerContent = (el, pickerEl) => {
44452
44915
  return pickerEl.querySelector(".navi_picker_content")?.contains(el);
44453
44916
  };
44917
+ const PICKER_ERROR_TOKEN = createOpenToken();
44918
+ // The `error` prop rides the control's own callout — the same surface already
44919
+ // used for failing constraints — so a caller never has to decide where to put
44920
+ // the message. It shows whether the popup is open or closed, and dismissing it
44921
+ // discards that error: only a new `error` value raises another one.
44922
+ const usePickerErrorCallout = (uiStateController, error) => {
44923
+ useEffect(() => {
44924
+ const {
44925
+ callout
44926
+ } = uiStateController.rules;
44927
+ const closeEvent = new CustomEvent("picker_error_cleared", {
44928
+ detail: {}
44929
+ });
44930
+ if (!error) {
44931
+ callout.removeOpenToken(PICKER_ERROR_TOKEN, closeEvent);
44932
+ return undefined;
44933
+ }
44934
+ callout.addOpenToken(PICKER_ERROR_TOKEN, {
44935
+ message: error === true ? "Something went wrong." : error,
44936
+ status: "error",
44937
+ skipFocus: true
44938
+ });
44939
+ return () => {
44940
+ callout.removeOpenToken(PICKER_ERROR_TOKEN, closeEvent);
44941
+ };
44942
+ }, [error]);
44943
+ };
44454
44944
  const PickerInput = props => {
44455
44945
  const {
44456
44946
  ui,
@@ -44577,10 +45067,14 @@ const PickerFirstResolver = props => {
44577
45067
  * step?: string | number,
44578
45068
  * disabled?: boolean,
44579
45069
  * readOnly?: boolean,
45070
+ * error?: boolean | string,
44580
45071
  * uiAction?: (value: any, event: Event) => void,
44581
45072
  * action?: (value: any, event: Event) => void,
44582
45073
  * children?: import("ignore:preact").ComponentChildren,
44583
45074
  * mode?: "popover" | "dialog",
45075
+ * popoverMode?: "nearby" | "overlay",
45076
+ * positionArea?: string,
45077
+ * popupWidthFitContent?: boolean,
44584
45078
  * variant?: "icon" | "headless",
44585
45079
  * icon?: import("ignore:preact").ComponentChildren,
44586
45080
  * maxLines?: number,
@@ -44593,6 +45087,24 @@ const PickerFirstResolver = props => {
44593
45087
  * ref?: import("ignore:preact").RefObject<HTMLElement>,
44594
45088
  * [key: string]: any,
44595
45089
  * }>}
45090
+ * @param {boolean|string} [error] Something went wrong around this picker (its
45091
+ * content failed to load, its value could not be resolved…). Shown as a
45092
+ * callout on the trigger, open or closed — the caller has nothing to place.
45093
+ * Dismissing it discards that error; a new `error` value raises another one.
45094
+ * @param {"nearby"|"overlay"} [popoverMode="nearby"] "overlay" lays the popover
45095
+ * over the trigger, "nearby" leaves a small gap below it.
45096
+ * @param {string} [positionArea] Where the popup goes — relative to the trigger
45097
+ * in popover mode, relative to the viewport in dialog mode. Same grammar as
45098
+ * Popover/Dialog's own `positionArea` ("top", "right-end", "inset(top-left)",
45099
+ * …). Defaults to "bottom-start" in popover mode ("inset(top-left)" when
45100
+ * popoverMode is "overlay"), and to Dialog's own "center" in dialog mode. A
45101
+ * popover still flips to the opposite side on its own when there isn't
45102
+ * enough room.
45103
+ * @param {boolean} [popupWidthFitContent] By default the popup is at least as
45104
+ * wide as the trigger. Set this to let the content size it instead, so a
45105
+ * popup narrower than the trigger stays narrow.
45106
+ * @param {number|string} [popoverMaxHeight] Soft cap on the popover's height
45107
+ * (default 300px). The popover shrinks below it when space is tight.
44596
45108
  */
44597
45109
  const Picker = createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
44598
45110
  Picker.UI = PickerDefaultUI;
@@ -50764,67 +51276,6 @@ const Address = ({
50764
51276
  });
50765
51277
  };
50766
51278
 
50767
- const LoadingDotsSvg = () => {
50768
- return jsxs("svg", {
50769
- viewBox: "0 0 200 200",
50770
- width: "100%",
50771
- height: "100%",
50772
- xmlns: "http://www.w3.org/2000/svg",
50773
- children: [jsx("rect", {
50774
- fill: "currentColor",
50775
- stroke: "currentColor",
50776
- "stroke-width": "15",
50777
- width: "30",
50778
- height: "30",
50779
- x: "25",
50780
- y: "85",
50781
- children: jsx("animate", {
50782
- attributeName: "opacity",
50783
- calcMode: "spline",
50784
- dur: "2",
50785
- values: "1;0;1;",
50786
- keySplines: ".5 0 .5 1;.5 0 .5 1",
50787
- repeatCount: "indefinite",
50788
- begin: "-.4"
50789
- })
50790
- }), jsx("rect", {
50791
- fill: "currentColor",
50792
- stroke: "currentColor",
50793
- "stroke-width": "15",
50794
- width: "30",
50795
- height: "30",
50796
- x: "85",
50797
- y: "85",
50798
- children: jsx("animate", {
50799
- attributeName: "opacity",
50800
- calcMode: "spline",
50801
- dur: "2",
50802
- values: "1;0;1;",
50803
- keySplines: ".5 0 .5 1;.5 0 .5 1",
50804
- repeatCount: "indefinite",
50805
- begin: "-.2"
50806
- })
50807
- }), jsx("rect", {
50808
- fill: "currentColor",
50809
- stroke: "currentColor",
50810
- "stroke-width": "15",
50811
- width: "30",
50812
- height: "30",
50813
- x: "145",
50814
- y: "85",
50815
- children: jsx("animate", {
50816
- attributeName: "opacity",
50817
- calcMode: "spline",
50818
- dur: "2",
50819
- values: "1;0;1;",
50820
- keySplines: ".5 0 .5 1;.5 0 .5 1",
50821
- repeatCount: "indefinite",
50822
- begin: "0"
50823
- })
50824
- })]
50825
- });
50826
- };
50827
-
50828
51279
  const formatNumber = (value, { lang = languagesSignal.value } = {}) => {
50829
51280
  return new Intl.NumberFormat(lang).format(value);
50830
51281
  };
@@ -52229,23 +52680,6 @@ const Image = ({
52229
52680
  });
52230
52681
  };
52231
52682
 
52232
- const LoadingIndicator = ({
52233
- variant = "circle",
52234
- ...props
52235
- }) => {
52236
- if (variant === "dots") {
52237
- return jsx(Icon, {
52238
- ...props,
52239
- children: jsx(LoadingDotsSvg, {})
52240
- });
52241
- }
52242
- return jsx(Icon, {
52243
- circle: true,
52244
- ...props,
52245
- children: jsx(LoadingIndicatorFluid, {})
52246
- });
52247
- };
52248
-
52249
52683
  const Svg = props => {
52250
52684
  return jsx(Box, {
52251
52685
  ...props,
@@ -52744,7 +53178,12 @@ const SidePanel = ({
52744
53178
  onClose: onClose,
52745
53179
  layer: layer,
52746
53180
  anchorCustomEventDetail: "ignore",
52747
- positionArea: side,
53181
+ positionArea: side
53182
+ // A side panel is flush against the edge it slides in from — none of
53183
+ // Dialog's own default gap with the container.
53184
+ ,
53185
+
53186
+ marginWithContainer: 0,
52748
53187
  animation: animation === true ? `slide-from-${side}` : animation,
52749
53188
  pointerInteractionOutsideEffect: closeOnClickOutside ? "close" : "none",
52750
53189
  focusCapture: closeOnClickOutside,
@@ -52960,5 +53399,5 @@ const UserSvg = () => jsx("svg", {
52960
53399
  })
52961
53400
  });
52962
53401
 
52963
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, Details, Dialog, DialogLayout, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePickerMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
53402
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, Details, Dialog, DialogLayout, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
52964
53403
  //# sourceMappingURL=jsenv_navi.js.map