@jsenv/navi 0.29.230 → 0.29.232

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.
@@ -80,7 +80,7 @@ installImportMetaCssBuild(import.meta);
80
80
  * any of these, and a number is the last resort, not the first tool.
81
81
  */
82
82
 
83
- const css$16 = /* css */ `@layer navi {
83
+ const css$17 = /* css */ `@layer navi {
84
84
  :root {
85
85
  --navi-z-index-control-hovered: 1;
86
86
  --navi-z-index-control-focused: 2;
@@ -92,7 +92,7 @@ const css$16 = /* css */ `@layer navi {
92
92
  }
93
93
  }
94
94
  `;
95
- import.meta.css = [css$16, "@jsenv/navi/src/navi_z_indexes.js"];
95
+ import.meta.css = [css$17, "@jsenv/navi/src/navi_z_indexes.js"];
96
96
 
97
97
  const addIntoArray = (array, ...valuesToAdd) => {
98
98
  if (valuesToAdd.length === 1) {
@@ -328,7 +328,7 @@ installImportMetaCssBuild(import.meta);
328
328
 
329
329
  const URL_TARGET_ATTRIBUTE = "data-url-target";
330
330
 
331
- const css$15 = /* css */ `@layer navi {
331
+ const css$16 = /* css */ `@layer navi {
332
332
  [data-url-target] {
333
333
  animation: navi_url_target var(--navi-url-target-duration, 2s)
334
334
  ease-out;
@@ -346,7 +346,7 @@ const css$15 = /* css */ `@layer navi {
346
346
  }
347
347
  }
348
348
  `;
349
- import.meta.css = [css$15, "@jsenv/navi/src/nav/url_target/url_target.js"];
349
+ import.meta.css = [css$16, "@jsenv/navi/src/nav/url_target/url_target.js"];
350
350
 
351
351
  let urlTargetOptions = {
352
352
  block: "start",
@@ -1683,7 +1683,7 @@ const useActionStatus = (action) => {
1683
1683
  };
1684
1684
 
1685
1685
  installImportMetaCssBuild(import.meta);
1686
- const css$14 = /* css */`.action_error {
1686
+ const css$15 = /* css */`.action_error {
1687
1687
  background: #fdd;
1688
1688
  border: 1px solid red;
1689
1689
  margin-top: 0;
@@ -1707,7 +1707,7 @@ const ActionRenderer = ({
1707
1707
  children,
1708
1708
  disabled
1709
1709
  }) => {
1710
- import.meta.css = [css$14, "@jsenv/navi/src/action/action_renderer.jsx"];
1710
+ import.meta.css = [css$15, "@jsenv/navi/src/action/action_renderer.jsx"];
1711
1711
  if (action === undefined) {
1712
1712
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
1713
1713
  }
@@ -2902,6 +2902,9 @@ ${lines.join("\n")}`,
2902
2902
  const willPrerunSet = new Set();
2903
2903
  const willRunSet = new Set();
2904
2904
  const willPromoteSet = new Set(); // prerun -> run requested
2905
+ // Asked for, already under way: the request adds no work, and answers with
2906
+ // the run that is already carrying it (see join_active_runs below).
2907
+ const willJoinSet = new Set();
2905
2908
  const staysRunningSet = new Set();
2906
2909
  const staysAbortedSet = new Set();
2907
2910
  const staysFailedSet = new Set();
@@ -2953,8 +2956,11 @@ ${lines.join("\n")}`,
2953
2956
  } else {
2954
2957
  willRunSet.add(action);
2955
2958
  }
2959
+ } else {
2960
+ // The request performs nothing, and still has to be answered: what
2961
+ // the caller asked for is the answer, not the running.
2962
+ willJoinSet.add(action);
2956
2963
  }
2957
- // Otherwise, ignore the request (action stays as-is)
2958
2964
  } else if (isPrerun) {
2959
2965
  willPrerunSet.add(action);
2960
2966
  } else {
@@ -2981,6 +2987,7 @@ ${lines.join("\n")}`,
2981
2987
  // Special case: action was prerun but not yet requested to run
2982
2988
  // Just promote it to "run requested" without rerunning
2983
2989
  willPromoteSet.add(actionToRun);
2990
+ willJoinSet.add(actionToRun);
2984
2991
  continue;
2985
2992
  }
2986
2993
  handleActionRequest(actionToRun, "run");
@@ -3134,6 +3141,27 @@ ${lines.join("\n")}`);
3134
3141
  actionToPromote.isPrerunSignal.value = false;
3135
3142
  }
3136
3143
  }
3144
+
3145
+ // A run asked for an action that is already running, or that already holds
3146
+ // the answer, performs nothing — and answers all the same, with the run
3147
+ // that IS carrying it. `await action.run()` then means the same thing to
3148
+ // every caller: the second one waits for the first one's request and reads
3149
+ // the same data, instead of being handed `undefined` before the network has
3150
+ // said anything. See the run() JSDoc.
3151
+ {
3152
+ for (const actionToJoin of willJoinSet) {
3153
+ const runningPromise = actionPromiseMap.get(actionToJoin);
3154
+ if (runningPromise) {
3155
+ hasAsync = true;
3156
+ resultArray.push({ type: "async", promise: runningPromise });
3157
+ } else {
3158
+ resultArray.push({
3159
+ type: "sync",
3160
+ result: actionToJoin.dataSignal.peek(),
3161
+ });
3162
+ }
3163
+ }
3164
+ }
3137
3165
  if (DEBUG$2) {
3138
3166
  console.groupEnd();
3139
3167
  }
@@ -3288,10 +3316,15 @@ const createAction = (callback, rootOptions = {}) => {
3288
3316
  return dispatchSingleAction(action, "prerun", options);
3289
3317
  };
3290
3318
  /**
3291
- * Requests the action's data. An action that is already RUNNING or
3292
- * COMPLETED already has it, so the request is a no-op there: use `rerun()`
3293
- * to force a fresh run ("refresh", "check now", any explicit user intent to
3294
- * go back to the network).
3319
+ * Requests the action's data, and resolves with it.
3320
+ *
3321
+ * An action that is already RUNNING or COMPLETED already has it, so the
3322
+ * request starts nothing — it JOINS: the caller waits for the run that is
3323
+ * already carrying the question and reads the same answer. Which is what
3324
+ * makes `await action.run()` say one thing to every caller, whether it is
3325
+ * the one that asked first or the one that arrived while the request was
3326
+ * out. Use `rerun()` to force a fresh run ("refresh", "check now", any
3327
+ * explicit user intent to go back to the network).
3295
3328
  */
3296
3329
  const run = (options) => {
3297
3330
  action.debug(`${action}.run(${stringifyForDisplay(options)})`);
@@ -4854,6 +4887,25 @@ const warnSignalCollision = (props, controlType, stateProp) => {
4854
4887
  }
4855
4888
  };
4856
4889
 
4890
+ /**
4891
+ * A signal handed to a prop that holds a plain value is the `signal` binding
4892
+ * written in the wrong place. Nothing throws: the signal object becomes the
4893
+ * value, and a value that cannot be written to the DOM is replaced by a
4894
+ * `window.__navi_js('…')` reference string — which is then what the field
4895
+ * shows on screen (see toDomValue in controller_registry.js).
4896
+ */
4897
+ const warnSignalAsState = (props, controlType, stateProps) => {
4898
+ for (const stateProp of stateProps) {
4899
+ if (isSignal(props[stateProp])) {
4900
+ console.warn(
4901
+ `[navi] "${controlType}" got a signal as "${stateProp}". ` +
4902
+ `A signal binds a control through "signal" — did you mean signal={...}? ` +
4903
+ `As "${stateProp}" it is taken for the value itself.`,
4904
+ );
4905
+ }
4906
+ }
4907
+ };
4908
+
4857
4909
  // In-memory registry of all mounted ui state controllers keyed by their id.
4858
4910
  // Allows direct controller access without dispatching DOM events — used by external
4859
4911
  // callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
@@ -5864,7 +5916,7 @@ installImportMetaCssBuild(import.meta);
5864
5916
  * (see calloutTemplate in callout.js).
5865
5917
  */
5866
5918
 
5867
- const css$13 = /* css */`.navi_callout_status_icon {
5919
+ const css$14 = /* css */`.navi_callout_status_icon {
5868
5920
  --x-callout-status-icon-color: var(--navi-callout-neutral-color);
5869
5921
  box-sizing: border-box;
5870
5922
  aspect-ratio: 1;
@@ -5935,7 +5987,7 @@ const CalloutStatusIcon = ({
5935
5987
  status = "info",
5936
5988
  shape = "square"
5937
5989
  }) => {
5938
- import.meta.css = [css$13, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
5990
+ import.meta.css = [css$14, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
5939
5991
  return jsx("span", {
5940
5992
  className: "navi_callout_status_icon",
5941
5993
  "data-status": status === "none" ? undefined : status,
@@ -5957,7 +6009,7 @@ installImportMetaCssBuild(import.meta);
5957
6009
  // Unique for the page's lifetime: a caller may write the id in a commandfor.
5958
6010
  let calloutCount = 0;
5959
6011
 
5960
- const css$12 = /* css */ `
6012
+ const css$13 = /* css */ `
5961
6013
  @layer navi {
5962
6014
  .navi_callout {
5963
6015
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -6305,7 +6357,7 @@ const openCallout = (
6305
6357
  debug = () => {},
6306
6358
  } = {},
6307
6359
  ) => {
6308
- import.meta.css = [css$12, "@jsenv/navi/src/control/rules/callout/callout.js"];
6360
+ import.meta.css = [css$13, "@jsenv/navi/src/control/rules/callout/callout.js"];
6309
6361
  if (debug === true) {
6310
6362
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
6311
6363
  }
@@ -18515,8 +18567,12 @@ const VISUAL_PROPS = {
18515
18567
  outlineWidth: PASS_THROUGH,
18516
18568
  boxDecorationBreak: PASS_THROUGH,
18517
18569
  boxShadow: PASS_THROUGH,
18518
- background: PASS_THROUGH,
18519
- backgroundColor: PASS_THROUGH,
18570
+ background: (value) => {
18571
+ return { background: resolveSurfaceKeyword(value) };
18572
+ },
18573
+ backgroundColor: (value) => {
18574
+ return { backgroundColor: resolveSurfaceKeyword(value) };
18575
+ },
18520
18576
  backgroundImage: PASS_THROUGH,
18521
18577
  backgroundSize: PASS_THROUGH,
18522
18578
  border: PASS_THROUGH,
@@ -18881,6 +18937,18 @@ const resolveColorKeyword = (value) => {
18881
18937
  return COLOR_KEYWORD_MAP[value] || value;
18882
18938
  };
18883
18939
 
18940
+ // The two planes an app paints: the paper its content sits on, and the frame
18941
+ // around it (see navi_css_vars.js). Named rather than derived at each call
18942
+ // site, so a top bar and a side menu are the same plane without agreeing on a
18943
+ // formula.
18944
+ const SURFACE_KEYWORD_MAP = {
18945
+ surface: "var(--navi-surface-color)",
18946
+ chrome: "var(--navi-chrome-color)",
18947
+ };
18948
+ const resolveSurfaceKeyword = (value) => {
18949
+ return SURFACE_KEYWORD_MAP[value] || value;
18950
+ };
18951
+
18884
18952
  const DEFAULT_DISPLAY_BY_TAG_NAME = {
18885
18953
  "inline": new Set([
18886
18954
  "a",
@@ -20810,6 +20878,16 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
20810
20878
  * children?: import("ignore:preact").ComponentChildren,
20811
20879
  * [key: string]: any,
20812
20880
  * }>}
20881
+ * @param {string} [background] Any CSS background, plus two keywords for the
20882
+ * planes an app paints: `"surface"` is the paper content sits on,
20883
+ * `"chrome"` the frame around a screen — a top bar, a side menu, a toolbar.
20884
+ * Both follow the theme, so a bar stays a bar in dark mode
20885
+ * (`--navi-surface-color` / `--navi-chrome-color`). `backgroundColor` takes
20886
+ * the same two.
20887
+ * @param {string} [color] Any CSS color, plus the five ink keywords —
20888
+ * `"primary"`, `"secondary"`, `"emphasis"`, `"discrete"`, `"hint"` — which
20889
+ * say how loud a text is rather than which color it is (see
20890
+ * docs/typography.md).
20813
20891
  * @param {object} [interactions] What this box answers, by interaction name
20814
20892
  * (see docs/interactions.md). A plain box has no wiring of its own: it does
20815
20893
  * nothing with `action` (that is a control's prop), so a click on it is
@@ -22721,6 +22799,20 @@ const debug$2 = (...args) => {
22721
22799
  // Base URL management
22722
22800
  let baseFileUrl;
22723
22801
  let baseUrl;
22802
+ /**
22803
+ * Where this document's addresses start, for a document that is not at the
22804
+ * root: a panel served from "/admin/admin.html" declares
22805
+ * `setBaseUrl("/admin/admin.html")` and then writes its routes as "/places",
22806
+ * "/places/:placeId". The url "/admin/places/42" matches, and `buildUrl` puts
22807
+ * the prefix back.
22808
+ *
22809
+ * Call it in the routes module, above the `route()` calls it governs — they
22810
+ * read the base url as they are created, so an entry point calling it after
22811
+ * importing them is too late. The server must serve the document for every
22812
+ * address below it (see docs/navigation.md).
22813
+ *
22814
+ * @param {string} value - the document's own url, absolute or relative.
22815
+ */
22724
22816
  const setBaseUrl = (value) => {
22725
22817
  baseFileUrl = new URL(
22726
22818
  value,
@@ -28563,7 +28655,7 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
28563
28655
  // the root pictures must NOT move (they carry the whole viewport, blank bands
28564
28656
  // included).
28565
28657
 
28566
- const css$11 = /* css */`:root[data-navi-route-transition] [data-navi-route-transition-area] {
28658
+ const css$12 = /* css */`:root[data-navi-route-transition] [data-navi-route-transition-area] {
28567
28659
  view-transition-name: navi-route-transition;
28568
28660
  }
28569
28661
 
@@ -28884,7 +28976,7 @@ const RouteTransitionArea = ({
28884
28976
  children,
28885
28977
  ...rest
28886
28978
  }) => {
28887
- import.meta.css = [css$11, "@jsenv/navi/src/nav/route_transition.jsx"];
28979
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
28888
28980
  installTransitionWindowCss();
28889
28981
  const props = {
28890
28982
  ...rest,
@@ -28954,7 +29046,7 @@ const RouteTransitionArea = ({
28954
29046
  * @returns {() => void} remove this relation.
28955
29047
  */
28956
29048
  const defineRouteTransition = (from, to, transition) => {
28957
- import.meta.css = [css$11, "@jsenv/navi/src/nav/route_transition.jsx"];
29049
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
28958
29050
  installTransitionWindowCss();
28959
29051
  if (!to) {
28960
29052
  throw new TypeError(`defineRouteTransition needs a destination: "to" is ${to}. The page reached from anywhere is written defineRouteTransition(null, THAT_PAGE, ...) — there is no relation the other way round, a page LEFT for anywhere being the back half of that one.`);
@@ -28995,7 +29087,7 @@ const defineRouteTransition = (from, to, transition) => {
28995
29087
  * @returns {() => void} remove this default.
28996
29088
  */
28997
29089
  const defineRouteDefaultTransition = transition => {
28998
- import.meta.css = [css$11, "@jsenv/navi/src/nav/route_transition.jsx"];
29090
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
28999
29091
  installTransitionWindowCss();
29000
29092
  const value = normalizeTransition(transition);
29001
29093
  defaultTransition = value;
@@ -29817,7 +29909,7 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
29817
29909
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
29818
29910
  // The name the box wears while it travels, and only then (see nameForTravel).
29819
29911
  const TRAVEL_NAME = "navi-route-travel";
29820
- const css$10 = /* css */`.navi_route_travel {
29912
+ const css$11 = /* css */`.navi_route_travel {
29821
29913
  position: relative;
29822
29914
 
29823
29915
  &[data-travel-by-drag="x"] {
@@ -30106,7 +30198,7 @@ const RouteTravel = ({
30106
30198
  children,
30107
30199
  ...rest
30108
30200
  }) => {
30109
- import.meta.css = [css$10, "@jsenv/navi/src/nav/route_travel.jsx"];
30201
+ import.meta.css = [css$11, "@jsenv/navi/src/nav/route_travel.jsx"];
30110
30202
  installTransitionWindowCss();
30111
30203
  const elementRef = useRef();
30112
30204
  const gestureRef = useRef(null);
@@ -37154,9 +37246,11 @@ const useControlProps = (props, {
37154
37246
  * props, which is how `signal` came to be forgotten.
37155
37247
  */
37156
37248
  const isControlValueGivenByProps = props => Object.hasOwn(props, "value") || Object.hasOwn(props, "defaultValue") || Object.hasOwn(props, "signal");
37249
+ const CONTROL_STATE_PROPS = ["value", "defaultValue", "checked", "defaultChecked", "open", "defaultOpen"];
37157
37250
  const createControlInfo = (props, {
37158
37251
  controlType
37159
37252
  }) => {
37253
+ warnSignalAsState(props, controlType, CONTROL_STATE_PROPS);
37160
37254
  let statePropName;
37161
37255
  let defaultStatePropName;
37162
37256
  let stateInitial;
@@ -39792,7 +39886,7 @@ const setupNetworkMonitoring = () => {
39792
39886
  setupNetworkMonitoring();
39793
39887
 
39794
39888
  installImportMetaCssBuild(import.meta);
39795
- const css$$ = /* css */`.navi_loading_indicator_fluid_container {
39889
+ const css$10 = /* css */`.navi_loading_indicator_fluid_container {
39796
39890
  border-radius: inherit;
39797
39891
  opacity: 1;
39798
39892
  width: 100%;
@@ -39823,7 +39917,7 @@ const LoadingIndicatorFluid = ({
39823
39917
  visuallyHidden,
39824
39918
  ...rest
39825
39919
  }) => {
39826
- import.meta.css = [css$$, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
39920
+ import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
39827
39921
  const ref = useRef(null);
39828
39922
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
39829
39923
  const [containerWidth, setContainerWidth] = useState(0);
@@ -40029,7 +40123,7 @@ const LoadingRectangleSvg = ({
40029
40123
  };
40030
40124
 
40031
40125
  installImportMetaCssBuild(import.meta);
40032
- const css$_ = /* css */`.navi_loading_outline_wrapper {
40126
+ const css$$ = /* css */`.navi_loading_outline_wrapper {
40033
40127
  top: max(var(--loading-outline-min-inset, -100vh),
40034
40128
  var(--loading-rectangle-top, 0px));
40035
40129
  right: max(var(--loading-outline-min-inset, -100vh),
@@ -40050,7 +40144,7 @@ const css$_ = /* css */`.navi_loading_outline_wrapper {
40050
40144
  }
40051
40145
  `;
40052
40146
  const LoadingOutline = props => {
40053
- import.meta.css = [css$_, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
40147
+ import.meta.css = [css$$, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
40054
40148
  if (props.containerRef) {
40055
40149
  const container = props.containerRef.current;
40056
40150
  if (!container) {
@@ -40185,7 +40279,7 @@ const LoadingOutlineWithPortal = props => {
40185
40279
  };
40186
40280
 
40187
40281
  installImportMetaCssBuild(import.meta);
40188
- const css$Z = /* css */`.navi_text_anchor {
40282
+ const css$_ = /* css */`.navi_text_anchor {
40189
40283
  vertical-align: baseline;
40190
40284
  user-select: none;
40191
40285
  overflow: hidden;
@@ -40223,7 +40317,7 @@ const TextAnchor = ({
40223
40317
  textSize,
40224
40318
  lineLayout
40225
40319
  }) => {
40226
- import.meta.css = [css$Z, "@jsenv/navi/src/text/text_anchor.jsx"];
40320
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text_anchor.jsx"];
40227
40321
  const anchorRef = useRef();
40228
40322
 
40229
40323
  // Plain useLayoutEffect would also fire while an ancestor dialog/popover
@@ -40451,7 +40545,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
40451
40545
  };
40452
40546
 
40453
40547
  installImportMetaCssBuild(import.meta);
40454
- const css$Y = /* css */`@layer navi {
40548
+ const css$Z = /* css */`@layer navi {
40455
40549
  .navi_text {
40456
40550
  &[data-skeleton] {
40457
40551
  border-radius: .2em;
@@ -41020,7 +41114,7 @@ const TextShrinkWrap = props => {
41020
41114
  });
41021
41115
  };
41022
41116
  const TextUI = props => {
41023
- import.meta.css = [css$Y, "@jsenv/navi/src/text/text.jsx"];
41117
+ import.meta.css = [css$Z, "@jsenv/navi/src/text/text.jsx"];
41024
41118
  let {
41025
41119
  ref,
41026
41120
  spacing,
@@ -41244,7 +41338,7 @@ const Icon = ({
41244
41338
  fillLine,
41245
41339
  ...props
41246
41340
  }) => {
41247
- import.meta.css = [css$Y, "@jsenv/navi/src/text/text.jsx"];
41341
+ import.meta.css = [css$Z, "@jsenv/navi/src/text/text.jsx"];
41248
41342
  const innerChildren = href ? jsx("svg", {
41249
41343
  width: "100%",
41250
41344
  height: "100%",
@@ -41434,7 +41528,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
41434
41528
  };
41435
41529
 
41436
41530
  installImportMetaCssBuild(import.meta);
41437
- const css$X = /* css */`@layer navi {
41531
+ const css$Y = /* css */`@layer navi {
41438
41532
  .navi_link {
41439
41533
  --link-border-radius: unset;
41440
41534
  --link-outline-color: var(--navi-focus-outline-color);
@@ -41892,7 +41986,7 @@ Object.assign(PSEUDO_CLASSES, {
41892
41986
  * @param {boolean} [props.readOnly]
41893
41987
  */
41894
41988
  const Link = props => {
41895
- import.meta.css = [css$X, "@jsenv/navi/src/nav/link/link.jsx"];
41989
+ import.meta.css = [css$Y, "@jsenv/navi/src/nav/link/link.jsx"];
41896
41990
  if (props.route) {
41897
41991
  return jsx(LinkWithRoute, {
41898
41992
  ...props
@@ -42460,7 +42554,7 @@ const rememberProgressAnimation = (navElement, travel) => {
42460
42554
  travel.progressAnimation = animation;
42461
42555
  travel.progressFrom = animation ? parseFloat(animation.effect.getKeyframes()[0]["--slide-travel-progress"]) || 0 : 0;
42462
42556
  };
42463
- const css$W = /* css */`@layer navi {
42557
+ const css$X = /* css */`@layer navi {
42464
42558
  .navi_nav {
42465
42559
  --nav-border: none;
42466
42560
  --nav-padding: 0px;
@@ -42724,7 +42818,7 @@ const Nav = ({
42724
42818
  slideContainer,
42725
42819
  ...props
42726
42820
  }) => {
42727
- import.meta.css = [css$W, "@jsenv/navi/src/nav/link/nav.jsx"];
42821
+ import.meta.css = [css$X, "@jsenv/navi/src/nav/link/nav.jsx"];
42728
42822
  const defaultRef = useRef();
42729
42823
  props.ref = props.ref || defaultRef;
42730
42824
  const navRef = props.ref;
@@ -43600,7 +43694,7 @@ const buildBinderCenterlinePathBelow = ({
43600
43694
  };
43601
43695
 
43602
43696
  installImportMetaCssBuild(import.meta);
43603
- const css$V = /* css */`@layer navi {
43697
+ const css$W = /* css */`@layer navi {
43604
43698
  .navi_binder {
43605
43699
  --binder-border-width: var(--navi-control-border-width);
43606
43700
  --binder-border-radius: var(--navi-control-border-radius);
@@ -43885,7 +43979,7 @@ const Binder = ({
43885
43979
  pagePadding,
43886
43980
  ...props
43887
43981
  }) => {
43888
- import.meta.css = [css$V, "@jsenv/navi/src/nav/binder/binder.jsx"];
43982
+ import.meta.css = [css$W, "@jsenv/navi/src/nav/binder/binder.jsx"];
43889
43983
  const items = toChildArray(children).map((child, index) => {
43890
43984
  const {
43891
43985
  value: itemValue,
@@ -44332,7 +44426,7 @@ const writeSpace = (area) => {
44332
44426
  };
44333
44427
 
44334
44428
  installImportMetaCssBuild(import.meta);
44335
- const css$U = /* css */`@layer navi {
44429
+ const css$V = /* css */`@layer navi {
44336
44430
  :root {
44337
44431
  --navi-fixed-bar-width: 56px;
44338
44432
  --navi-fixed-bar-height: 56px;
@@ -44452,7 +44546,7 @@ const FixedBar = ({
44452
44546
  border = true,
44453
44547
  ...props
44454
44548
  }) => {
44455
- import.meta.css = [css$U, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
44549
+ import.meta.css = [css$V, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
44456
44550
  const defaultRef = useRef();
44457
44551
  props.ref = props.ref || defaultRef;
44458
44552
  // Said with the width the border rule reads rather than with an attribute of
@@ -44546,7 +44640,7 @@ const FixedBar = ({
44546
44640
  // Subpixel layout rounds rectangles up on boxes that fit exactly.
44547
44641
  const OVERFLOW_TOLERANCE = 1;
44548
44642
 
44549
- const css$T = /* css */ `
44643
+ const css$U = /* css */ `
44550
44644
  [data-navi-overflow-x] {
44551
44645
  outline: 2px dashed #e74c3c;
44552
44646
  outline-offset: -2px;
@@ -44570,7 +44664,7 @@ const detectHorizontalOverflow = ({
44570
44664
  let styleEl = null;
44571
44665
  if (highlight) {
44572
44666
  styleEl = document.createElement("style");
44573
- styleEl.textContent = css$T;
44667
+ styleEl.textContent = css$U;
44574
44668
  document.head.appendChild(styleEl);
44575
44669
  }
44576
44670
 
@@ -44726,7 +44820,7 @@ const useFocusGroup = (
44726
44820
 
44727
44821
  installImportMetaCssBuild(import.meta);
44728
44822
  const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
44729
- const css$S = /* css */`.navi_summary_marker {
44823
+ const css$T = /* css */`.navi_summary_marker {
44730
44824
  flex-shrink: 0;
44731
44825
  width: 1em;
44732
44826
  height: 1em;
@@ -44818,7 +44912,7 @@ const SummaryMarker = ({
44818
44912
  loading,
44819
44913
  openDirection = "down"
44820
44914
  }) => {
44821
- import.meta.css = [css$S, "@jsenv/navi/src/control/details/summary_marker.jsx"];
44915
+ import.meta.css = [css$T, "@jsenv/navi/src/control/details/summary_marker.jsx"];
44822
44916
  const showLoading = useDebounceTrue(loading, 300);
44823
44917
  return jsx("span", {
44824
44918
  className: "navi_summary_marker",
@@ -44864,7 +44958,7 @@ const SummaryMarker = ({
44864
44958
  };
44865
44959
 
44866
44960
  installImportMetaCssBuild(import.meta);
44867
- const css$R = /* css */`.navi_details {
44961
+ const css$S = /* css */`.navi_details {
44868
44962
  z-index: 1;
44869
44963
  flex-direction: column;
44870
44964
  flex-shrink: 0;
@@ -44909,7 +45003,7 @@ const Details = props => {
44909
45003
  return details;
44910
45004
  };
44911
45005
  const DetailsField = props => {
44912
- import.meta.css = [css$R, "@jsenv/navi/src/control/details/details.jsx"];
45006
+ import.meta.css = [css$S, "@jsenv/navi/src/control/details/details.jsx"];
44913
45007
  const {
44914
45008
  ref,
44915
45009
  persists,
@@ -45825,6 +45919,7 @@ const useOpenPropsEffectOnOpenController = (
45825
45919
  name = "popup",
45826
45920
  ) => {
45827
45921
  const { signal, value, defaultOpen, navState } = props;
45922
+ warnSignalAsState(props, name, ["open", "defaultOpen"]);
45828
45923
  const { id: navStateId, type: navStateType } = resolveNavStateProp(
45829
45924
  navState,
45830
45925
  props.id,
@@ -46709,7 +46804,7 @@ const usePopupContentMount = (
46709
46804
  };
46710
46805
 
46711
46806
  installImportMetaCssBuild(import.meta);
46712
- const css$Q = /* css */`.navi_expandable {
46807
+ const css$R = /* css */`.navi_expandable {
46713
46808
  flex-direction: column;
46714
46809
  flex-shrink: 0;
46715
46810
  display: flex;
@@ -46939,7 +47034,7 @@ const useExpandableContext = partName => {
46939
47034
  * builds the content ahead of the click.
46940
47035
  */
46941
47036
  const Expandable = props => {
46942
- import.meta.css = [css$Q, "@jsenv/navi/src/control/expandable/expandable.jsx"];
47037
+ import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
46943
47038
  /* The open props are read from `props` by the open controller below; they
46944
47039
  are named here only to keep them out of `rest`, and so out of the DOM. */
46945
47040
  /* eslint-disable no-unused-vars */
@@ -47626,49 +47721,6 @@ const shallowDiffers = (a, b) => {
47626
47721
  return false;
47627
47722
  };
47628
47723
 
47629
- const ButtonRouteResolver = props => {
47630
- const Next = useNextResolver();
47631
- if (props.route) {
47632
- return jsx(ButtonWithRoute, {
47633
- ...props
47634
- });
47635
- }
47636
- return jsx(Next, {
47637
- ...props
47638
- });
47639
- };
47640
- const ButtonWithRoute = props => {
47641
- const Next = useNextResolver();
47642
- const {
47643
- route,
47644
- routeParams,
47645
- children,
47646
- pseudoState,
47647
- ...rest
47648
- } = props;
47649
- {
47650
- assertRoute(route);
47651
- }
47652
- const url = route.buildUrl(routeParams);
47653
- const {
47654
- matching
47655
- } = useRouteStatus(route);
47656
- const paramsAreMatching = route.matchesParams(routeParams);
47657
- const linkMatching = matching && paramsAreMatching;
47658
-
47659
- // Merged into whatever the caller already holds: a button can be forced into
47660
- // a state for a demo and still learn its own current-ness from its route.
47661
- return jsx(Next, {
47662
- href: url,
47663
- pseudoState: {
47664
- ...pseudoState,
47665
- ":-navi-href-current": linkMatching
47666
- },
47667
- ...rest,
47668
- children: children || route.buildRelativeUrl(routeParams)
47669
- });
47670
- };
47671
-
47672
47724
  const LIGHT_ACCENT_ATTRIBUTE = "data-accent-light";
47673
47725
  const VERY_LIGHT_ACCENT_ATTRIBUTE = "data-accent-very-light";
47674
47726
  const DARK_CONTRAST_ATTRIBUTE = "data-accent-needs-dark-fg";
@@ -47769,7 +47821,7 @@ const useAccentColorAttributes = (
47769
47821
  };
47770
47822
 
47771
47823
  installImportMetaCssBuild(import.meta);
47772
- const css$P = /* css */`@layer navi {
47824
+ const css$Q = /* css */`@layer navi {
47773
47825
  .navi_button {
47774
47826
  --button-border-radius: var(--navi-control-border-radius);
47775
47827
  --button-border-width: var(--navi-control-border-width);
@@ -48070,7 +48122,7 @@ a.navi_button {
48070
48122
  }
48071
48123
  `;
48072
48124
  const ButtonUI = props => {
48073
- import.meta.css = [css$P, "@jsenv/navi/src/control/input/button_ui.jsx"];
48125
+ import.meta.css = [css$Q, "@jsenv/navi/src/control/input/button_ui.jsx"];
48074
48126
  const {
48075
48127
  ref,
48076
48128
  // href/link
@@ -48285,6 +48337,189 @@ const ButtonShadow = () => {
48285
48337
  };
48286
48338
  markAsOutsideTextFlow(ButtonShadow);
48287
48339
 
48340
+ installImportMetaCssBuild(import.meta);
48341
+ const css$P = /* css */`.navi_button_confirm {
48342
+ align-items: center;
48343
+ gap: var(--navi-s);
48344
+ flex-wrap: wrap;
48345
+ display: inline-flex;
48346
+ }
48347
+ `;
48348
+ const ButtonConfirmResolver = props => {
48349
+ const Next = useNextResolver();
48350
+ if (!props.confirm) {
48351
+ return jsx(Next, {
48352
+ ...props
48353
+ });
48354
+ }
48355
+ return jsx(ButtonConfirm, {
48356
+ ...props,
48357
+ Next: Next
48358
+ });
48359
+ };
48360
+ const ButtonConfirm = ({
48361
+ Next,
48362
+ confirm,
48363
+ confirmLabel = naviI18n("button.confirm"),
48364
+ cancelLabel = naviI18n("button.cancel"),
48365
+ confirmTestId,
48366
+ cancelTestId,
48367
+ ...props
48368
+ }) => {
48369
+ import.meta.css = [css$P, "@jsenv/navi/src/control/input/button_confirm.jsx"];
48370
+ const [asking, setAsking] = useState(false);
48371
+ const askingRef = useRef(null);
48372
+ const cancelRef = useRef(null);
48373
+ // Whether going back to the resting label owes the focus a place to land: a
48374
+ // press on "Annuler" or an Escape does, a focus that already left does not —
48375
+ // it is elsewhere by then, and pulling it back would take it from whatever
48376
+ // the person moved to.
48377
+ const restoreFocusRef = useRef(false);
48378
+ const questionId = `${useId()}_question`;
48379
+ const cancel = restoreFocus => {
48380
+ restoreFocusRef.current = restoreFocus;
48381
+ setAsking(false);
48382
+ };
48383
+ useLayoutEffect(() => {
48384
+ if (asking) {
48385
+ // The press was aimed at this button, and the button that answers now is
48386
+ // where it should land: navi's own ladder inside the question, which
48387
+ // finds "Confirmer" (first in the row) unless the caller marked
48388
+ // something else.
48389
+ const found = findFocusTarget(askingRef.current, {
48390
+ restoreMayClaim: true
48391
+ });
48392
+ if (found) {
48393
+ moveFocusTo(found.target);
48394
+ }
48395
+ return;
48396
+ }
48397
+ if (restoreFocusRef.current) {
48398
+ restoreFocusRef.current = false;
48399
+ // The answer may have taken the button with it — a row deleting itself
48400
+ // is the whole point of asking — and there is then nothing to give the
48401
+ // focus back to.
48402
+ const buttonEl = props.ref.current;
48403
+ if (buttonEl) {
48404
+ moveFocusTo(buttonEl);
48405
+ }
48406
+ }
48407
+ }, [asking]);
48408
+ if (!asking) {
48409
+ return jsx(Next, {
48410
+ ...props,
48411
+ // The first press is the question, not the act — so nothing that acts
48412
+ // is handed to the resolvers below. `type` with them: a submit button
48413
+ // would be given `--navi-send` back.
48414
+ type: "button",
48415
+ action: undefined,
48416
+ command: undefined,
48417
+ href: undefined,
48418
+ route: undefined,
48419
+ onClick: () => {
48420
+ setAsking(true);
48421
+ }
48422
+ });
48423
+ }
48424
+ const {
48425
+ action,
48426
+ onActionEnd,
48427
+ onClick
48428
+ } = props;
48429
+ return jsxs("span", {
48430
+ className: "navi_button_confirm",
48431
+ ref: askingRef,
48432
+ onKeyDown: e => {
48433
+ if (e.key === "Escape") {
48434
+ cancel(true);
48435
+ }
48436
+ },
48437
+ onFocusOut: e => {
48438
+ if (!askingRef.current.contains(e.relatedTarget)) {
48439
+ cancel(false);
48440
+ }
48441
+ },
48442
+ children: [jsx("span", {
48443
+ id: questionId,
48444
+ children: confirm === true ? naviI18n("confirm.message") : confirm
48445
+ }), jsx(Next, {
48446
+ ...props,
48447
+ "aria-describedby": questionId
48448
+ // The button confirming is the button that was pressed: it keeps the
48449
+ // caller's own testid unless the question names one of its own.
48450
+ ,
48451
+ "data-testid": confirmTestId || props["data-testid"],
48452
+ onClick: e => {
48453
+ onClick?.(e);
48454
+ if (!action) {
48455
+ // A command runs on the press itself: there is nothing to wait for,
48456
+ // the question is answered as soon as it is pressed.
48457
+ cancel(true);
48458
+ }
48459
+ }
48460
+ // A run that failed keeps the question up, with its callout on the
48461
+ // button that raised it: the retry is then one press away.
48462
+ ,
48463
+ onActionEnd: (data, e) => {
48464
+ onActionEnd?.(data, e);
48465
+ cancel(true);
48466
+ },
48467
+ children: confirmLabel
48468
+ }), jsx(ButtonUI, {
48469
+ ref: cancelRef,
48470
+ type: "button",
48471
+ "data-testid": cancelTestId,
48472
+ onClick: () => {
48473
+ cancel(true);
48474
+ },
48475
+ children: cancelLabel
48476
+ })]
48477
+ });
48478
+ };
48479
+
48480
+ const ButtonRouteResolver = props => {
48481
+ const Next = useNextResolver();
48482
+ if (props.route) {
48483
+ return jsx(ButtonWithRoute, {
48484
+ ...props
48485
+ });
48486
+ }
48487
+ return jsx(Next, {
48488
+ ...props
48489
+ });
48490
+ };
48491
+ const ButtonWithRoute = props => {
48492
+ const Next = useNextResolver();
48493
+ const {
48494
+ route,
48495
+ routeParams,
48496
+ children,
48497
+ pseudoState,
48498
+ ...rest
48499
+ } = props;
48500
+ {
48501
+ assertRoute(route);
48502
+ }
48503
+ const url = route.buildUrl(routeParams);
48504
+ const {
48505
+ matching
48506
+ } = useRouteStatus(route);
48507
+ const paramsAreMatching = route.matchesParams(routeParams);
48508
+ const linkMatching = matching && paramsAreMatching;
48509
+
48510
+ // Merged into whatever the caller already holds: a button can be forced into
48511
+ // a state for a demo and still learn its own current-ness from its route.
48512
+ return jsx(Next, {
48513
+ href: url,
48514
+ pseudoState: {
48515
+ ...pseudoState,
48516
+ ":-navi-href-current": linkMatching
48517
+ },
48518
+ ...rest,
48519
+ children: children || route.buildRelativeUrl(routeParams)
48520
+ });
48521
+ };
48522
+
48288
48523
  const ButtonFirstResolver = props => {
48289
48524
  const Next = useNextResolver();
48290
48525
  const defaultRef = useRef(null);
@@ -48446,6 +48681,20 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48446
48681
  * more: the work may already have happened on the other side (see
48447
48682
  * docs/actions.md#aborting-saves-resources-it-does-not-undo), so say it only
48448
48683
  * where the screen can be re-opened on what is actually there.
48684
+ * @param {boolean|import("ignore:preact").ComponentChildren} [confirm] Ask before
48685
+ * doing it, in the place the button stands: the first press replaces the
48686
+ * button with this question and a "Confirmer"/"Annuler" pair, the second one
48687
+ * does what the button was for. `true` asks navi's default question. The
48688
+ * press stays under the finger and there is nothing to dismiss — "Annuler",
48689
+ * Escape, or the focus leaving puts the button back. Use `<Picker
48690
+ * type="confirm">` instead when the question is long enough to want a popup,
48691
+ * or when the row has no space for it.
48692
+ * @param {import("ignore:preact").ComponentChildren} [confirmLabel] Label of the
48693
+ * second press.
48694
+ * @param {import("ignore:preact").ComponentChildren} [cancelLabel] Label of the way
48695
+ * out of the question.
48696
+ * @param {string} [confirmTestId] `data-testid` of the second press.
48697
+ * @param {string} [cancelTestId] `data-testid` of the way out.
48449
48698
  * @param {string} [contentDisplay] The display of the frame the button draws
48450
48699
  * around its children. It follows the button's own by default — its display
48451
48700
  * and, a display alone saying nothing about direction, the rest of its flow
@@ -48460,7 +48709,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48460
48709
  * (it wears it itself) and loses the shrink under the finger, which has
48461
48710
  * nothing left to scale but the interactive area itself.
48462
48711
  */
48463
- const Button = createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonUI]);
48712
+ const Button = createComponentResolver([ButtonFirstResolver, ButtonConfirmResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonUI]);
48464
48713
 
48465
48714
  installImportMetaCssBuild(import.meta);
48466
48715
  const css$O = /* css */`.navi_control_swap {
@@ -56505,13 +56754,7 @@ const TimeDate = ({
56505
56754
  })
56506
56755
  });
56507
56756
  }
56508
- const date = toDate(children, value => {
56509
- if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
56510
- const d = new Date(`${value}T00:00:00`);
56511
- return isNaN(d.getTime()) ? null : d;
56512
- }
56513
- return null;
56514
- });
56757
+ const date = toDate(children);
56515
56758
  if (!date) {
56516
56759
  return jsx(TimeText, {
56517
56760
  ...props,
@@ -73491,6 +73734,26 @@ const css$r = /* css */`.navi_checkbox_group {
73491
73734
  `;
73492
73735
 
73493
73736
  /**
73737
+ * A set of checkboxes answering one question, holding the array of the values
73738
+ * that are checked.
73739
+ *
73740
+ * It renders a `<fieldset>` and hands its `name` down, so the boxes inside are
73741
+ * plain `<Input type="checkbox" value="…">` with nothing else to declare; each
73742
+ * value that is checked appears in the array, in the order the boxes are
73743
+ * written. What it holds is said the way every control says it — `value` /
73744
+ * `defaultValue` (an array), or a bound `signal` — and inside a `<Form>` the
73745
+ * group is one entry of the submitted params, an array.
73746
+ *
73747
+ * ```jsx
73748
+ * <CheckboxGroup name="permissions" defaultValue={["places.moderate"]} action={save}>
73749
+ * <legend>Permissions</legend>
73750
+ * <Field as="label"><Input type="checkbox" value="places.moderate" /> Modérer les lieux</Field>
73751
+ * <Field as="label"><Input type="checkbox" value="games.list" /> Lister les parties</Field>
73752
+ * </CheckboxGroup>
73753
+ * ```
73754
+ *
73755
+ * See `<RadioGroup>` for the same shape holding a single value.
73756
+ *
73494
73757
  * @type {import("ignore:preact").FunctionComponent<{
73495
73758
  * maxLength?: number,
73496
73759
  * maxLengthGuard?: number,
@@ -74212,6 +74475,27 @@ const css$p = /* css */`.navi_radio_group {
74212
74475
  }
74213
74476
  }
74214
74477
  `;
74478
+
74479
+ /**
74480
+ * A set of radios answering one question, holding the value of the one that is
74481
+ * checked.
74482
+ *
74483
+ * It renders a `<fieldset>` and hands its `name` down, so the radios inside are
74484
+ * plain `<Input type="radio" value="…">` with nothing else to declare — sharing
74485
+ * a name is what makes them exclusive. What it holds is said the way every
74486
+ * control says it — `value` / `defaultValue`, or a bound `signal` — and inside a
74487
+ * `<Form>` the group is one entry of the submitted params.
74488
+ *
74489
+ * ```jsx
74490
+ * <RadioGroup name="role" defaultValue="viewer" action={save}>
74491
+ * <legend>Rôle</legend>
74492
+ * <Field as="label"><Input type="radio" value="viewer" /> Lecteur</Field>
74493
+ * <Field as="label"><Input type="radio" value="admin" /> Administrateur</Field>
74494
+ * </RadioGroup>
74495
+ * ```
74496
+ *
74497
+ * See `<CheckboxGroup>` for the same shape holding an array.
74498
+ */
74215
74499
  const RadioGroup = props => {
74216
74500
  const refDefault = useRef(null);
74217
74501
  props.ref = props.ref || refDefault;