@jsenv/navi 0.29.482 → 0.29.483

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.
@@ -953,6 +953,14 @@ const buttonI18n = /*#__PURE__*/ defineNaviMessages({
953
953
  en: "Remove",
954
954
  fr: "Retirer",
955
955
  },
956
+ "button.copy": {
957
+ en: "Copy",
958
+ fr: "Copier",
959
+ },
960
+ "button.copied": {
961
+ en: "Copied!",
962
+ fr: "Copié !",
963
+ },
956
964
  });
957
965
 
958
966
  // Spin messages — the ends of what one steps through when the control holding
@@ -3610,7 +3618,6 @@ const mergeActionParams = (currentParams, newParams) => {
3610
3618
  }
3611
3619
  return mergeTwoJsValues(currentParams, newParams);
3612
3620
  };
3613
-
3614
3621
  const actionWeakMap = new WeakMap();
3615
3622
  /**
3616
3623
  * Wraps an async callback into an action: a question, its answer, and the
@@ -6959,6 +6966,32 @@ const notePageChange = () => {
6959
6966
 
6960
6967
  const isRenderingPageChange = () => renderingPageChange;
6961
6968
 
6969
+ // A browser refusing storage (blocked site data, some private windows) throws
6970
+ // on the first touch of window.localStorage, and a full one throws on setItem.
6971
+ // Either way what was to be kept lives in memory for the session, which is all
6972
+ // a persisted value can fall back to — so every access goes through here.
6973
+
6974
+ const readLocalStorage = (key) => {
6975
+ try {
6976
+ const raw = window.localStorage.getItem(key);
6977
+ return raw === null ? undefined : raw;
6978
+ } catch {
6979
+ return undefined;
6980
+ }
6981
+ };
6982
+
6983
+ const writeLocalStorage = (key, value) => {
6984
+ try {
6985
+ if (value === undefined) {
6986
+ window.localStorage.removeItem(key);
6987
+ } else {
6988
+ window.localStorage.setItem(key, value);
6989
+ }
6990
+ } catch {
6991
+ // Full, or refused: the value lives for the session only.
6992
+ }
6993
+ };
6994
+
6962
6995
  // Global signal registry for route template detection
6963
6996
  const globalSignalRegistry = new Map();
6964
6997
 
@@ -6993,7 +7026,6 @@ const generateSignalId = () => {
6993
7026
  const id = signalIdCounter++;
6994
7027
  return id;
6995
7028
  };
6996
-
6997
7029
  /**
6998
7030
  * Creates an advanced signal with dynamic default value, local storage persistence, and validation.
6999
7031
  *
@@ -7189,27 +7221,19 @@ const stateSignal = (defaultValue, options = {}) => {
7189
7221
  fallback: () => getDefaultValue(false),
7190
7222
  });
7191
7223
  const readFromLocalStorage = persists
7192
- ? () => {
7193
- const raw = window.localStorage.getItem(localStorageKey);
7194
- if (raw === null) {
7195
- return undefined;
7196
- }
7197
- return raw;
7198
- }
7224
+ ? () => readLocalStorage(localStorageKey)
7199
7225
  : () => undefined;
7200
7226
  const updateLocalStorage = persists
7201
7227
  ? () => {
7202
- const localStorageValue = validity.representations.localStorage.value;
7203
- if (localStorageValue === undefined) {
7204
- window.localStorage.removeItem(localStorageKey);
7205
- } else {
7206
- window.localStorage.setItem(localStorageKey, localStorageValue);
7207
- }
7228
+ writeLocalStorage(
7229
+ localStorageKey,
7230
+ validity.representations.localStorage.value,
7231
+ );
7208
7232
  }
7209
7233
  : () => {};
7210
7234
  const removeFromLocalStorage = persists
7211
7235
  ? () => {
7212
- window.localStorage.removeItem(localStorageKey);
7236
+ writeLocalStorage(localStorageKey, undefined);
7213
7237
  }
7214
7238
  : () => {};
7215
7239
 
@@ -9960,16 +9984,14 @@ let isUpdatingRoutesFromUrl = false;
9960
9984
  * route(`/games/:gameId=${gamePageIdSignal}`, { dropSearchParams: ["v"] });
9961
9985
  * ```
9962
9986
  */
9963
- const route = (
9964
- pattern,
9965
- {
9987
+ const route = (pattern, options = {}) => {
9988
+ const {
9966
9989
  searchParams,
9967
9990
  params,
9968
9991
  redirectRoute,
9969
9992
  redirectRouteParams,
9970
9993
  dropSearchParams,
9971
- } = {},
9972
- ) => {
9994
+ } = options;
9973
9995
  const routePattern = createRoutePattern(pattern, { searchParams, params });
9974
9996
  const { cleanPattern } = routePattern;
9975
9997
  const [publishStatus, subscribeStatus] = createPubSub();
@@ -11642,7 +11664,6 @@ const activeRouteReadsSignal = computed(() => {
11642
11664
  activeRouteReadsPrevious = reads;
11643
11665
  return reads;
11644
11666
  });
11645
-
11646
11667
  /**
11647
11668
  * Binds an action to a route: it runs when the route matches, with the params
11648
11669
  * the effect reads off the address, runs again on a `reload()`, and is aborted
@@ -13481,6 +13502,12 @@ const stopLoad = (reason = "stopLoad() called") => {
13481
13502
  * actions of the routes matching the url run again (see routeAction), one that
13482
13503
  * failed included — what a "Retry" under a page that could not load calls.
13483
13504
  * The address, the history and the scroll stay as they are.
13505
+ *
13506
+ * Route actions only: a component's `useAsyncData(fn)`, or an action no route
13507
+ * reads, is not asked again. Nor does it bring back code whose `import()`
13508
+ * failed — the document refuses that address again without a request — so
13509
+ * the way out of a failed import is a fresh document,
13510
+ * `window.location.reload()` (docs/dynamic_import.md).
13484
13511
  */
13485
13512
  const reload = browserIntegration.reload;
13486
13513
  /**
@@ -24338,16 +24365,11 @@ const arraySignalMembership = (...args) => {
24338
24365
  };
24339
24366
 
24340
24367
  const localStorageSignal = (key) => {
24341
- const initialValue = localStorage.getItem(key);
24342
-
24343
- const valueSignal = signal(initialValue === null ? undefined : initialValue);
24368
+ const valueSignal = signal(readLocalStorage(key));
24344
24369
  effect(() => {
24345
24370
  const value = valueSignal.value;
24346
- if (value === undefined) {
24347
- localStorage.removeItem(key);
24348
- } else {
24349
- localStorage.setItem(key, value);
24350
- }
24371
+ // Storage keeps strings: a null written as is would come back as "null".
24372
+ writeLocalStorage(key, value === null ? undefined : value);
24351
24373
  });
24352
24374
 
24353
24375
  return valueSignal;
@@ -25193,12 +25215,51 @@ const resolveRerunOn = (rerunOn, inheritedRerunOn = defaultRerunOn) => {
25193
25215
  if (!rerunOn) {
25194
25216
  return inheritedRerunOn;
25195
25217
  }
25218
+ assertRerunOnCanFire(rerunOn);
25196
25219
  return {
25197
25220
  GET: rerunOn.GET ?? inheritedRerunOn.GET,
25198
25221
  GET_MANY: rerunOn.GET_MANY ?? inheritedRerunOn.GET_MANY,
25199
25222
  GET_RANGE: rerunOn.GET_RANGE ?? inheritedRerunOn.GET_RANGE,
25200
25223
  };
25201
25224
  };
25225
+ // What can never fire throws, as a dependency name does (see
25226
+ // resolveDependency): a rerun that never happens leaves a read on its old
25227
+ // value with nothing to say so.
25228
+ const RERUN_READS = ["GET", "GET_MANY", "GET_RANGE"];
25229
+ const RERUN_VERBS = ["POST", "PUT", "PATCH", "DELETE", "*"];
25230
+ const assertRerunOnCanFire = (rerunOn) => {
25231
+ for (const read of Object.keys(rerunOn)) {
25232
+ if (!RERUN_READS.includes(read)) {
25233
+ throw new Error(
25234
+ `rerunOn: "${read}" is not a read that reruns — rerunOn takes GET, GET_MANY and GET_RANGE`,
25235
+ );
25236
+ }
25237
+ const verbs = rerunOn[read];
25238
+ if (
25239
+ verbs === undefined ||
25240
+ verbs === null ||
25241
+ verbs === false ||
25242
+ verbs === "*"
25243
+ ) {
25244
+ continue;
25245
+ }
25246
+ if (!Array.isArray(verbs)) {
25247
+ throw new Error(
25248
+ `rerunOn.${read}: ${JSON.stringify(verbs)} never fires — it takes false, "*" or a list of verbs`,
25249
+ );
25250
+ }
25251
+ for (const verb of verbs) {
25252
+ if (
25253
+ typeof verb !== "string" ||
25254
+ !RERUN_VERBS.includes(verb.toUpperCase())
25255
+ ) {
25256
+ throw new Error(
25257
+ `rerunOn.${read}: ${JSON.stringify(verb)} never fires — rerunOn names verbs (POST, PUT, PATCH, DELETE or "*"): POST covers POST_MANY`,
25258
+ );
25259
+ }
25260
+ }
25261
+ }
25262
+ };
25202
25263
 
25203
25264
  // What a `dependencies` entry names are callbacks, not verbs (`rerunOn` names
25204
25265
  // verbs, where `PATCH` covers `PATCH_MANY`): one callback of a resource can be
@@ -26226,7 +26287,6 @@ const debug$2 = (...args) => {
26226
26287
  return;
26227
26288
  }
26228
26289
  };
26229
-
26230
26290
  /**
26231
26291
  * Creates a reactive REST resource backed by a shared signal store.
26232
26292
  * Returns a `stateFacade` exposing one action per REST callback provided
@@ -26285,9 +26345,8 @@ const debug$2 = (...args) => {
26285
26345
  * DELETE: ({ id }) => fetchJson(`/users/${id}`, { method: "DELETE" }),
26286
26346
  * });
26287
26347
  */
26288
- const resource = (
26289
- name,
26290
- {
26348
+ const resource = (name, restCallbacks = {}) => {
26349
+ const {
26291
26350
  // configuration options
26292
26351
  idKey = "id",
26293
26352
  uniqueKeys = [],
@@ -26306,8 +26365,7 @@ const resource = (
26306
26365
  PATCH_MANY,
26307
26366
  DELETE,
26308
26367
  DELETE_MANY,
26309
- } = {},
26310
- ) => {
26368
+ } = restCallbacks;
26311
26369
  const declarationSite = getDeclarationSite();
26312
26370
  const setupCallbackSet = new Set();
26313
26371
  const addItemSetup = (callback) => {
@@ -28199,8 +28257,8 @@ const valueInLocalStorage = (key, { type = "any" } = {}) => {
28199
28257
  const converter = TYPE_CONVERTERS[type];
28200
28258
 
28201
28259
  const get = () => {
28202
- let valueInLocalStorage = window.localStorage.getItem(key);
28203
- if (valueInLocalStorage === null) {
28260
+ const valueInLocalStorage = readLocalStorage(key);
28261
+ if (valueInLocalStorage === undefined) {
28204
28262
  return undefined;
28205
28263
  }
28206
28264
  let valueToReturn = valueInLocalStorage;
@@ -28224,7 +28282,7 @@ const valueInLocalStorage = (key, { type = "any" } = {}) => {
28224
28282
 
28225
28283
  const set = (value) => {
28226
28284
  if (value === undefined) {
28227
- window.localStorage.removeItem(key);
28285
+ writeLocalStorage(key, undefined);
28228
28286
  return;
28229
28287
  }
28230
28288
  let valueToStore = value;
@@ -28232,10 +28290,10 @@ const valueInLocalStorage = (key, { type = "any" } = {}) => {
28232
28290
  const valueEncoded = converter.encode(valueToStore);
28233
28291
  valueToStore = valueEncoded;
28234
28292
  }
28235
- window.localStorage.setItem(key, valueToStore);
28293
+ writeLocalStorage(key, valueToStore);
28236
28294
  };
28237
28295
  const remove = () => {
28238
- window.localStorage.removeItem(key);
28296
+ writeLocalStorage(key, undefined);
28239
28297
  };
28240
28298
 
28241
28299
  return [get, set, remove];
@@ -28455,7 +28513,8 @@ const useForceRender = () => {
28455
28513
  *
28456
28514
  * @example <caption>Handle error inline</caption>
28457
28515
  * const [user, , error] = useAsyncData(userAction, { error: true });
28458
- * if (error) return <ErrorCard message={error.message} />;
28516
+ * // A failed refresh keeps `user`: only a first load has nothing else to show.
28517
+ * if (error && user === undefined) return <ErrorCard message={error.message} />;
28459
28518
  *
28460
28519
  * @example <caption>Say the failure over the rows already on screen</caption>
28461
28520
  * const [items, , error, dismissError] = useAsyncData(listAction, { error: true });
@@ -40264,9 +40323,12 @@ const confirmCommand = (source, event) => {
40264
40323
  event,
40265
40324
  source: commandSource,
40266
40325
  });
40326
+ // Carried by the close too: a Dialog or a Popover answers the yes in
40327
+ // its onClose (detail.isConfirm), the twin of isCancel.
40267
40328
  return dispatchCustomEvent(target, "navi_request_close", {
40268
40329
  event,
40269
40330
  source: commandSource,
40331
+ isConfirm: true,
40270
40332
  });
40271
40333
  },
40272
40334
  };
@@ -47488,26 +47550,33 @@ const useControlUIState = (ref, uiStateInitial) => {
47488
47550
  if (!element) {
47489
47551
  return undefined;
47490
47552
  }
47491
- // The host, not the box around it: a control is a whole little tree (a
47492
- // picker is a box holding an input) and the state is announced on the one
47493
- // element that holds it. Same resolution every request goes through, see
47494
- // ui_state_dom.js.
47495
- const host = findControlHost(element) || element;
47496
- // Asked once here rather than trusted from the props above: between the
47497
- // first render and this effect the control has read its own value prop, its
47498
- // defaultValue and any signal it was bound to, and settled which of them
47499
- // wins — the answer to that is on the element, not in what we were handed.
47500
- setUIState(getUIStateFromElement(host));
47501
- const onUIStateChange = e => {
47502
- setUIState(e.detail.value);
47503
- };
47504
- host.addEventListener("navi_ui_state_change", onUIStateChange);
47505
- return () => {
47506
- host.removeEventListener("navi_ui_state_change", onUIStateChange);
47507
- };
47553
+ return subscribeToControlUIState(element, setUIState);
47508
47554
  }, [ref]);
47509
47555
  return uiState;
47510
47556
  };
47557
+ /**
47558
+ * Calls `callback` with what the control `element` holds, right away and on
47559
+ * every change. Returns the teardown.
47560
+ */
47561
+ const subscribeToControlUIState = (element, callback) => {
47562
+ // The host, not the box around it: a control is a whole little tree (a
47563
+ // picker is a box holding an input) and the state is announced on the one
47564
+ // element that holds it. Same resolution every request goes through, see
47565
+ // ui_state_dom.js.
47566
+ const host = findControlHost(element) || element;
47567
+ // Asked once here rather than trusted from props: by the time this runs the
47568
+ // control has read its own value prop, its defaultValue and any signal it
47569
+ // was bound to, and settled which of them wins — the answer to that is on
47570
+ // the element.
47571
+ callback(getUIStateFromElement(host));
47572
+ const onUIStateChange = e => {
47573
+ callback(e.detail.value);
47574
+ };
47575
+ host.addEventListener("navi_ui_state_change", onUIStateChange);
47576
+ return () => {
47577
+ host.removeEventListener("navi_ui_state_change", onUIStateChange);
47578
+ };
47579
+ };
47511
47580
 
47512
47581
  /**
47513
47582
  * Like `useControlProps` but also establishes a 1:1 facade sync between the
@@ -52151,8 +52220,8 @@ const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
52151
52220
  * those instead.
52152
52221
  * @param {string} [props.value] - Value emitted by the control facade
52153
52222
  * (`navi_value`); defaults to `href`.
52154
- * @param {boolean} [props.current] - Forces the "current" state on (otherwise
52155
- * derived from the href/route).
52223
+ * @param {boolean} [props.current] - Forces the "current" state: `true` on,
52224
+ * `false` off. Left out, it is derived from the href/route.
52156
52225
  * @param {object|object[]} [props.currentExcept] -
52157
52226
  * Route(s) inside this link's own that are NOT it: while one of them matches,
52158
52227
  * the link is not current even though its route still is. A bar entry
@@ -52294,7 +52363,10 @@ const LinkWithRoute = ({
52294
52363
  except: currentExcept,
52295
52364
  also: currentAlso
52296
52365
  });
52297
- const innerCurrent = current || linkMatching;
52366
+ // Always a boolean from here: LinkPlain takes a given `current` as the
52367
+ // answer, so an href matching the address cannot relight what
52368
+ // currentExcept turned off.
52369
+ const innerCurrent = current ?? linkMatching;
52298
52370
 
52299
52371
  // What a childless link shows is LinkPlain's call (hrefFallback); a route
52300
52372
  // only says it the way its pattern writes it, not as the resolved href.
@@ -52378,7 +52450,7 @@ const LinkPlain = props => {
52378
52450
  } = getHrefTargetInfo(href, documentUrlSignal.peek());
52379
52451
  // A tab that is a SLIDE is current when the container is on it — which the
52380
52452
  // <Nav> around reads off that container, so nothing here has to be told.
52381
- const innerCurrent = current || (slide ? nav?.currentSlideArea === slide : isCurrent);
52453
+ const innerCurrent = current ?? (slide ? nav?.currentSlideArea === slide : isCurrent);
52382
52454
  useReportCurrentToBinderItem(innerCurrent);
52383
52455
  controlHostProps.basePseudoState = {
52384
52456
  ...basePseudoState,
@@ -57109,6 +57181,8 @@ const ButtonUI = props => {
57109
57181
  // Where the href leads next to the page one is on (the target, the rel, the
57110
57182
  // current state) is resolved above, by ButtonHrefResolver.
57111
57183
  const as = href === undefined ? "button" : "a";
57184
+ // What `data-href-current` paints, said to assistive tech as a link says it.
57185
+ const ariaCurrent = as === "a" && props.pseudoState?.[":-navi-href-current"] ? "page" : undefined;
57112
57186
 
57113
57187
  // Worn as an attribute, like a link's (see link_replace.js): read off the
57114
57188
  // anchor by the click handler, off the source by --navi-nav-to.
@@ -57145,6 +57219,7 @@ const ButtonUI = props => {
57145
57219
  // caller's props, so their own flex direction wins.
57146
57220
  ,
57147
57221
  flex: icon ? "x" : undefined,
57222
+ "aria-current": ariaCurrent,
57148
57223
  ...buttonControlRootProps,
57149
57224
  ...buttonControlHostProps,
57150
57225
  // eslint-disable-next-line react/no-children-prop
@@ -57497,7 +57572,10 @@ const ButtonHrefResolver = props => {
57497
57572
  // Whether the href is the page one is on depends on the address, and re-renders
57498
57573
  // the button only when it flips (see useHrefTargetFlag). Whether it leaves the
57499
57574
  // site does not depend on the address.
57500
- const ButtonWithHref = props => {
57575
+ const ButtonWithHref = ({
57576
+ current,
57577
+ ...props
57578
+ }) => {
57501
57579
  const Next = useNextResolver();
57502
57580
  const {
57503
57581
  href,
@@ -57518,7 +57596,7 @@ const ButtonWithHref = props => {
57518
57596
  // the state.
57519
57597
  ,
57520
57598
  pseudoState: {
57521
- ":-navi-href-current": isCurrent,
57599
+ ":-navi-href-current": current ?? isCurrent,
57522
57600
  ...pseudoState
57523
57601
  }
57524
57602
  });
@@ -57540,13 +57618,20 @@ const ButtonWithRoute = props => {
57540
57618
  const {
57541
57619
  route,
57542
57620
  routeParams,
57621
+ current,
57622
+ currentExcept,
57623
+ currentAlso,
57543
57624
  children,
57544
57625
  pseudoState,
57545
57626
  href,
57546
57627
  ...rest
57547
57628
  } = props;
57548
57629
  const url = route.buildUrl(routeParams);
57549
- const linkMatching = useRouteCurrent(route, routeParams);
57630
+ // What a route Link reads, with the same amendments (see link.jsx).
57631
+ const linkMatching = useRouteCurrent(route, routeParams, {
57632
+ except: currentExcept,
57633
+ also: currentAlso
57634
+ });
57550
57635
 
57551
57636
  // Merged into whatever the caller already holds: a button can be forced into
57552
57637
  // a state for a demo and still learn its own current-ness from its route.
@@ -57554,7 +57639,7 @@ const ButtonWithRoute = props => {
57554
57639
  href: url,
57555
57640
  pseudoState: {
57556
57641
  ...pseudoState,
57557
- ":-navi-href-current": linkMatching
57642
+ ":-navi-href-current": current ?? linkMatching
57558
57643
  },
57559
57644
  ...rest,
57560
57645
  children: children || route.buildRelativeUrl(routeParams)
@@ -57673,8 +57758,23 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
57673
57758
  * actionStandalone?: boolean,
57674
57759
  * actionAbortable?: boolean,
57675
57760
  * optimistic?: boolean,
57761
+ * route?: object,
57762
+ * routeParams?: object,
57763
+ * current?: boolean,
57764
+ * currentExcept?: object | object[],
57765
+ * currentAlso?: object | object[],
57676
57766
  * [key: string]: any,
57677
57767
  * }>}
57768
+ * @param {object} [route] Where the press leads, as a `<Link route>` says it:
57769
+ * the button is drawn as an `<a>` to the route's url (with `routeParams`),
57770
+ * and says whether it is the page one is on — `[data-href-current]` for the
57771
+ * eye, `aria-current="page"` for assistive tech. A bar entry drawn as a
57772
+ * button for its press feedback.
57773
+ * @param {boolean} [current] Forces that "current" state: `true` on, `false`
57774
+ * off. Left out, it is read from the route (or the `href`).
57775
+ * @param {object|object[]} [currentExcept] Route(s) inside the button's own
57776
+ * that are NOT it, and `currentAlso` route(s) beside it that are: what they
57777
+ * mean on a `<Link>`.
57678
57778
  * @param {boolean} [replace] Go where the press leads — an `href`, a
57679
57779
  * `--navi-nav-to` command — by TAKING THE PLACE of the current history entry
57680
57780
  * rather than stacking on it: what `<Link replace>` says, for a press drawn
@@ -61645,36 +61745,61 @@ const Textarea = ({
61645
61745
  * typed over how many the limit allows, the way Material writes it (just the
61646
61746
  * count when there is no `maxLength`). Where it goes is the caller's call,
61647
61747
  * which is why it is a separate component rather than something the textarea
61648
- * draws: put it under the box, in a form footer, next to a label, and feed it
61649
- * the same value or signal as the textarea.
61748
+ * draws: put it under the box, in a form footer, next to a label, and point
61749
+ * it at the textarea with `for`.
61650
61750
  *
61651
61751
  * @type {import("preact").FunctionComponent<{
61752
+ * for?: string,
61652
61753
  * value?: string,
61653
61754
  * signal?: import("@preact/signals").Signal<string>,
61654
61755
  * maxLength?: number,
61655
61756
  * [key: string]: any,
61656
61757
  * }>}
61657
- * @param {string} [value] The text being counted. Say `signal` instead for a
61658
- * two-way bound textarea: reading it here subscribes the count to it.
61758
+ * @param {string} [for] The `id` of the Textarea counted: the count reads
61759
+ * what it holds, so the field stays a plain one (`defaultValue={record.bio}`)
61760
+ * with no signal bound only for the counter's sake. The limit defaults to
61761
+ * that Textarea's `maxLength` (or `maxLengthGuard`).
61762
+ * @param {string} [value] The text being counted, when it is not read from a
61763
+ * Textarea. Say `signal` instead to subscribe the count to it.
61659
61764
  * @param {number} [maxLength] The limit, shown after the count ("50/200").
61660
61765
  * Without it the count stands alone.
61661
61766
  */
61662
61767
  const TextareaCharCount = ({
61768
+ for: controlId,
61663
61769
  value,
61664
61770
  signal,
61665
61771
  maxLength,
61666
61772
  ...rest
61667
61773
  }) => {
61668
61774
  import.meta.css = [css$J, "@jsenv/navi/src/control/input/textarea.jsx"];
61669
- const resolvedValue = signal ? signal.value : value;
61775
+ const [controlText, controlMaxLength] = useTextOfControl(controlId);
61776
+ const resolvedValue = controlId ? controlText : signal ? signal.value : value;
61670
61777
  const length = typeof resolvedValue === "string" ? resolvedValue.length : 0;
61778
+ const limit = maxLength ?? controlMaxLength;
61671
61779
  return jsx(Box, {
61672
61780
  as: "span",
61673
61781
  baseClassName: "navi_textarea_char_count",
61674
61782
  ...rest,
61675
- children: maxLength === undefined ? length : `${length}/${maxLength}`
61783
+ children: limit === undefined ? length : `${length}/${limit}`
61676
61784
  });
61677
61785
  };
61786
+ const useTextOfControl = controlId => {
61787
+ const [text, setText] = useState();
61788
+ const [maxLength, setMaxLength] = useState();
61789
+ useLayoutEffect(() => {
61790
+ if (!controlId) {
61791
+ return undefined;
61792
+ }
61793
+ const element = document.getElementById(controlId);
61794
+ if (!element) {
61795
+ return undefined;
61796
+ }
61797
+ const controlProps = getUIStateControllerById(controlId)?.props;
61798
+ setMaxLength(controlProps?.maxLength ?? controlProps?.maxLengthGuard);
61799
+ return subscribeToControlUIState(element, setText);
61800
+ }, [controlId]);
61801
+ return [text, maxLength];
61802
+ };
61678
61803
 
61679
61804
  // `field-sizing: content` sizes the box from the value, and an empty field has
61680
61805
  // none — the placeholder is text the browser refuses to make room for. So the
@@ -70426,6 +70551,8 @@ const css$C = /* css */`
70426
70551
  * actually closes — not preventable: it is the close happening, not a
70427
70552
  * request. What refuses a close is navi's: a control inside still
70428
70553
  * running its action (see popup_busy.js for the runs that let it through).
70554
+ * `event.detail.isCancel` says the close meant "revert", and
70555
+ * `event.detail.isConfirm` that a `--navi-confirm` said yes.
70429
70556
  * @param {boolean|string|{id?: string, type?: "push"|"replace"}} [props.navState] -
70430
70557
  * Keeps the open state in the history entry, so a screen left and come back
70431
70558
  * to finds this popup as it was — open, and without an entrance playing: it
@@ -70662,6 +70789,7 @@ const UncontrolledDialog = props => {
70662
70789
  onnavi_request_close: e => {
70663
70790
  const closing = openController.requestClose(e, {
70664
70791
  isCancel: e.detail?.isCancel,
70792
+ isConfirm: e.detail?.isConfirm,
70665
70793
  requester: e.detail?.source
70666
70794
  });
70667
70795
  if (!closing) {
@@ -72423,6 +72551,8 @@ const css$B = /* css */`
72423
72551
  * actually closes — not preventable: it is the close happening, not a
72424
72552
  * request. What refuses a close is navi's: a control inside still
72425
72553
  * running its action (see popup_busy.js for the runs that let it through).
72554
+ * `event.detail.isCancel` says the close meant "revert", and
72555
+ * `event.detail.isConfirm` that a `--navi-confirm` said yes.
72426
72556
  * @param {boolean|string|{id?: string, type?: "push"|"replace"}} [props.navState] -
72427
72557
  * Keeps the open state in the history entry, so a screen left and come back
72428
72558
  * to finds this popup as it was — open, and without an entrance playing: it
@@ -72542,7 +72672,8 @@ const UncontrolledPopover = props => {
72542
72672
  },
72543
72673
  onnavi_request_close: e => {
72544
72674
  const closing = openController.requestClose(e, {
72545
- isCancel: e.detail?.isCancel
72675
+ isCancel: e.detail?.isCancel,
72676
+ isConfirm: e.detail?.isConfirm
72546
72677
  });
72547
72678
  if (!closing) {
72548
72679
  // Said back to whoever asked: --navi-close:all stops climbing here.
@@ -74614,6 +74745,7 @@ const PickerCustom = props => {
74614
74745
  allowed: () => {
74615
74746
  const closing = requestClose(e, {
74616
74747
  isCancel: e.detail.isCancel,
74748
+ isConfirm: e.detail.isConfirm,
74617
74749
  isSend: e.detail.isSend
74618
74750
  });
74619
74751
  if (!closing) {
@@ -77667,6 +77799,11 @@ const css$v = /* css */`@property --list-group-label-height {
77667
77799
  --list-item-background-color-highlight: #ffe066;
77668
77800
  display: inline-block;
77669
77801
  }
77802
+
77803
+ .navi_list_item_group[data-label-in-flow] > .navi_list_item_group_label {
77804
+ background-color: #0000;
77805
+ display: block;
77806
+ }
77670
77807
  }
77671
77808
 
77672
77809
  .navi_list_item_group_label {
@@ -78014,16 +78151,7 @@ const css$v = /* css */`@property --list-group-label-height {
78014
78151
  min-width: 100%;
78015
78152
 
78016
78153
  & .navi_list_item_group_label {
78017
- top: var(--list-group-label-top, var(--x-list-group-label-top, 0px));
78018
- z-index: var(--list-group-label-z-index);
78019
- background-color: var(--list-group-label-background-color);
78020
78154
  user-select: none;
78021
- display: block;
78022
- position: sticky;
78023
-
78024
- &[navi-stuck] {
78025
- z-index: var(--list-group-label-z-index-stuck);
78026
- }
78027
78155
 
78028
78156
  &[navi-default] {
78029
78157
  color: light-dark(#888, #aaa);
@@ -78039,6 +78167,18 @@ const css$v = /* css */`@property --list-group-label-height {
78039
78167
  }
78040
78168
  }
78041
78169
 
78170
+ &:not([data-label-in-flow]) > .navi_list_item_group_label {
78171
+ top: var(--list-group-label-top, var(--x-list-group-label-top, 0px));
78172
+ z-index: var(--list-group-label-z-index);
78173
+ background-color: var(--list-group-label-background-color);
78174
+ display: block;
78175
+ position: sticky;
78176
+
78177
+ &[navi-stuck] {
78178
+ z-index: var(--list-group-label-z-index-stuck);
78179
+ }
78180
+ }
78181
+
78042
78182
  & .navi_list_item_group_list {
78043
78183
  --list-group-label-height: inherit;
78044
78184
  flex-direction: column;
@@ -78057,6 +78197,20 @@ const css$v = /* css */`@property --list-group-label-height {
78057
78197
  }
78058
78198
  }
78059
78199
 
78200
+ &[data-label-in-flow] {
78201
+ & .navi_list_item_group_list .navi_list_item {
78202
+ scroll-margin-top: var(--x-list-scroll-spacing-top);
78203
+ }
78204
+
78205
+ & .navi_list_item_group_list > .navi_list_item:first-child {
78206
+ scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height));
78207
+ }
78208
+
78209
+ &[data-continued] > .navi_list_item_group_label {
78210
+ visibility: hidden;
78211
+ }
78212
+ }
78213
+
78060
78214
  &[data-hidden-while-empty]:not(:has([navi-list-item-real])) {
78061
78215
  display: none;
78062
78216
  }
@@ -78829,6 +78983,15 @@ const useListScrollSync = ({
78829
78983
  // partly in view. After it, the quarter a window keeps behind the screen
78830
78984
  // (see evaluateWindow), until the screen sizes it around that item.
78831
78985
  const itemsAboveOpening = (openAt, windowSize) => {
78986
+ // An item placed in the middle of the view, or at its end, has the items
78987
+ // before it on screen from the first paint.
78988
+ const align = openAt && typeof openAt === "object" ? openAt.align : undefined;
78989
+ if (align === "center") {
78990
+ return Math.floor(windowSize / 2);
78991
+ }
78992
+ if (align === "end") {
78993
+ return windowSize - 1;
78994
+ }
78832
78995
  if (stageRef.current !== "steady") {
78833
78996
  return openAt && typeof openAt === "object" && openAt.offset > 0 ? 1 : 0;
78834
78997
  }
@@ -79369,22 +79532,30 @@ const useListScrollSync = ({
79369
79532
  return;
79370
79533
  }
79371
79534
  if (typeof openAt === "object" && openAt.id !== undefined) {
79372
- placeItem(findItemElement(getListEl(), openAt.id), openAt.offset || 0);
79535
+ placeItem(findItemElement(getListEl(), openAt.id), openAt.offset || 0, openAt.align);
79373
79536
  }
79374
79537
  };
79375
79538
  // An item is placed by measuring it where it is drawn — named, or at the
79376
79539
  // place a number says — so it lands where it should whatever the items
79377
79540
  // above it weigh, and wherever the list starts in the box that scrolls it.
79378
79541
  // The window frames it from the first commit on (see holdWindow).
79379
- const placeItem = (itemEl, offset) => {
79542
+ const placeItem = (itemEl, offset, align = "start") => {
79380
79543
  if (!itemEl) {
79381
79544
  return;
79382
79545
  }
79383
79546
  const scrollerEl = getScroller();
79547
+ // Measured in the scroller's own pixels, the ones a scroll is written in.
79548
+ const scale = readScrollerScale(scrollerEl, horizontal);
79384
79549
  const viewportRect = getScrollerViewportRect(scrollerEl);
79385
79550
  const itemRect = itemEl.getBoundingClientRect();
79386
- const offsetWanted = resolveOpenOffset(getItemScrollInset(scrollerEl, itemEl, horizontal) + offset, horizontal ? viewportRect.width : viewportRect.height, horizontal ? itemRect.width : itemRect.height);
79387
- const offsetNow = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
79551
+ const viewportSize = (horizontal ? viewportRect.width : viewportRect.height) / scale;
79552
+ const itemSize = (horizontal ? itemRect.width : itemRect.height) / scale;
79553
+ const scrollInset = getItemScrollInset(scrollerEl, itemEl, horizontal);
79554
+ // Centred in what the room at the start leaves of the view (a fixed bar,
79555
+ // a sticky header), not in the whole of it.
79556
+ const alignedOffset = align === "center" ? scrollInset + (viewportSize - scrollInset - itemSize) / 2 : align === "end" ? viewportSize - itemSize : scrollInset;
79557
+ const offsetWanted = resolveOpenOffset(alignedOffset + offset, viewportSize, itemSize);
79558
+ const offsetNow = (horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top) / scale;
79388
79559
  const scrollNow = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
79389
79560
  const delta = offsetNow - offsetWanted;
79390
79561
  if (delta > -0.5 && delta < 0.5) {
@@ -80237,6 +80408,25 @@ const useListScrollSync = ({
80237
80408
  // The band of the scroller the user actually sees. A page-level scroller is
80238
80409
  // the viewport itself — its own box is the whole document, which says nothing
80239
80410
  // about what is on screen.
80411
+ // A scroller drawn under a transform (a popup scaling in from its trigger)
80412
+ // shows a rect of another size than the box it scrolls: the ratio of the two
80413
+ // turns what the screen measures into the scroller's own pixels. A rounding
80414
+ // of the box's size is not a scale.
80415
+ const readScrollerScale = (scrollerEl, horizontal) => {
80416
+ if (scrollerEl === document.scrollingElement) {
80417
+ return 1;
80418
+ }
80419
+ const size = horizontal ? scrollerEl.offsetWidth : scrollerEl.offsetHeight;
80420
+ if (!size) {
80421
+ return 1;
80422
+ }
80423
+ const rect = scrollerEl.getBoundingClientRect();
80424
+ const scale = (horizontal ? rect.width : rect.height) / size;
80425
+ if (!scale || scale > 0.99 && scale < 1.01) {
80426
+ return 1;
80427
+ }
80428
+ return scale;
80429
+ };
80240
80430
  const getScrollerViewportRect = scrollerEl => {
80241
80431
  if (scrollerEl === document.scrollingElement) {
80242
80432
  const width = document.documentElement.clientWidth;
@@ -80311,7 +80501,7 @@ const canScrollerScroll = (scrollerEl, axis) => {
80311
80501
  // horizontally on mobile, where `overflow-x: clip` keeps that guarantee without
80312
80502
  // making a scroll container, or to let a wide table scroll sideways, where the
80313
80503
  // header cannot stick to both boxes.
80314
- const STICKY_LIST_PART_SELECTOR = `.navi_list_item_header, .navi_list_item_footer, .navi_list_item_group_label`;
80504
+ const STICKY_LIST_PART_SELECTOR = `.navi_list_item_header, .navi_list_item_footer, .navi_list_item_group:not([data-label-in-flow]) > .navi_list_item_group_label`;
80315
80505
  const useStickyScrollportWarning = (ref, scroller, getScroller, horizontal) => {
80316
80506
  useRef(false);
80317
80507
  useLayoutEffect(() => {
@@ -82341,6 +82531,7 @@ const VISIBILITY_HIDDEN_STYLE = {
82341
82531
  * groupBy?: (item: any, index: number) => any,
82342
82532
  * renderGroupLabel?: (item: any, index: number) => import("preact").ComponentChildren,
82343
82533
  * groupLabelProps?: (item: any, index: number) => object,
82534
+ * groupLabelSticky?: boolean,
82344
82535
  * pageSize?: number,
82345
82536
  * memoryBudget?: number,
82346
82537
  * renderSkeleton?: false | ((index: number) => import("preact").ComponentChildren),
@@ -82405,6 +82596,12 @@ const VISIBILITY_HIDDEN_STYLE = {
82405
82596
  * its group (a day behind us, today, one ahead) rather than just naming it:
82406
82597
  * the state then sits on the element the CSS styles, instead of being read
82407
82598
  * back from a child.
82599
+ * @param {boolean} [props.groupLabelSticky=true]
82600
+ * `false`: the label stays in the flow, a boundary scrolled past with its
82601
+ * group instead of riding the top of the screen — a thread of days whose
82602
+ * cards must not slide under a band, a "past" line in a list the page
82603
+ * scrolls. It paints nothing of its own then: its display and background
82604
+ * are defaults a `groupLabelProps` class takes back.
82408
82605
  * @param {number} [props.pageSize=100]
82409
82606
  * How many items to ask for at a time. A turn of the wheel opens a hole three
82410
82607
  * items wide; asking for exactly that would ask again at the next turn.
@@ -82589,6 +82786,7 @@ const useRunItems = (store, {
82589
82786
  groupBy,
82590
82787
  renderGroupLabel,
82591
82788
  groupLabelProps,
82789
+ groupLabelSticky = true,
82592
82790
  renderSkeleton,
82593
82791
  renderError
82594
82792
  }) => {
@@ -82848,6 +83046,7 @@ const useRunItems = (store, {
82848
83046
  nodes.push(jsx(ListItemGroup, {
82849
83047
  label: group.label,
82850
83048
  labelProps: group.labelProps,
83049
+ labelSticky: groupLabelSticky,
82851
83050
  continued: group.continued,
82852
83051
  children: group.children
82853
83052
  }, groupCount === 1 ? `${ownerId}_group_${group.key}` : `${ownerId}_group_${group.key}_${groupCount}`));
@@ -83843,6 +84042,7 @@ const useItemStore = ({
83843
84042
  const ListItemGroup = ({
83844
84043
  label,
83845
84044
  labelProps,
84045
+ labelSticky = true,
83846
84046
  hiddenWhileEmpty,
83847
84047
  continued,
83848
84048
  children,
@@ -83888,6 +84088,7 @@ const ListItemGroup = ({
83888
84088
  role: "presentation",
83889
84089
  "data-hidden-while-empty": hiddenWhileEmpty ? "" : undefined,
83890
84090
  "data-continued": continued,
84091
+ "data-label-in-flow": labelSticky ? undefined : "",
83891
84092
  children: [jsx("span", {
83892
84093
  ...labelRest,
83893
84094
  ref: labelRef,
@@ -83955,8 +84156,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
83955
84156
  * renderBudget?: number | string | {initial?: number | string, after?: number | string},
83956
84157
  * virtualItemSize?: number,
83957
84158
  * onListVisibleItemsChange?: (visibleItems: any[]) => void,
83958
- * scrolled?: "start" | "end" | number | {id: string, offset?: number},
83959
- * defaultScrolled?: "start" | "end" | number | {id: string, offset?: number},
84159
+ * scrolled?: "start" | "end" | number | {id: string, offset?: number, align?: "start" | "center" | "end"},
84160
+ * defaultScrolled?: "start" | "end" | number | {id: string, offset?: number, align?: "start" | "center" | "end"},
83960
84161
  * onScrolledChange?: (scrolled: {id: string, index: number, offset: number}) => void,
83961
84162
  * scroller?: "self" | "parent" | "document" | Element | {current: Element},
83962
84163
  * hoverWhileScrolling?: boolean,
@@ -84060,7 +84261,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
84060
84261
  * are skeletons, the others hold their room. `0` says the list is already
84061
84262
  * known to be empty: the empty `fallback` shows right away rather than an
84062
84263
  * empty frame.
84063
- * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.defaultScrolled="start"]
84264
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, align?: "start"|"center"|"end", visibleCount?: number}} [props.defaultScrolled="start"]
84064
84265
  * Where the list opens, after which the user owns the scroll — unless it is
84065
84266
  * being come back to (see `scrollResetOnNavigation`). `"end"` is a
84066
84267
  * thread read backwards — the last items are the ones to show, and the ones
@@ -84075,10 +84276,13 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
84075
84276
  * room has to be restated as a number by whoever asks. The `index` and
84076
84277
  * `visibleCount` it also hands out say where to aim before the item is found,
84077
84278
  * and how many items to draw before the first paint (see `renderBudget`).
84279
+ * `align: "center"` puts the item in the middle of the view instead (`"end"`:
84280
+ * at its far edge) — the chosen level of a horizontal scale opening on it —
84281
+ * and keeps it there while the box around it grows, until the user scrolls.
84078
84282
  * A value that changes before anyone has moved the list is followed (a place
84079
84283
  * read from a count that answered again): it is compared by value, so it can
84080
84284
  * be computed at every render.
84081
- * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.scrolled]
84285
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, align?: "start"|"center"|"end", visibleCount?: number}} [props.scrolled]
84082
84286
  * The same, but held: the list goes back there every time this changes, even
84083
84287
  * after the user has scrolled — the caller owns where the list is (see
84084
84288
  * `defaultScrolled` for the uncontrolled form, and `open`/`defaultOpen`
@@ -85580,6 +85784,7 @@ const css$r = /* css */`@layer navi {
85580
85784
  --picker-font-size: var(--navi-control-font-size);
85581
85785
  --picker-font-family: var(--navi-control-font-family, inherit);
85582
85786
  --picker-loader-color: var(--navi-loader-color);
85787
+ --picker-cta-background-color: var(--navi-accent-color);
85583
85788
  --picker-border-color: var(--navi-control-border-color);
85584
85789
  --picker-background-color: white;
85585
85790
  --picker-color: currentColor;
@@ -85678,7 +85883,7 @@ const css$r = /* css */`@layer navi {
85678
85883
  line-height: inherit;
85679
85884
  }
85680
85885
 
85681
- .navi_picker[data-variant="button"] {
85886
+ .navi_picker:is([data-variant="button"], [data-variant="border"], [data-variant="discrete-border"]) {
85682
85887
  --picker-padding-x-default: var(--navi-button-padding-x-default);
85683
85888
  --picker-padding-y-default: var(--navi-button-padding-y-default);
85684
85889
  --picker-align-x-default: center;
@@ -85696,6 +85901,49 @@ const css$r = /* css */`@layer navi {
85696
85901
  --picker-text-align-default: center;
85697
85902
  }
85698
85903
 
85904
+ .navi_picker[data-variant="border"] {
85905
+ --picker-background-color: transparent;
85906
+ --picker-background-color-hover: color-mix(in srgb,
85907
+ currentColor 8%,
85908
+ var(--picker-background-color));
85909
+ --picker-background-color-readonly: var(--picker-background-color);
85910
+ --picker-background-color-disabled: var(--picker-background-color);
85911
+ }
85912
+
85913
+ .navi_picker[data-variant="discrete-border"] {
85914
+ --picker-background-color: transparent;
85915
+ --picker-background-color-hover: var(--picker-background-color);
85916
+ --picker-background-color-readonly: var(--picker-background-color);
85917
+ --picker-background-color-disabled: var(--picker-background-color);
85918
+ }
85919
+
85920
+ .navi_picker[data-cta] {
85921
+ --picker-background-color: var(--picker-cta-background-color);
85922
+ --picker-border-color: var(--picker-cta-background-color);
85923
+ --picker-color: white;
85924
+ --picker-background-color-hover: color-mix(in srgb,
85925
+ var(--picker-background-color) 85%,
85926
+ white);
85927
+ --picker-border-color-hover: color-mix(in srgb,
85928
+ var(--picker-border-color) 85%,
85929
+ white);
85930
+ --picker-background-color-readonly: color-mix(in srgb,
85931
+ var(--picker-background-color) 50%,
85932
+ white);
85933
+ --picker-border-color-readonly: color-mix(in srgb,
85934
+ var(--picker-border-color) 50%,
85935
+ white);
85936
+ --picker-background-color-disabled: color-mix(in srgb,
85937
+ var(--picker-background-color) 40%,
85938
+ white);
85939
+ --picker-border-color-disabled: color-mix(in srgb,
85940
+ var(--picker-border-color) 40%,
85941
+ white);
85942
+ --picker-color-disabled: color-mix(in srgb,
85943
+ var(--picker-color) 60%,
85944
+ transparent);
85945
+ }
85946
+
85699
85947
  .navi_picker[data-variant="text"] {
85700
85948
  --picker-padding-x-default: 0;
85701
85949
  --picker-padding-y-default: 0;
@@ -85976,6 +86224,18 @@ const css$r = /* css */`@layer navi {
85976
86224
  --x-picker-cursor: default;
85977
86225
  }
85978
86226
 
86227
+ &[data-variant="discrete-border"] {
86228
+ --x-picker-border-color: transparent;
86229
+
86230
+ &[data-hover] {
86231
+ --x-picker-border-color: var(--picker-border-color-hover);
86232
+ }
86233
+
86234
+ &[data-readonly], &[data-disabled] {
86235
+ --x-picker-border-color: transparent;
86236
+ }
86237
+ }
86238
+
85979
86239
  &[data-callout] {
85980
86240
  --x-picker-border-color: var(--callout-color);
85981
86241
  }
@@ -86056,6 +86316,8 @@ const PickerButton = props => {
86056
86316
  // The trigger is one icon — its box is that icon's, the way <Button icon>
86057
86317
  // means it. A shape, so it composes with the variant painting around it.
86058
86318
  icon,
86319
+ // A call to action, filled with the accent: what `cta` is on a Button.
86320
+ cta,
86059
86321
  rightSlotIcon,
86060
86322
  rightSlotIconSize = "inherit",
86061
86323
  // What goes in the right slot as-is — no <Icon> around it, so a caller can
@@ -86133,7 +86395,9 @@ const PickerButton = props => {
86133
86395
  // after it, before it, or out of the flow altogether.
86134
86396
  const isBare = variant === "bare";
86135
86397
  const bareChevron = isBare && chevron !== undefined ? chevron : undefined;
86136
- const hasRightSlot = !picksNothing && rightSlot !== null && !isIcon && variant !== "headless" && variant !== "button" && variant !== "text" && (!isBare || bareChevron !== undefined) && ui !== "default";
86398
+ // The Button's own looks: a label, no slot after it.
86399
+ const drawnAsButton = variant === "button" || variant === "border" || variant === "discrete-border";
86400
+ const hasRightSlot = !picksNothing && rightSlot !== null && !isIcon && variant !== "headless" && !drawnAsButton && variant !== "text" && (!isBare || bareChevron !== undefined) && ui !== "default";
86137
86401
  // How far the press must follow a hanging chevron: its size, as a length the
86138
86402
  // input's inset can add up. "inherit" is 1em of the picker's own font, which
86139
86403
  // the input shares (font: inherit), so the em the icon is drawn in and the
@@ -86231,6 +86495,7 @@ const PickerButton = props => {
86231
86495
  baseClassName: "navi_picker",
86232
86496
  pseudoClasses: PICKER_BUTTON_PSEUDO_CLASSES,
86233
86497
  "data-variant": variant,
86498
+ "data-cta": cta ? "" : undefined,
86234
86499
  "data-icon": isIcon ? "" : undefined,
86235
86500
  "data-chevron": hasRightSlot ? bareChevron : undefined,
86236
86501
  "navi-picker": "",
@@ -86241,6 +86506,7 @@ const PickerButton = props => {
86241
86506
  basePseudoState: basePseudoState,
86242
86507
  styleCSSVars: PickerStyleCSSVars,
86243
86508
  variant: undefined,
86509
+ cta: undefined,
86244
86510
  icon: undefined
86245
86511
  // The caller's own role goes onto the host, beside the name (see
86246
86512
  // control_context.js): a role and a name on two different elements
@@ -86399,7 +86665,7 @@ const PickerButton = props => {
86399
86665
  // caller is drawing there ("no filter", "anywhere"), so how it
86400
86666
  // looks empty stays theirs (documented on the ui prop below).
86401
86667
  ,
86402
- "navi-placeholder": (ui === undefined || pickerUIIsNaviOwn(ui)) && variant !== "button" && variant !== "text" && uiStateHoldsNothing(value) ? "" : undefined,
86668
+ "navi-placeholder": (ui === undefined || pickerUIIsNaviOwn(ui)) && !drawnAsButton && variant !== "text" && uiStateHoldsNothing(value) ? "" : undefined,
86403
86669
  maxLines: maxLines,
86404
86670
  children: jsx(PickerOwnContent, {
86405
86671
  children: jsx(MaxLinesContext.Provider, {
@@ -86855,7 +87121,8 @@ const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
86855
87121
  * popupWidthFitContent?: boolean,
86856
87122
  * loadingOutline?: "custom",
86857
87123
  * loadingOutlineInset?: number,
86858
- * variant?: "icon" | "circle" | "headless" | "discrete" | "button" | "text" | "bare" | "picker",
87124
+ * variant?: "icon" | "circle" | "headless" | "discrete" | "button" | "border" | "discrete-border" | "text" | "bare" | "picker",
87125
+ * cta?: boolean,
86859
87126
  * icon?: boolean,
86860
87127
  * alignX?: "start" | "center" | "end",
86861
87128
  * alignY?: "start" | "center" | "end" | "stretch",
@@ -87120,10 +87387,13 @@ const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
87120
87387
  * receives the cleared value like any other choice. A cut (Ctrl+X) on such a
87121
87388
  * picker copies the value and leaves it in place: no clear without the
87122
87389
  * question.
87123
- * @param {"icon"|"headless"|"discrete"|"button"|"text"|"bare"|"picker"} [variant]
87390
+ * @param {"icon"|"headless"|"discrete"|"button"|"border"|"discrete-border"|"text"|"bare"|"picker"} [variant]
87124
87391
  * How the trigger is drawn. `"button"` is a Button's drawing — surface,
87125
87392
  * padding, a centered label, no chevron — and what a `type="confirm"` picker
87126
87393
  * draws unless told otherwise: nothing is picked, the popup is a question.
87394
+ * `"border"` and `"discrete-border"` are the Button's two other looks, from
87395
+ * its own formulas: a picker swapped in for a Button that opened a popup
87396
+ * looks the same at rest, on hover and read-only.
87127
87397
  * `"text"` is a word in a sentence, underlined with dots, no box and no
87128
87398
  * slot: what `mode="callout"` uses for a callout on a term rather than on an
87129
87399
  * icon (`"icon"` being its default, with an info icon in place of the
@@ -87147,6 +87417,9 @@ const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
87147
87417
  * nearest positioned ancestor it happens to find. `"picker"` (or an explicit
87148
87418
  * `variant={undefined}`) asks a confirm or callout picker for the field-like
87149
87419
  * drawing every other picker has.
87420
+ * @param {boolean} [cta] A call to action: filled with the accent, its border
87421
+ * matching, as `cta` draws a Button — for a confirm picker that is a row's
87422
+ * main action.
87150
87423
  * @param {"custom"} [loadingOutline] Who draws the loading outline while the
87151
87424
  * action runs. Left unsaid, the picker does, around its own box. `"custom"`
87152
87425
  * says someone else does — what it means on a `Button` too — for a picker
@@ -87656,8 +87929,13 @@ const TRAVELS_NONE = [];
87656
87929
  * vertical?: boolean,
87657
87930
  * previousLabel?: string,
87658
87931
  * nextLabel?: string,
87932
+ * previousTestId?: string,
87933
+ * nextTestId?: string,
87659
87934
  * [key: string]: any,
87660
87935
  * }>}
87936
+ * @param {string} [previousTestId] The `data-testid` of the way out to the
87937
+ * previous value, which navi builds and the caller has no other way to name.
87938
+ * `nextTestId` is its twin.
87661
87939
  * @param {any} [value] The value shown. Held from above: `uiAction` says when
87662
87940
  * it should move. Say `signal` for a two-way binding instead, or
87663
87941
  * `defaultValue` to let the spin hold the value itself.
@@ -87739,6 +88017,8 @@ const Spin = ({
87739
88017
  maxLines,
87740
88018
  previousLabel,
87741
88019
  nextLabel,
88020
+ previousTestId,
88021
+ nextTestId,
87742
88022
  ...rest
87743
88023
  }) => {
87744
88024
  import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
@@ -88050,6 +88330,7 @@ const Spin = ({
88050
88330
  unavailable: !allowed || loadingResolved || readOnlyResolved,
88051
88331
  unavailableMessage: () => wayOutMessage(allowed, isNext),
88052
88332
  label: isNext ? nextLabel ?? spinI18n("spin.next") : previousLabel ?? spinI18n("spin.previous"),
88333
+ testId: isNext ? nextTestId : previousTestId,
88053
88334
  onPress: e => {
88054
88335
  focusMiddle();
88055
88336
  if (editable) {
@@ -88280,6 +88561,7 @@ const WayOut = ({
88280
88561
  unavailable,
88281
88562
  unavailableMessage,
88282
88563
  label,
88564
+ testId,
88283
88565
  onPress,
88284
88566
  onPointerDown,
88285
88567
  children
@@ -88318,6 +88600,7 @@ const WayOut = ({
88318
88600
  ,
88319
88601
  role: "button",
88320
88602
  "aria-label": label,
88603
+ "data-testid": testId,
88321
88604
  "aria-disabled": unavailable ? "true" : undefined,
88322
88605
  "data-unavailable": unavailable ? "" : undefined
88323
88606
  // At the chevron, not beside it: a callout aims its arrow at where the
@@ -97267,6 +97550,7 @@ const css$b = /* css */`@layer navi {
97267
97550
  `;
97268
97551
  const ButtonCopyToClipboard = ({
97269
97552
  children,
97553
+ "aria-label": ariaLabel = buttonI18n("button.copy"),
97270
97554
  ...props
97271
97555
  }) => {
97272
97556
  import.meta.css = [css$b, "@jsenv/navi/src/control/input/button_copy_to_clipboard.jsx"];
@@ -97283,11 +97567,15 @@ const ButtonCopyToClipboard = ({
97283
97567
  ...props,
97284
97568
  children: [jsx(Box, {
97285
97569
  className: "navi_copied_notif",
97286
- "aria-hidden": copied ? "false" : "true",
97570
+ role: "status",
97287
97571
  opacity: copied ? 1 : 0,
97288
- children: "Copié !"
97572
+ children: copied ? buttonI18n("button.copied") : null
97289
97573
  }), jsx(Button, {
97290
- className: "navi_copy_button",
97574
+ className: "navi_copy_button"
97575
+ // The props land on the container; the name belongs to the button,
97576
+ // which shows only an icon.
97577
+ ,
97578
+ "aria-label": ariaLabel,
97291
97579
  icon: true,
97292
97580
  revealOnInteraction: true,
97293
97581
  square: true,