@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.
@@ -980,6 +980,14 @@ const buttonI18n = /*#__PURE__*/ defineNaviMessages({
980
980
  en: "Remove",
981
981
  fr: "Retirer",
982
982
  },
983
+ "button.copy": {
984
+ en: "Copy",
985
+ fr: "Copier",
986
+ },
987
+ "button.copied": {
988
+ en: "Copied!",
989
+ fr: "Copié !",
990
+ },
983
991
  });
984
992
 
985
993
  // Spin messages — the ends of what one steps through when the control holding
@@ -2964,6 +2972,78 @@ const mergeTwoJsValues = (firstValue, secondValue) => {
2964
2972
  return objectMerge;
2965
2973
  };
2966
2974
 
2975
+ // An option a function does not read does nothing, and what is built on it
2976
+ // still looks plausible: `persists` written `persist` keeps nothing across a
2977
+ // reload, and nothing says so. Said out loud in dev, with the option it most
2978
+ // likely meant, because the caller cannot tell "ignored" from "broken".
2979
+
2980
+ /**
2981
+ * Warns, once per `where` and key, about each key of `options` that is not
2982
+ * in `knownKeys`. `hints` maps a key to a sentence saying where that wish
2983
+ * belongs instead.
2984
+ */
2985
+ const warnUnknownOptions = (where, options, knownKeys, hints = {}) => {
2986
+ for (const key of Object.keys(options)) {
2987
+ if (knownKeys.has(key)) {
2988
+ continue;
2989
+ }
2990
+ const warnedKey = `${where} ${key}`;
2991
+ if (warnedKeySet.has(warnedKey)) {
2992
+ continue;
2993
+ }
2994
+ warnedKeySet.add(warnedKey);
2995
+ let message = `${where}: "${key}" is not an option it reads, it does nothing.`;
2996
+ const closest = findClosestKey(key, knownKeys);
2997
+ if (closest) {
2998
+ message += ` Did you mean "${closest}"?`;
2999
+ }
3000
+ if (hints[key]) {
3001
+ message += ` ${hints[key]}`;
3002
+ }
3003
+ console.warn(message);
3004
+ }
3005
+ };
3006
+
3007
+ const warnedKeySet = new Set();
3008
+
3009
+ // A slip of a letter or two (`persist`, `debounceURL`), or the same word
3010
+ // with a different case.
3011
+ const findClosestKey = (key, knownKeys) => {
3012
+ const keyLowerCase = key.toLowerCase();
3013
+ let closest;
3014
+ let closestDistance = 3;
3015
+ for (const knownKey of knownKeys) {
3016
+ const distance = editDistance(keyLowerCase, knownKey.toLowerCase());
3017
+ if (distance < closestDistance) {
3018
+ closest = knownKey;
3019
+ closestDistance = distance;
3020
+ }
3021
+ }
3022
+ return closest;
3023
+ };
3024
+
3025
+ const editDistance = (a, b) => {
3026
+ let previousRow = Array.from({ length: b.length + 1 }, (_, i) => i);
3027
+ for (let i = 1; i <= a.length; i++) {
3028
+ const row = [i];
3029
+ for (let j = 1; j <= b.length; j++) {
3030
+ const substitution = previousRow[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
3031
+ const insertion = row[j - 1] + 1;
3032
+ const deletion = previousRow[j] + 1;
3033
+ let smallest = substitution;
3034
+ if (insertion < smallest) {
3035
+ smallest = insertion;
3036
+ }
3037
+ if (deletion < smallest) {
3038
+ smallest = deletion;
3039
+ }
3040
+ row.push(smallest);
3041
+ }
3042
+ previousRow = row;
3043
+ }
3044
+ return previousRow[b.length];
3045
+ };
3046
+
2967
3047
  /**
2968
3048
  * Creates an effect that uses WeakRef to prevent garbage collection of referenced values.
2969
3049
  *
@@ -3759,6 +3839,33 @@ const mergeActionParams = (currentParams, newParams) => {
3759
3839
  return mergeTwoJsValues(currentParams, newParams);
3760
3840
  };
3761
3841
 
3842
+ // What createActionCore reads off the options (see warnUnknownOptions):
3843
+ // `{ dependencies: [X] }` would otherwise be accepted and never rerun anything.
3844
+ const OPTIONS_CREATE_ACTION_READS = new Set([
3845
+ "keep",
3846
+ "name",
3847
+ "params",
3848
+ "isPrerun",
3849
+ "runningState",
3850
+ "aborted",
3851
+ "error",
3852
+ "value",
3853
+ "resultToValue",
3854
+ "valueToData",
3855
+ "provisionalValue",
3856
+ "inheritData",
3857
+ "dataDefault",
3858
+ "data",
3859
+ "completed",
3860
+ "sideEffect",
3861
+ "meta",
3862
+ "outputSignal",
3863
+ "completeSideEffect",
3864
+ ]);
3865
+ const OPTIONS_CREATE_ACTION_HINTS = {
3866
+ dependencies: `A read that follows writes is a resource (docs/actions.md, "Reruns").`,
3867
+ rerunOn: `A read that follows writes is a resource (docs/actions.md, "Reruns").`,
3868
+ };
3762
3869
  const actionWeakMap = new WeakMap();
3763
3870
  /**
3764
3871
  * Wraps an async callback into an action: a question, its answer, and the
@@ -3801,6 +3908,14 @@ const createAction = (callback, rootOptions = {}) => {
3801
3908
  if (existing) {
3802
3909
  return existing;
3803
3910
  }
3911
+ {
3912
+ warnUnknownOptions(
3913
+ `createAction(${callback.name || "anonymous"})`,
3914
+ rootOptions,
3915
+ OPTIONS_CREATE_ACTION_READS,
3916
+ OPTIONS_CREATE_ACTION_HINTS,
3917
+ );
3918
+ }
3804
3919
 
3805
3920
  const keptAnswerRegistry = rootOptions.keep
3806
3921
  ? createKeptAnswerRegistry(rootOptions.keep)
@@ -7316,6 +7431,32 @@ const notePageChange = () => {
7316
7431
 
7317
7432
  const isRenderingPageChange = () => renderingPageChange;
7318
7433
 
7434
+ // A browser refusing storage (blocked site data, some private windows) throws
7435
+ // on the first touch of window.localStorage, and a full one throws on setItem.
7436
+ // Either way what was to be kept lives in memory for the session, which is all
7437
+ // a persisted value can fall back to — so every access goes through here.
7438
+
7439
+ const readLocalStorage = (key) => {
7440
+ try {
7441
+ const raw = window.localStorage.getItem(key);
7442
+ return raw === null ? undefined : raw;
7443
+ } catch {
7444
+ return undefined;
7445
+ }
7446
+ };
7447
+
7448
+ const writeLocalStorage = (key, value) => {
7449
+ try {
7450
+ if (value === undefined) {
7451
+ window.localStorage.removeItem(key);
7452
+ } else {
7453
+ window.localStorage.setItem(key, value);
7454
+ }
7455
+ } catch {
7456
+ // Full, or refused: the value lives for the session only.
7457
+ }
7458
+ };
7459
+
7319
7460
  // Global signal registry for route template detection
7320
7461
  const globalSignalRegistry = new Map();
7321
7462
 
@@ -7351,6 +7492,26 @@ const generateSignalId = () => {
7351
7492
  return id;
7352
7493
  };
7353
7494
 
7495
+ // Read by stateSignal, or off the options it stores by route_pattern.js
7496
+ // (itemType).
7497
+ const STATE_SIGNAL_OPTIONS = new Set([
7498
+ "id",
7499
+ "type",
7500
+ "itemType",
7501
+ "min",
7502
+ "max",
7503
+ "step",
7504
+ "oneOf",
7505
+ "localStorageRepresentation",
7506
+ "persists",
7507
+ "debug",
7508
+ "default",
7509
+ "ignoreArrayOrder",
7510
+ "autoFix",
7511
+ "weak",
7512
+ "history",
7513
+ "debounceUrl",
7514
+ ]);
7354
7515
  /**
7355
7516
  * Creates an advanced signal with dynamic default value, local storage persistence, and validation.
7356
7517
  *
@@ -7493,6 +7654,9 @@ const stateSignal = (defaultValue, options = {}) => {
7493
7654
  history = "replace",
7494
7655
  debounceUrl = 0,
7495
7656
  } = options;
7657
+ {
7658
+ warnUnknownOptions(`stateSignal "${id}"`, options, STATE_SIGNAL_OPTIONS);
7659
+ }
7496
7660
 
7497
7661
  if (history !== "replace" && history !== "push") {
7498
7662
  throw new TypeError(
@@ -7561,27 +7725,19 @@ const stateSignal = (defaultValue, options = {}) => {
7561
7725
  }
7562
7726
  }
7563
7727
  const readFromLocalStorage = persists
7564
- ? () => {
7565
- const raw = window.localStorage.getItem(localStorageKey);
7566
- if (raw === null) {
7567
- return undefined;
7568
- }
7569
- return raw;
7570
- }
7728
+ ? () => readLocalStorage(localStorageKey)
7571
7729
  : () => undefined;
7572
7730
  const updateLocalStorage = persists
7573
7731
  ? () => {
7574
- const localStorageValue = validity.representations.localStorage.value;
7575
- if (localStorageValue === undefined) {
7576
- window.localStorage.removeItem(localStorageKey);
7577
- } else {
7578
- window.localStorage.setItem(localStorageKey, localStorageValue);
7579
- }
7732
+ writeLocalStorage(
7733
+ localStorageKey,
7734
+ validity.representations.localStorage.value,
7735
+ );
7580
7736
  }
7581
7737
  : () => {};
7582
7738
  const removeFromLocalStorage = persists
7583
7739
  ? () => {
7584
- window.localStorage.removeItem(localStorageKey);
7740
+ writeLocalStorage(localStorageKey, undefined);
7585
7741
  }
7586
7742
  : () => {};
7587
7743
 
@@ -10289,6 +10445,13 @@ const getRoutePrivateProperties = (route) => {
10289
10445
  const ROUTE_NOT_MATCHING_PARAMS = {};
10290
10446
  // Flag to prevent signal-to-URL synchronization during URL-to-signal synchronization
10291
10447
  let isUpdatingRoutesFromUrl = false;
10448
+ const ROUTE_OPTIONS = new Set([
10449
+ "searchParams",
10450
+ "params",
10451
+ "redirectRoute",
10452
+ "redirectRouteParams",
10453
+ "dropSearchParams",
10454
+ ]);
10292
10455
  /**
10293
10456
  * Declares a route from an url pattern.
10294
10457
  *
@@ -10371,16 +10534,17 @@ let isUpdatingRoutesFromUrl = false;
10371
10534
  * route(`/games/:gameId=${gamePageIdSignal}`, { dropSearchParams: ["v"] });
10372
10535
  * ```
10373
10536
  */
10374
- const route = (
10375
- pattern,
10376
- {
10537
+ const route = (pattern, options = {}) => {
10538
+ const {
10377
10539
  searchParams,
10378
10540
  params,
10379
10541
  redirectRoute,
10380
10542
  redirectRouteParams,
10381
10543
  dropSearchParams,
10382
- } = {},
10383
- ) => {
10544
+ } = options;
10545
+ {
10546
+ warnUnknownOptions(`route("${pattern}")`, options, ROUTE_OPTIONS);
10547
+ }
10384
10548
  const routePattern = createRoutePattern(pattern, { searchParams, params });
10385
10549
  const { cleanPattern } = routePattern;
10386
10550
  const [publishStatus, subscribeStatus] = createPubSub();
@@ -12086,6 +12250,13 @@ const activeRouteReadsSignal = computed(() => {
12086
12250
  return reads;
12087
12251
  });
12088
12252
 
12253
+ // Besides `when` and `prefetch`, what the binding reads (actionRunEffect,
12254
+ // then bindParams).
12255
+ const ROUTE_ACTION_BINDING_OPTIONS = new Set([
12256
+ "debounce",
12257
+ "inheritData",
12258
+ "rerunOnChange",
12259
+ ]);
12089
12260
  /**
12090
12261
  * Binds an action to a route: it runs when the route matches, with the params
12091
12262
  * the effect reads off the address, runs again on a `reload()`, and is aborted
@@ -12135,6 +12306,9 @@ const routeAction = (
12135
12306
  paramsEffect,
12136
12307
  { when, prefetch = true, ...options } = {},
12137
12308
  ) => {
12309
+ {
12310
+ warnUnknownOptions("routeAction", options, ROUTE_ACTION_BINDING_OPTIONS);
12311
+ }
12138
12312
  if (typeof action === "function") {
12139
12313
  action = createAction(action);
12140
12314
  }
@@ -13977,6 +14151,12 @@ const stopLoad = (reason = "stopLoad() called") => {
13977
14151
  * actions of the routes matching the url run again (see routeAction), one that
13978
14152
  * failed included — what a "Retry" under a page that could not load calls.
13979
14153
  * The address, the history and the scroll stay as they are.
14154
+ *
14155
+ * Route actions only: a component's `useAsyncData(fn)`, or an action no route
14156
+ * reads, is not asked again. Nor does it bring back code whose `import()`
14157
+ * failed — the document refuses that address again without a request — so
14158
+ * the way out of a failed import is a fresh document,
14159
+ * `window.location.reload()` (docs/dynamic_import.md).
13980
14160
  */
13981
14161
  const reload = browserIntegration.reload;
13982
14162
  /**
@@ -25212,16 +25392,11 @@ const arraySignalMembership = (...args) => {
25212
25392
  };
25213
25393
 
25214
25394
  const localStorageSignal = (key) => {
25215
- const initialValue = localStorage.getItem(key);
25216
-
25217
- const valueSignal = signal(initialValue === null ? undefined : initialValue);
25395
+ const valueSignal = signal(readLocalStorage(key));
25218
25396
  effect(() => {
25219
25397
  const value = valueSignal.value;
25220
- if (value === undefined) {
25221
- localStorage.removeItem(key);
25222
- } else {
25223
- localStorage.setItem(key, value);
25224
- }
25398
+ // Storage keeps strings: a null written as is would come back as "null".
25399
+ writeLocalStorage(key, value === null ? undefined : value);
25225
25400
  });
25226
25401
 
25227
25402
  return valueSignal;
@@ -26067,12 +26242,51 @@ const resolveRerunOn = (rerunOn, inheritedRerunOn = defaultRerunOn) => {
26067
26242
  if (!rerunOn) {
26068
26243
  return inheritedRerunOn;
26069
26244
  }
26245
+ assertRerunOnCanFire(rerunOn);
26070
26246
  return {
26071
26247
  GET: rerunOn.GET ?? inheritedRerunOn.GET,
26072
26248
  GET_MANY: rerunOn.GET_MANY ?? inheritedRerunOn.GET_MANY,
26073
26249
  GET_RANGE: rerunOn.GET_RANGE ?? inheritedRerunOn.GET_RANGE,
26074
26250
  };
26075
26251
  };
26252
+ // What can never fire throws, as a dependency name does (see
26253
+ // resolveDependency): a rerun that never happens leaves a read on its old
26254
+ // value with nothing to say so.
26255
+ const RERUN_READS = ["GET", "GET_MANY", "GET_RANGE"];
26256
+ const RERUN_VERBS = ["POST", "PUT", "PATCH", "DELETE", "*"];
26257
+ const assertRerunOnCanFire = (rerunOn) => {
26258
+ for (const read of Object.keys(rerunOn)) {
26259
+ if (!RERUN_READS.includes(read)) {
26260
+ throw new Error(
26261
+ `rerunOn: "${read}" is not a read that reruns — rerunOn takes GET, GET_MANY and GET_RANGE`,
26262
+ );
26263
+ }
26264
+ const verbs = rerunOn[read];
26265
+ if (
26266
+ verbs === undefined ||
26267
+ verbs === null ||
26268
+ verbs === false ||
26269
+ verbs === "*"
26270
+ ) {
26271
+ continue;
26272
+ }
26273
+ if (!Array.isArray(verbs)) {
26274
+ throw new Error(
26275
+ `rerunOn.${read}: ${JSON.stringify(verbs)} never fires — it takes false, "*" or a list of verbs`,
26276
+ );
26277
+ }
26278
+ for (const verb of verbs) {
26279
+ if (
26280
+ typeof verb !== "string" ||
26281
+ !RERUN_VERBS.includes(verb.toUpperCase())
26282
+ ) {
26283
+ throw new Error(
26284
+ `rerunOn.${read}: ${JSON.stringify(verb)} never fires — rerunOn names verbs (POST, PUT, PATCH, DELETE or "*"): POST covers POST_MANY`,
26285
+ );
26286
+ }
26287
+ }
26288
+ }
26289
+ };
26076
26290
 
26077
26291
  // What a `dependencies` entry names are callbacks, not verbs (`rerunOn` names
26078
26292
  // verbs, where `PATCH` covers `PATCH_MANY`): one callback of a resource can be
@@ -27101,6 +27315,24 @@ const debug$2 = (...args) => {
27101
27315
  }
27102
27316
  };
27103
27317
 
27318
+ const RESOURCE_OPTIONS = new Set([
27319
+ "idKey",
27320
+ "uniqueKeys",
27321
+ "rerunOn",
27322
+ "dependencies",
27323
+ "persist",
27324
+ "GET",
27325
+ "GET_MANY",
27326
+ "GET_RANGE",
27327
+ "POST",
27328
+ "POST_MANY",
27329
+ "PUT",
27330
+ "PUT_MANY",
27331
+ "PATCH",
27332
+ "PATCH_MANY",
27333
+ "DELETE",
27334
+ "DELETE_MANY",
27335
+ ]);
27104
27336
  /**
27105
27337
  * Creates a reactive REST resource backed by a shared signal store.
27106
27338
  * Returns a `stateFacade` exposing one action per REST callback provided
@@ -27159,9 +27391,8 @@ const debug$2 = (...args) => {
27159
27391
  * DELETE: ({ id }) => fetchJson(`/users/${id}`, { method: "DELETE" }),
27160
27392
  * });
27161
27393
  */
27162
- const resource = (
27163
- name,
27164
- {
27394
+ const resource = (name, restCallbacks = {}) => {
27395
+ const {
27165
27396
  // configuration options
27166
27397
  idKey = "id",
27167
27398
  uniqueKeys = [],
@@ -27180,8 +27411,10 @@ const resource = (
27180
27411
  PATCH_MANY,
27181
27412
  DELETE,
27182
27413
  DELETE_MANY,
27183
- } = {},
27184
- ) => {
27414
+ } = restCallbacks;
27415
+ {
27416
+ warnUnknownOptions(`resource(${name})`, restCallbacks, RESOURCE_OPTIONS);
27417
+ }
27185
27418
  const declarationSite = getDeclarationSite();
27186
27419
  const setupCallbackSet = new Set();
27187
27420
  const addItemSetup = (callback) => {
@@ -29073,8 +29306,8 @@ const valueInLocalStorage = (key, { type = "any" } = {}) => {
29073
29306
  const converter = TYPE_CONVERTERS[type];
29074
29307
 
29075
29308
  const get = () => {
29076
- let valueInLocalStorage = window.localStorage.getItem(key);
29077
- if (valueInLocalStorage === null) {
29309
+ const valueInLocalStorage = readLocalStorage(key);
29310
+ if (valueInLocalStorage === undefined) {
29078
29311
  return undefined;
29079
29312
  }
29080
29313
  let valueToReturn = valueInLocalStorage;
@@ -29098,7 +29331,7 @@ const valueInLocalStorage = (key, { type = "any" } = {}) => {
29098
29331
 
29099
29332
  const set = (value) => {
29100
29333
  if (value === undefined) {
29101
- window.localStorage.removeItem(key);
29334
+ writeLocalStorage(key, undefined);
29102
29335
  return;
29103
29336
  }
29104
29337
  let valueToStore = value;
@@ -29106,10 +29339,10 @@ const valueInLocalStorage = (key, { type = "any" } = {}) => {
29106
29339
  const valueEncoded = converter.encode(valueToStore);
29107
29340
  valueToStore = valueEncoded;
29108
29341
  }
29109
- window.localStorage.setItem(key, valueToStore);
29342
+ writeLocalStorage(key, valueToStore);
29110
29343
  };
29111
29344
  const remove = () => {
29112
- window.localStorage.removeItem(key);
29345
+ writeLocalStorage(key, undefined);
29113
29346
  };
29114
29347
 
29115
29348
  return [get, set, remove];
@@ -29329,7 +29562,8 @@ const useForceRender = () => {
29329
29562
  *
29330
29563
  * @example <caption>Handle error inline</caption>
29331
29564
  * const [user, , error] = useAsyncData(userAction, { error: true });
29332
- * if (error) return <ErrorCard message={error.message} />;
29565
+ * // A failed refresh keeps `user`: only a first load has nothing else to show.
29566
+ * if (error && user === undefined) return <ErrorCard message={error.message} />;
29333
29567
  *
29334
29568
  * @example <caption>Say the failure over the rows already on screen</caption>
29335
29569
  * const [items, , error, dismissError] = useAsyncData(listAction, { error: true });
@@ -41288,9 +41522,12 @@ const confirmCommand = (source, event) => {
41288
41522
  event,
41289
41523
  source: commandSource,
41290
41524
  });
41525
+ // Carried by the close too: a Dialog or a Popover answers the yes in
41526
+ // its onClose (detail.isConfirm), the twin of isCancel.
41291
41527
  return dispatchCustomEvent(target, "navi_request_close", {
41292
41528
  event,
41293
41529
  source: commandSource,
41530
+ isConfirm: true,
41294
41531
  });
41295
41532
  },
41296
41533
  };
@@ -48691,26 +48928,33 @@ const useControlUIState = (ref, uiStateInitial) => {
48691
48928
  if (!element) {
48692
48929
  return undefined;
48693
48930
  }
48694
- // The host, not the box around it: a control is a whole little tree (a
48695
- // picker is a box holding an input) and the state is announced on the one
48696
- // element that holds it. Same resolution every request goes through, see
48697
- // ui_state_dom.js.
48698
- const host = findControlHost(element) || element;
48699
- // Asked once here rather than trusted from the props above: between the
48700
- // first render and this effect the control has read its own value prop, its
48701
- // defaultValue and any signal it was bound to, and settled which of them
48702
- // wins — the answer to that is on the element, not in what we were handed.
48703
- setUIState(getUIStateFromElement(host));
48704
- const onUIStateChange = e => {
48705
- setUIState(e.detail.value);
48706
- };
48707
- host.addEventListener("navi_ui_state_change", onUIStateChange);
48708
- return () => {
48709
- host.removeEventListener("navi_ui_state_change", onUIStateChange);
48710
- };
48931
+ return subscribeToControlUIState(element, setUIState);
48711
48932
  }, [ref]);
48712
48933
  return uiState;
48713
48934
  };
48935
+ /**
48936
+ * Calls `callback` with what the control `element` holds, right away and on
48937
+ * every change. Returns the teardown.
48938
+ */
48939
+ const subscribeToControlUIState = (element, callback) => {
48940
+ // The host, not the box around it: a control is a whole little tree (a
48941
+ // picker is a box holding an input) and the state is announced on the one
48942
+ // element that holds it. Same resolution every request goes through, see
48943
+ // ui_state_dom.js.
48944
+ const host = findControlHost(element) || element;
48945
+ // Asked once here rather than trusted from props: by the time this runs the
48946
+ // control has read its own value prop, its defaultValue and any signal it
48947
+ // was bound to, and settled which of them wins — the answer to that is on
48948
+ // the element.
48949
+ callback(getUIStateFromElement(host));
48950
+ const onUIStateChange = e => {
48951
+ callback(e.detail.value);
48952
+ };
48953
+ host.addEventListener("navi_ui_state_change", onUIStateChange);
48954
+ return () => {
48955
+ host.removeEventListener("navi_ui_state_change", onUIStateChange);
48956
+ };
48957
+ };
48714
48958
 
48715
48959
  /**
48716
48960
  * Like `useControlProps` but also establishes a 1:1 facade sync between the
@@ -53356,8 +53600,8 @@ const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
53356
53600
  * those instead.
53357
53601
  * @param {string} [props.value] - Value emitted by the control facade
53358
53602
  * (`navi_value`); defaults to `href`.
53359
- * @param {boolean} [props.current] - Forces the "current" state on (otherwise
53360
- * derived from the href/route).
53603
+ * @param {boolean} [props.current] - Forces the "current" state: `true` on,
53604
+ * `false` off. Left out, it is derived from the href/route.
53361
53605
  * @param {object|object[]} [props.currentExcept] -
53362
53606
  * Route(s) inside this link's own that are NOT it: while one of them matches,
53363
53607
  * the link is not current even though its route still is. A bar entry
@@ -53515,7 +53759,10 @@ const LinkWithRoute = ({
53515
53759
  except: currentExcept,
53516
53760
  also: currentAlso
53517
53761
  });
53518
- const innerCurrent = current || linkMatching;
53762
+ // Always a boolean from here: LinkPlain takes a given `current` as the
53763
+ // answer, so an href matching the address cannot relight what
53764
+ // currentExcept turned off.
53765
+ const innerCurrent = current ?? linkMatching;
53519
53766
 
53520
53767
  // What a childless link shows is LinkPlain's call (hrefFallback); a route
53521
53768
  // only says it the way its pattern writes it, not as the resolved href.
@@ -53599,7 +53846,7 @@ const LinkPlain = props => {
53599
53846
  } = getHrefTargetInfo(href, documentUrlSignal.peek());
53600
53847
  // A tab that is a SLIDE is current when the container is on it — which the
53601
53848
  // <Nav> around reads off that container, so nothing here has to be told.
53602
- const innerCurrent = current || (slide ? nav?.currentSlideArea === slide : isCurrent);
53849
+ const innerCurrent = current ?? (slide ? nav?.currentSlideArea === slide : isCurrent);
53603
53850
  useReportCurrentToBinderItem(innerCurrent);
53604
53851
  controlHostProps.basePseudoState = {
53605
53852
  ...basePseudoState,
@@ -58330,6 +58577,8 @@ const ButtonUI = props => {
58330
58577
  // Where the href leads next to the page one is on (the target, the rel, the
58331
58578
  // current state) is resolved above, by ButtonHrefResolver.
58332
58579
  const as = href === undefined ? "button" : "a";
58580
+ // What `data-href-current` paints, said to assistive tech as a link says it.
58581
+ const ariaCurrent = as === "a" && props.pseudoState?.[":-navi-href-current"] ? "page" : undefined;
58333
58582
 
58334
58583
  // Worn as an attribute, like a link's (see link_replace.js): read off the
58335
58584
  // anchor by the click handler, off the source by --navi-nav-to.
@@ -58366,6 +58615,7 @@ const ButtonUI = props => {
58366
58615
  // caller's props, so their own flex direction wins.
58367
58616
  ,
58368
58617
  flex: icon ? "x" : undefined,
58618
+ "aria-current": ariaCurrent,
58369
58619
  ...buttonControlRootProps,
58370
58620
  ...buttonControlHostProps,
58371
58621
  // eslint-disable-next-line react/no-children-prop
@@ -58718,7 +58968,10 @@ const ButtonHrefResolver = props => {
58718
58968
  // Whether the href is the page one is on depends on the address, and re-renders
58719
58969
  // the button only when it flips (see useHrefTargetFlag). Whether it leaves the
58720
58970
  // site does not depend on the address.
58721
- const ButtonWithHref = props => {
58971
+ const ButtonWithHref = ({
58972
+ current,
58973
+ ...props
58974
+ }) => {
58722
58975
  const Next = useNextResolver();
58723
58976
  const {
58724
58977
  href,
@@ -58739,7 +58992,7 @@ const ButtonWithHref = props => {
58739
58992
  // the state.
58740
58993
  ,
58741
58994
  pseudoState: {
58742
- ":-navi-href-current": isCurrent,
58995
+ ":-navi-href-current": current ?? isCurrent,
58743
58996
  ...pseudoState
58744
58997
  }
58745
58998
  });
@@ -58750,6 +59003,9 @@ const ButtonRouteResolver = props => {
58750
59003
  if (props.route) {
58751
59004
  return renderResolver(ButtonWithRoute, props);
58752
59005
  }
59006
+ if ((props.currentExcept || props.currentAlso)) {
59007
+ console.warn(`currentExcept/currentAlso amend what the button's own route says about being current, so they need a "route" prop.`);
59008
+ }
58753
59009
  return jsx(Next, {
58754
59010
  ...props
58755
59011
  });
@@ -58761,6 +59017,9 @@ const ButtonWithRoute = props => {
58761
59017
  const {
58762
59018
  route,
58763
59019
  routeParams,
59020
+ current,
59021
+ currentExcept,
59022
+ currentAlso,
58764
59023
  children,
58765
59024
  pseudoState,
58766
59025
  href,
@@ -58771,9 +59030,18 @@ const ButtonWithRoute = props => {
58771
59030
  if (href !== undefined) {
58772
59031
  console.warn(`A button with a "route" ignores its "href" (${href}): the route builds the address.`);
58773
59032
  }
59033
+ for (const amendingRoute of [currentExcept, currentAlso].flat()) {
59034
+ if (amendingRoute) {
59035
+ assertRoute(amendingRoute);
59036
+ }
59037
+ }
58774
59038
  }
58775
59039
  const url = route.buildUrl(routeParams);
58776
- const linkMatching = useRouteCurrent(route, routeParams);
59040
+ // What a route Link reads, with the same amendments (see link.jsx).
59041
+ const linkMatching = useRouteCurrent(route, routeParams, {
59042
+ except: currentExcept,
59043
+ also: currentAlso
59044
+ });
58777
59045
 
58778
59046
  // Merged into whatever the caller already holds: a button can be forced into
58779
59047
  // a state for a demo and still learn its own current-ness from its route.
@@ -58781,7 +59049,7 @@ const ButtonWithRoute = props => {
58781
59049
  href: url,
58782
59050
  pseudoState: {
58783
59051
  ...pseudoState,
58784
- ":-navi-href-current": linkMatching
59052
+ ":-navi-href-current": current ?? linkMatching
58785
59053
  },
58786
59054
  ...rest,
58787
59055
  children: children || route.buildRelativeUrl(routeParams)
@@ -58900,8 +59168,23 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
58900
59168
  * actionStandalone?: boolean,
58901
59169
  * actionAbortable?: boolean,
58902
59170
  * optimistic?: boolean,
59171
+ * route?: object,
59172
+ * routeParams?: object,
59173
+ * current?: boolean,
59174
+ * currentExcept?: object | object[],
59175
+ * currentAlso?: object | object[],
58903
59176
  * [key: string]: any,
58904
59177
  * }>}
59178
+ * @param {object} [route] Where the press leads, as a `<Link route>` says it:
59179
+ * the button is drawn as an `<a>` to the route's url (with `routeParams`),
59180
+ * and says whether it is the page one is on — `[data-href-current]` for the
59181
+ * eye, `aria-current="page"` for assistive tech. A bar entry drawn as a
59182
+ * button for its press feedback.
59183
+ * @param {boolean} [current] Forces that "current" state: `true` on, `false`
59184
+ * off. Left out, it is read from the route (or the `href`).
59185
+ * @param {object|object[]} [currentExcept] Route(s) inside the button's own
59186
+ * that are NOT it, and `currentAlso` route(s) beside it that are: what they
59187
+ * mean on a `<Link>`.
58905
59188
  * @param {boolean} [replace] Go where the press leads — an `href`, a
58906
59189
  * `--navi-nav-to` command — by TAKING THE PLACE of the current history entry
58907
59190
  * rather than stacking on it: what `<Link replace>` says, for a press drawn
@@ -62886,36 +63169,61 @@ const Textarea = ({
62886
63169
  * typed over how many the limit allows, the way Material writes it (just the
62887
63170
  * count when there is no `maxLength`). Where it goes is the caller's call,
62888
63171
  * which is why it is a separate component rather than something the textarea
62889
- * draws: put it under the box, in a form footer, next to a label, and feed it
62890
- * the same value or signal as the textarea.
63172
+ * draws: put it under the box, in a form footer, next to a label, and point
63173
+ * it at the textarea with `for`.
62891
63174
  *
62892
63175
  * @type {import("preact").FunctionComponent<{
63176
+ * for?: string,
62893
63177
  * value?: string,
62894
63178
  * signal?: import("@preact/signals").Signal<string>,
62895
63179
  * maxLength?: number,
62896
63180
  * [key: string]: any,
62897
63181
  * }>}
62898
- * @param {string} [value] The text being counted. Say `signal` instead for a
62899
- * two-way bound textarea: reading it here subscribes the count to it.
63182
+ * @param {string} [for] The `id` of the Textarea counted: the count reads
63183
+ * what it holds, so the field stays a plain one (`defaultValue={record.bio}`)
63184
+ * with no signal bound only for the counter's sake. The limit defaults to
63185
+ * that Textarea's `maxLength` (or `maxLengthGuard`).
63186
+ * @param {string} [value] The text being counted, when it is not read from a
63187
+ * Textarea. Say `signal` instead to subscribe the count to it.
62900
63188
  * @param {number} [maxLength] The limit, shown after the count ("50/200").
62901
63189
  * Without it the count stands alone.
62902
63190
  */
62903
63191
  const TextareaCharCount = ({
63192
+ for: controlId,
62904
63193
  value,
62905
63194
  signal,
62906
63195
  maxLength,
62907
63196
  ...rest
62908
63197
  }) => {
62909
63198
  import.meta.css = [css$J, "@jsenv/navi/src/control/input/textarea.jsx"];
62910
- const resolvedValue = signal ? signal.value : value;
63199
+ const [controlText, controlMaxLength] = useTextOfControl(controlId);
63200
+ const resolvedValue = controlId ? controlText : signal ? signal.value : value;
62911
63201
  const length = typeof resolvedValue === "string" ? resolvedValue.length : 0;
63202
+ const limit = maxLength ?? controlMaxLength;
62912
63203
  return jsx(Box, {
62913
63204
  as: "span",
62914
63205
  baseClassName: "navi_textarea_char_count",
62915
63206
  ...rest,
62916
- children: maxLength === undefined ? length : `${length}/${maxLength}`
63207
+ children: limit === undefined ? length : `${length}/${limit}`
62917
63208
  });
62918
63209
  };
63210
+ const useTextOfControl = controlId => {
63211
+ const [text, setText] = useState();
63212
+ const [maxLength, setMaxLength] = useState();
63213
+ useLayoutEffect(() => {
63214
+ if (!controlId) {
63215
+ return undefined;
63216
+ }
63217
+ const element = document.getElementById(controlId);
63218
+ if (!element) {
63219
+ return undefined;
63220
+ }
63221
+ const controlProps = getUIStateControllerById(controlId)?.props;
63222
+ setMaxLength(controlProps?.maxLength ?? controlProps?.maxLengthGuard);
63223
+ return subscribeToControlUIState(element, setText);
63224
+ }, [controlId]);
63225
+ return [text, maxLength];
63226
+ };
62919
63227
 
62920
63228
  // `field-sizing: content` sizes the box from the value, and an empty field has
62921
63229
  // none — the placeholder is text the browser refuses to make room for. So the
@@ -67273,6 +67581,11 @@ const Time = props => {
67273
67581
  const {
67274
67582
  type
67275
67583
  } = props;
67584
+ if (props.value !== undefined && props.children === undefined) {
67585
+ // Otherwise drawn as the placeholder, a plausible screen with nothing wrong
67586
+ // on it.
67587
+ console.warn(`<Time> reads what it writes from its children, not from "value": <Time type="${type}">{value}</Time>.`);
67588
+ }
67276
67589
  if (type === "date") {
67277
67590
  return jsx(TimeDate, {
67278
67591
  ...props
@@ -71752,6 +72065,8 @@ const css$C = /* css */`
71752
72065
  * actually closes — not preventable: it is the close happening, not a
71753
72066
  * request. What refuses a close is navi's: a control inside still
71754
72067
  * running its action (see popup_busy.js for the runs that let it through).
72068
+ * `event.detail.isCancel` says the close meant "revert", and
72069
+ * `event.detail.isConfirm` that a `--navi-confirm` said yes.
71755
72070
  * @param {boolean|string|{id?: string, type?: "push"|"replace"}} [props.navState] -
71756
72071
  * Keeps the open state in the history entry, so a screen left and come back
71757
72072
  * to finds this popup as it was — open, and without an entrance playing: it
@@ -71990,6 +72305,7 @@ const UncontrolledDialog = props => {
71990
72305
  onnavi_request_close: e => {
71991
72306
  const closing = openController.requestClose(e, {
71992
72307
  isCancel: e.detail?.isCancel,
72308
+ isConfirm: e.detail?.isConfirm,
71993
72309
  requester: e.detail?.source
71994
72310
  });
71995
72311
  if (!closing) {
@@ -73838,6 +74154,8 @@ const css$B = /* css */`
73838
74154
  * actually closes — not preventable: it is the close happening, not a
73839
74155
  * request. What refuses a close is navi's: a control inside still
73840
74156
  * running its action (see popup_busy.js for the runs that let it through).
74157
+ * `event.detail.isCancel` says the close meant "revert", and
74158
+ * `event.detail.isConfirm` that a `--navi-confirm` said yes.
73841
74159
  * @param {boolean|string|{id?: string, type?: "push"|"replace"}} [props.navState] -
73842
74160
  * Keeps the open state in the history entry, so a screen left and come back
73843
74161
  * to finds this popup as it was — open, and without an entrance playing: it
@@ -73959,7 +74277,8 @@ const UncontrolledPopover = props => {
73959
74277
  },
73960
74278
  onnavi_request_close: e => {
73961
74279
  const closing = openController.requestClose(e, {
73962
- isCancel: e.detail?.isCancel
74280
+ isCancel: e.detail?.isCancel,
74281
+ isConfirm: e.detail?.isConfirm
73963
74282
  });
73964
74283
  if (!closing) {
73965
74284
  // Said back to whoever asked: --navi-close:all stops climbing here.
@@ -76054,6 +76373,7 @@ const PickerCustom = props => {
76054
76373
  allowed: () => {
76055
76374
  const closing = requestClose(e, {
76056
76375
  isCancel: e.detail.isCancel,
76376
+ isConfirm: e.detail.isConfirm,
76057
76377
  isSend: e.detail.isSend
76058
76378
  });
76059
76379
  if (!closing) {
@@ -79108,6 +79428,11 @@ const css$v = /* css */`@property --list-group-label-height {
79108
79428
  --list-item-background-color-highlight: #ffe066;
79109
79429
  display: inline-block;
79110
79430
  }
79431
+
79432
+ .navi_list_item_group[data-label-in-flow] > .navi_list_item_group_label {
79433
+ background-color: #0000;
79434
+ display: block;
79435
+ }
79111
79436
  }
79112
79437
 
79113
79438
  .navi_list_item_group_label {
@@ -79455,16 +79780,7 @@ const css$v = /* css */`@property --list-group-label-height {
79455
79780
  min-width: 100%;
79456
79781
 
79457
79782
  & .navi_list_item_group_label {
79458
- top: var(--list-group-label-top, var(--x-list-group-label-top, 0px));
79459
- z-index: var(--list-group-label-z-index);
79460
- background-color: var(--list-group-label-background-color);
79461
79783
  user-select: none;
79462
- display: block;
79463
- position: sticky;
79464
-
79465
- &[navi-stuck] {
79466
- z-index: var(--list-group-label-z-index-stuck);
79467
- }
79468
79784
 
79469
79785
  &[navi-default] {
79470
79786
  color: light-dark(#888, #aaa);
@@ -79480,6 +79796,18 @@ const css$v = /* css */`@property --list-group-label-height {
79480
79796
  }
79481
79797
  }
79482
79798
 
79799
+ &:not([data-label-in-flow]) > .navi_list_item_group_label {
79800
+ top: var(--list-group-label-top, var(--x-list-group-label-top, 0px));
79801
+ z-index: var(--list-group-label-z-index);
79802
+ background-color: var(--list-group-label-background-color);
79803
+ display: block;
79804
+ position: sticky;
79805
+
79806
+ &[navi-stuck] {
79807
+ z-index: var(--list-group-label-z-index-stuck);
79808
+ }
79809
+ }
79810
+
79483
79811
  & .navi_list_item_group_list {
79484
79812
  --list-group-label-height: inherit;
79485
79813
  flex-direction: column;
@@ -79498,6 +79826,20 @@ const css$v = /* css */`@property --list-group-label-height {
79498
79826
  }
79499
79827
  }
79500
79828
 
79829
+ &[data-label-in-flow] {
79830
+ & .navi_list_item_group_list .navi_list_item {
79831
+ scroll-margin-top: var(--x-list-scroll-spacing-top);
79832
+ }
79833
+
79834
+ & .navi_list_item_group_list > .navi_list_item:first-child {
79835
+ scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height));
79836
+ }
79837
+
79838
+ &[data-continued] > .navi_list_item_group_label {
79839
+ visibility: hidden;
79840
+ }
79841
+ }
79842
+
79501
79843
  &[data-hidden-while-empty]:not(:has([navi-list-item-real])) {
79502
79844
  display: none;
79503
79845
  }
@@ -80309,6 +80651,15 @@ const useListScrollSync = ({
80309
80651
  // partly in view. After it, the quarter a window keeps behind the screen
80310
80652
  // (see evaluateWindow), until the screen sizes it around that item.
80311
80653
  const itemsAboveOpening = (openAt, windowSize) => {
80654
+ // An item placed in the middle of the view, or at its end, has the items
80655
+ // before it on screen from the first paint.
80656
+ const align = openAt && typeof openAt === "object" ? openAt.align : undefined;
80657
+ if (align === "center") {
80658
+ return Math.floor(windowSize / 2);
80659
+ }
80660
+ if (align === "end") {
80661
+ return windowSize - 1;
80662
+ }
80312
80663
  if (stageRef.current !== "steady") {
80313
80664
  return openAt && typeof openAt === "object" && openAt.offset > 0 ? 1 : 0;
80314
80665
  }
@@ -80862,22 +81213,30 @@ const useListScrollSync = ({
80862
81213
  return;
80863
81214
  }
80864
81215
  if (typeof openAt === "object" && openAt.id !== undefined) {
80865
- placeItem(findItemElement(getListEl(), openAt.id), openAt.offset || 0);
81216
+ placeItem(findItemElement(getListEl(), openAt.id), openAt.offset || 0, openAt.align);
80866
81217
  }
80867
81218
  };
80868
81219
  // An item is placed by measuring it where it is drawn — named, or at the
80869
81220
  // place a number says — so it lands where it should whatever the items
80870
81221
  // above it weigh, and wherever the list starts in the box that scrolls it.
80871
81222
  // The window frames it from the first commit on (see holdWindow).
80872
- const placeItem = (itemEl, offset) => {
81223
+ const placeItem = (itemEl, offset, align = "start") => {
80873
81224
  if (!itemEl) {
80874
81225
  return;
80875
81226
  }
80876
81227
  const scrollerEl = getScroller();
81228
+ // Measured in the scroller's own pixels, the ones a scroll is written in.
81229
+ const scale = readScrollerScale(scrollerEl, horizontal);
80877
81230
  const viewportRect = getScrollerViewportRect(scrollerEl);
80878
81231
  const itemRect = itemEl.getBoundingClientRect();
80879
- const offsetWanted = resolveOpenOffset(getItemScrollInset(scrollerEl, itemEl, horizontal) + offset, horizontal ? viewportRect.width : viewportRect.height, horizontal ? itemRect.width : itemRect.height);
80880
- const offsetNow = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
81232
+ const viewportSize = (horizontal ? viewportRect.width : viewportRect.height) / scale;
81233
+ const itemSize = (horizontal ? itemRect.width : itemRect.height) / scale;
81234
+ const scrollInset = getItemScrollInset(scrollerEl, itemEl, horizontal);
81235
+ // Centred in what the room at the start leaves of the view (a fixed bar,
81236
+ // a sticky header), not in the whole of it.
81237
+ const alignedOffset = align === "center" ? scrollInset + (viewportSize - scrollInset - itemSize) / 2 : align === "end" ? viewportSize - itemSize : scrollInset;
81238
+ const offsetWanted = resolveOpenOffset(alignedOffset + offset, viewportSize, itemSize);
81239
+ const offsetNow = (horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top) / scale;
80881
81240
  const scrollNow = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
80882
81241
  const delta = offsetNow - offsetWanted;
80883
81242
  if (delta > -0.5 && delta < 0.5) {
@@ -81740,6 +82099,25 @@ const useListScrollSync = ({
81740
82099
  // The band of the scroller the user actually sees. A page-level scroller is
81741
82100
  // the viewport itself — its own box is the whole document, which says nothing
81742
82101
  // about what is on screen.
82102
+ // A scroller drawn under a transform (a popup scaling in from its trigger)
82103
+ // shows a rect of another size than the box it scrolls: the ratio of the two
82104
+ // turns what the screen measures into the scroller's own pixels. A rounding
82105
+ // of the box's size is not a scale.
82106
+ const readScrollerScale = (scrollerEl, horizontal) => {
82107
+ if (scrollerEl === document.scrollingElement) {
82108
+ return 1;
82109
+ }
82110
+ const size = horizontal ? scrollerEl.offsetWidth : scrollerEl.offsetHeight;
82111
+ if (!size) {
82112
+ return 1;
82113
+ }
82114
+ const rect = scrollerEl.getBoundingClientRect();
82115
+ const scale = (horizontal ? rect.width : rect.height) / size;
82116
+ if (!scale || scale > 0.99 && scale < 1.01) {
82117
+ return 1;
82118
+ }
82119
+ return scale;
82120
+ };
81743
82121
  const getScrollerViewportRect = scrollerEl => {
81744
82122
  if (scrollerEl === document.scrollingElement) {
81745
82123
  const width = document.documentElement.clientWidth;
@@ -81853,7 +82231,7 @@ const canScrollerScroll = (scrollerEl, axis) => {
81853
82231
  // horizontally on mobile, where `overflow-x: clip` keeps that guarantee without
81854
82232
  // making a scroll container, or to let a wide table scroll sideways, where the
81855
82233
  // header cannot stick to both boxes.
81856
- const STICKY_LIST_PART_SELECTOR = `.navi_list_item_header, .navi_list_item_footer, .navi_list_item_group_label`;
82234
+ 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`;
81857
82235
  const useStickyScrollportWarning = (ref, scroller, getScroller, horizontal) => {
81858
82236
  const doneRef = useRef(false);
81859
82237
  useLayoutEffect(() => {
@@ -83964,6 +84342,7 @@ const VISIBILITY_HIDDEN_STYLE = {
83964
84342
  * groupBy?: (item: any, index: number) => any,
83965
84343
  * renderGroupLabel?: (item: any, index: number) => import("preact").ComponentChildren,
83966
84344
  * groupLabelProps?: (item: any, index: number) => object,
84345
+ * groupLabelSticky?: boolean,
83967
84346
  * pageSize?: number,
83968
84347
  * memoryBudget?: number,
83969
84348
  * renderSkeleton?: false | ((index: number) => import("preact").ComponentChildren),
@@ -84028,6 +84407,12 @@ const VISIBILITY_HIDDEN_STYLE = {
84028
84407
  * its group (a day behind us, today, one ahead) rather than just naming it:
84029
84408
  * the state then sits on the element the CSS styles, instead of being read
84030
84409
  * back from a child.
84410
+ * @param {boolean} [props.groupLabelSticky=true]
84411
+ * `false`: the label stays in the flow, a boundary scrolled past with its
84412
+ * group instead of riding the top of the screen — a thread of days whose
84413
+ * cards must not slide under a band, a "past" line in a list the page
84414
+ * scrolls. It paints nothing of its own then: its display and background
84415
+ * are defaults a `groupLabelProps` class takes back.
84031
84416
  * @param {number} [props.pageSize=100]
84032
84417
  * How many items to ask for at a time. A turn of the wheel opens a hole three
84033
84418
  * items wide; asking for exactly that would ask again at the next turn.
@@ -84212,6 +84597,7 @@ const useRunItems = (store, {
84212
84597
  groupBy,
84213
84598
  renderGroupLabel,
84214
84599
  groupLabelProps,
84600
+ groupLabelSticky = true,
84215
84601
  renderSkeleton,
84216
84602
  renderError
84217
84603
  }) => {
@@ -84471,6 +84857,7 @@ const useRunItems = (store, {
84471
84857
  nodes.push(jsx(ListItemGroup, {
84472
84858
  label: group.label,
84473
84859
  labelProps: group.labelProps,
84860
+ labelSticky: groupLabelSticky,
84474
84861
  continued: group.continued,
84475
84862
  children: group.children
84476
84863
  }, groupCount === 1 ? `${ownerId}_group_${group.key}` : `${ownerId}_group_${group.key}_${groupCount}`));
@@ -85475,6 +85862,7 @@ const useItemStore = ({
85475
85862
  const ListItemGroup = ({
85476
85863
  label,
85477
85864
  labelProps,
85865
+ labelSticky = true,
85478
85866
  hiddenWhileEmpty,
85479
85867
  continued,
85480
85868
  children,
@@ -85520,6 +85908,7 @@ const ListItemGroup = ({
85520
85908
  role: "presentation",
85521
85909
  "data-hidden-while-empty": hiddenWhileEmpty ? "" : undefined,
85522
85910
  "data-continued": continued,
85911
+ "data-label-in-flow": labelSticky ? undefined : "",
85523
85912
  children: [jsx("span", {
85524
85913
  ...labelRest,
85525
85914
  ref: labelRef,
@@ -85587,8 +85976,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
85587
85976
  * renderBudget?: number | string | {initial?: number | string, after?: number | string},
85588
85977
  * virtualItemSize?: number,
85589
85978
  * onListVisibleItemsChange?: (visibleItems: any[]) => void,
85590
- * scrolled?: "start" | "end" | number | {id: string, offset?: number},
85591
- * defaultScrolled?: "start" | "end" | number | {id: string, offset?: number},
85979
+ * scrolled?: "start" | "end" | number | {id: string, offset?: number, align?: "start" | "center" | "end"},
85980
+ * defaultScrolled?: "start" | "end" | number | {id: string, offset?: number, align?: "start" | "center" | "end"},
85592
85981
  * onScrolledChange?: (scrolled: {id: string, index: number, offset: number}) => void,
85593
85982
  * scroller?: "self" | "parent" | "document" | Element | {current: Element},
85594
85983
  * hoverWhileScrolling?: boolean,
@@ -85692,7 +86081,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
85692
86081
  * are skeletons, the others hold their room. `0` says the list is already
85693
86082
  * known to be empty: the empty `fallback` shows right away rather than an
85694
86083
  * empty frame.
85695
- * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.defaultScrolled="start"]
86084
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, align?: "start"|"center"|"end", visibleCount?: number}} [props.defaultScrolled="start"]
85696
86085
  * Where the list opens, after which the user owns the scroll — unless it is
85697
86086
  * being come back to (see `scrollResetOnNavigation`). `"end"` is a
85698
86087
  * thread read backwards — the last items are the ones to show, and the ones
@@ -85707,10 +86096,13 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
85707
86096
  * room has to be restated as a number by whoever asks. The `index` and
85708
86097
  * `visibleCount` it also hands out say where to aim before the item is found,
85709
86098
  * and how many items to draw before the first paint (see `renderBudget`).
86099
+ * `align: "center"` puts the item in the middle of the view instead (`"end"`:
86100
+ * at its far edge) — the chosen level of a horizontal scale opening on it —
86101
+ * and keeps it there while the box around it grows, until the user scrolls.
85710
86102
  * A value that changes before anyone has moved the list is followed (a place
85711
86103
  * read from a count that answered again): it is compared by value, so it can
85712
86104
  * be computed at every render.
85713
- * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.scrolled]
86105
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, align?: "start"|"center"|"end", visibleCount?: number}} [props.scrolled]
85714
86106
  * The same, but held: the list goes back there every time this changes, even
85715
86107
  * after the user has scrolled — the caller owns where the list is (see
85716
86108
  * `defaultScrolled` for the uncontrolled form, and `open`/`defaultOpen`
@@ -87212,6 +87604,7 @@ const css$r = /* css */`@layer navi {
87212
87604
  --picker-font-size: var(--navi-control-font-size);
87213
87605
  --picker-font-family: var(--navi-control-font-family, inherit);
87214
87606
  --picker-loader-color: var(--navi-loader-color);
87607
+ --picker-cta-background-color: var(--navi-accent-color);
87215
87608
  --picker-border-color: var(--navi-control-border-color);
87216
87609
  --picker-background-color: white;
87217
87610
  --picker-color: currentColor;
@@ -87310,7 +87703,7 @@ const css$r = /* css */`@layer navi {
87310
87703
  line-height: inherit;
87311
87704
  }
87312
87705
 
87313
- .navi_picker[data-variant="button"] {
87706
+ .navi_picker:is([data-variant="button"], [data-variant="border"], [data-variant="discrete-border"]) {
87314
87707
  --picker-padding-x-default: var(--navi-button-padding-x-default);
87315
87708
  --picker-padding-y-default: var(--navi-button-padding-y-default);
87316
87709
  --picker-align-x-default: center;
@@ -87328,6 +87721,49 @@ const css$r = /* css */`@layer navi {
87328
87721
  --picker-text-align-default: center;
87329
87722
  }
87330
87723
 
87724
+ .navi_picker[data-variant="border"] {
87725
+ --picker-background-color: transparent;
87726
+ --picker-background-color-hover: color-mix(in srgb,
87727
+ currentColor 8%,
87728
+ var(--picker-background-color));
87729
+ --picker-background-color-readonly: var(--picker-background-color);
87730
+ --picker-background-color-disabled: var(--picker-background-color);
87731
+ }
87732
+
87733
+ .navi_picker[data-variant="discrete-border"] {
87734
+ --picker-background-color: transparent;
87735
+ --picker-background-color-hover: var(--picker-background-color);
87736
+ --picker-background-color-readonly: var(--picker-background-color);
87737
+ --picker-background-color-disabled: var(--picker-background-color);
87738
+ }
87739
+
87740
+ .navi_picker[data-cta] {
87741
+ --picker-background-color: var(--picker-cta-background-color);
87742
+ --picker-border-color: var(--picker-cta-background-color);
87743
+ --picker-color: white;
87744
+ --picker-background-color-hover: color-mix(in srgb,
87745
+ var(--picker-background-color) 85%,
87746
+ white);
87747
+ --picker-border-color-hover: color-mix(in srgb,
87748
+ var(--picker-border-color) 85%,
87749
+ white);
87750
+ --picker-background-color-readonly: color-mix(in srgb,
87751
+ var(--picker-background-color) 50%,
87752
+ white);
87753
+ --picker-border-color-readonly: color-mix(in srgb,
87754
+ var(--picker-border-color) 50%,
87755
+ white);
87756
+ --picker-background-color-disabled: color-mix(in srgb,
87757
+ var(--picker-background-color) 40%,
87758
+ white);
87759
+ --picker-border-color-disabled: color-mix(in srgb,
87760
+ var(--picker-border-color) 40%,
87761
+ white);
87762
+ --picker-color-disabled: color-mix(in srgb,
87763
+ var(--picker-color) 60%,
87764
+ transparent);
87765
+ }
87766
+
87331
87767
  .navi_picker[data-variant="text"] {
87332
87768
  --picker-padding-x-default: 0;
87333
87769
  --picker-padding-y-default: 0;
@@ -87608,6 +88044,18 @@ const css$r = /* css */`@layer navi {
87608
88044
  --x-picker-cursor: default;
87609
88045
  }
87610
88046
 
88047
+ &[data-variant="discrete-border"] {
88048
+ --x-picker-border-color: transparent;
88049
+
88050
+ &[data-hover] {
88051
+ --x-picker-border-color: var(--picker-border-color-hover);
88052
+ }
88053
+
88054
+ &[data-readonly], &[data-disabled] {
88055
+ --x-picker-border-color: transparent;
88056
+ }
88057
+ }
88058
+
87611
88059
  &[data-callout] {
87612
88060
  --x-picker-border-color: var(--callout-color);
87613
88061
  }
@@ -87681,6 +88129,7 @@ const PickerButton = props => {
87681
88129
  import.meta.css = [css$r, "@jsenv/navi/src/control/picker/picker.jsx"];
87682
88130
  warnOnUnknownPickerType(props);
87683
88131
  warnOnUIDrawnByNobody(props);
88132
+ warnOnMaxLinesNotClamped(props);
87684
88133
  if (typeof props.maxLines === "string") {
87685
88134
  props.maxLines = parseInt(props.maxLines);
87686
88135
  }
@@ -87690,6 +88139,8 @@ const PickerButton = props => {
87690
88139
  // The trigger is one icon — its box is that icon's, the way <Button icon>
87691
88140
  // means it. A shape, so it composes with the variant painting around it.
87692
88141
  icon,
88142
+ // A call to action, filled with the accent: what `cta` is on a Button.
88143
+ cta,
87693
88144
  rightSlotIcon,
87694
88145
  rightSlotIconSize = "inherit",
87695
88146
  // What goes in the right slot as-is — no <Icon> around it, so a caller can
@@ -87768,7 +88219,9 @@ const PickerButton = props => {
87768
88219
  const isBare = variant === "bare";
87769
88220
  const bareChevron = isBare && chevron !== undefined ? chevron : undefined;
87770
88221
  warnOnChevronOffBare(props, isBare);
87771
- const hasRightSlot = !picksNothing && rightSlot !== null && !isIcon && variant !== "headless" && variant !== "button" && variant !== "text" && (!isBare || bareChevron !== undefined) && ui !== "default";
88222
+ // The Button's own looks: a label, no slot after it.
88223
+ const drawnAsButton = variant === "button" || variant === "border" || variant === "discrete-border";
88224
+ const hasRightSlot = !picksNothing && rightSlot !== null && !isIcon && variant !== "headless" && !drawnAsButton && variant !== "text" && (!isBare || bareChevron !== undefined) && ui !== "default";
87772
88225
  warnOnClearableWithoutSlot(props, hasRightSlot);
87773
88226
  // How far the press must follow a hanging chevron: its size, as a length the
87774
88227
  // input's inset can add up. "inherit" is 1em of the picker's own font, which
@@ -87867,6 +88320,7 @@ const PickerButton = props => {
87867
88320
  baseClassName: "navi_picker",
87868
88321
  pseudoClasses: PICKER_BUTTON_PSEUDO_CLASSES,
87869
88322
  "data-variant": variant,
88323
+ "data-cta": cta ? "" : undefined,
87870
88324
  "data-icon": isIcon ? "" : undefined,
87871
88325
  "data-chevron": hasRightSlot ? bareChevron : undefined,
87872
88326
  "navi-picker": "",
@@ -87877,6 +88331,7 @@ const PickerButton = props => {
87877
88331
  basePseudoState: basePseudoState,
87878
88332
  styleCSSVars: PickerStyleCSSVars,
87879
88333
  variant: undefined,
88334
+ cta: undefined,
87880
88335
  icon: undefined
87881
88336
  // The caller's own role goes onto the host, beside the name (see
87882
88337
  // control_context.js): a role and a name on two different elements
@@ -88035,7 +88490,7 @@ const PickerButton = props => {
88035
88490
  // caller is drawing there ("no filter", "anywhere"), so how it
88036
88491
  // looks empty stays theirs (documented on the ui prop below).
88037
88492
  ,
88038
- "navi-placeholder": (ui === undefined || pickerUIIsNaviOwn(ui)) && variant !== "button" && variant !== "text" && uiStateHoldsNothing(value) ? "" : undefined,
88493
+ "navi-placeholder": (ui === undefined || pickerUIIsNaviOwn(ui)) && !drawnAsButton && variant !== "text" && uiStateHoldsNothing(value) ? "" : undefined,
88039
88494
  maxLines: maxLines,
88040
88495
  children: jsx(PickerOwnContent, {
88041
88496
  children: jsx(MaxLinesContext.Provider, {
@@ -88489,6 +88944,24 @@ const warnOnClearableWithoutSlot = (props, hasRightSlot) => {
88489
88944
  }
88490
88945
  console.warn(`[navi] <Picker ${cause} clearable> — that leaves no right slot, so no cross is drawn. ` + `Draw it where it belongs instead: <Picker ${cause} ui={<YourDrawing>… <Picker.Clear /></YourDrawing>}>. ` + `It is the same cross, and it takes itself away when there is nothing left to clear.`);
88491
88946
  };
88947
+
88948
+ // A text or bare picker clamps nothing (see where maxLines is resolved), so a
88949
+ // `maxLines` written on one is dropped without a word.
88950
+ const maxLinesNotClampedWarnedSet = new Set();
88951
+ const warnOnMaxLinesNotClamped = props => {
88952
+ const {
88953
+ maxLines,
88954
+ variant
88955
+ } = props;
88956
+ if (maxLines === undefined || variant !== "text" && variant !== "bare") {
88957
+ return;
88958
+ }
88959
+ if (maxLinesNotClampedWarnedSet.has(variant)) {
88960
+ return;
88961
+ }
88962
+ maxLinesNotClampedWarnedSet.add(variant);
88963
+ console.warn(`[navi] <Picker variant="${variant}" maxLines={${maxLines}}> — a ${variant} picker clamps nothing: its drawing is the caller's own. ` + `Put maxLines on the Text in its ui: <Picker variant="${variant}" ui={<Text maxLines={${maxLines}}>…</Text>}>.`);
88964
+ };
88492
88965
  let chevronOffBareWarned = false;
88493
88966
  const warnOnChevronOffBare = (props, isBare) => {
88494
88967
  const {
@@ -88589,7 +89062,8 @@ const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
88589
89062
  * popupWidthFitContent?: boolean,
88590
89063
  * loadingOutline?: "custom",
88591
89064
  * loadingOutlineInset?: number,
88592
- * variant?: "icon" | "circle" | "headless" | "discrete" | "button" | "text" | "bare" | "picker",
89065
+ * variant?: "icon" | "circle" | "headless" | "discrete" | "button" | "border" | "discrete-border" | "text" | "bare" | "picker",
89066
+ * cta?: boolean,
88593
89067
  * icon?: boolean,
88594
89068
  * alignX?: "start" | "center" | "end",
88595
89069
  * alignY?: "start" | "center" | "end" | "stretch",
@@ -88854,10 +89328,13 @@ const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
88854
89328
  * receives the cleared value like any other choice. A cut (Ctrl+X) on such a
88855
89329
  * picker copies the value and leaves it in place: no clear without the
88856
89330
  * question.
88857
- * @param {"icon"|"headless"|"discrete"|"button"|"text"|"bare"|"picker"} [variant]
89331
+ * @param {"icon"|"headless"|"discrete"|"button"|"border"|"discrete-border"|"text"|"bare"|"picker"} [variant]
88858
89332
  * How the trigger is drawn. `"button"` is a Button's drawing — surface,
88859
89333
  * padding, a centered label, no chevron — and what a `type="confirm"` picker
88860
89334
  * draws unless told otherwise: nothing is picked, the popup is a question.
89335
+ * `"border"` and `"discrete-border"` are the Button's two other looks, from
89336
+ * its own formulas: a picker swapped in for a Button that opened a popup
89337
+ * looks the same at rest, on hover and read-only.
88861
89338
  * `"text"` is a word in a sentence, underlined with dots, no box and no
88862
89339
  * slot: what `mode="callout"` uses for a callout on a term rather than on an
88863
89340
  * icon (`"icon"` being its default, with an info icon in place of the
@@ -88881,6 +89358,9 @@ const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
88881
89358
  * nearest positioned ancestor it happens to find. `"picker"` (or an explicit
88882
89359
  * `variant={undefined}`) asks a confirm or callout picker for the field-like
88883
89360
  * drawing every other picker has.
89361
+ * @param {boolean} [cta] A call to action: filled with the accent, its border
89362
+ * matching, as `cta` draws a Button — for a confirm picker that is a row's
89363
+ * main action.
88884
89364
  * @param {"custom"} [loadingOutline] Who draws the loading outline while the
88885
89365
  * action runs. Left unsaid, the picker does, around its own box. `"custom"`
88886
89366
  * says someone else does — what it means on a `Button` too — for a picker
@@ -89390,8 +89870,13 @@ const TRAVELS_NONE = [];
89390
89870
  * vertical?: boolean,
89391
89871
  * previousLabel?: string,
89392
89872
  * nextLabel?: string,
89873
+ * previousTestId?: string,
89874
+ * nextTestId?: string,
89393
89875
  * [key: string]: any,
89394
89876
  * }>}
89877
+ * @param {string} [previousTestId] The `data-testid` of the way out to the
89878
+ * previous value, which navi builds and the caller has no other way to name.
89879
+ * `nextTestId` is its twin.
89395
89880
  * @param {any} [value] The value shown. Held from above: `uiAction` says when
89396
89881
  * it should move. Say `signal` for a two-way binding instead, or
89397
89882
  * `defaultValue` to let the spin hold the value itself.
@@ -89473,6 +89958,8 @@ const Spin = ({
89473
89958
  maxLines,
89474
89959
  previousLabel,
89475
89960
  nextLabel,
89961
+ previousTestId,
89962
+ nextTestId,
89476
89963
  ...rest
89477
89964
  }) => {
89478
89965
  import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
@@ -89784,6 +90271,7 @@ const Spin = ({
89784
90271
  unavailable: !allowed || loadingResolved || readOnlyResolved,
89785
90272
  unavailableMessage: () => wayOutMessage(allowed, isNext),
89786
90273
  label: isNext ? nextLabel ?? spinI18n("spin.next") : previousLabel ?? spinI18n("spin.previous"),
90274
+ testId: isNext ? nextTestId : previousTestId,
89787
90275
  onPress: e => {
89788
90276
  focusMiddle();
89789
90277
  if (editable) {
@@ -90014,6 +90502,7 @@ const WayOut = ({
90014
90502
  unavailable,
90015
90503
  unavailableMessage,
90016
90504
  label,
90505
+ testId,
90017
90506
  onPress,
90018
90507
  onPointerDown,
90019
90508
  children
@@ -90052,6 +90541,7 @@ const WayOut = ({
90052
90541
  ,
90053
90542
  role: "button",
90054
90543
  "aria-label": label,
90544
+ "data-testid": testId,
90055
90545
  "aria-disabled": unavailable ? "true" : undefined,
90056
90546
  "data-unavailable": unavailable ? "" : undefined
90057
90547
  // At the chevron, not beside it: a callout aims its arrow at where the
@@ -99012,6 +99502,7 @@ const css$b = /* css */`@layer navi {
99012
99502
  `;
99013
99503
  const ButtonCopyToClipboard = ({
99014
99504
  children,
99505
+ "aria-label": ariaLabel = buttonI18n("button.copy"),
99015
99506
  ...props
99016
99507
  }) => {
99017
99508
  import.meta.css = [css$b, "@jsenv/navi/src/control/input/button_copy_to_clipboard.jsx"];
@@ -99028,11 +99519,15 @@ const ButtonCopyToClipboard = ({
99028
99519
  ...props,
99029
99520
  children: [jsx(Box, {
99030
99521
  className: "navi_copied_notif",
99031
- "aria-hidden": copied ? "false" : "true",
99522
+ role: "status",
99032
99523
  opacity: copied ? 1 : 0,
99033
- children: "Copié !"
99524
+ children: copied ? buttonI18n("button.copied") : null
99034
99525
  }), jsx(Button, {
99035
- className: "navi_copy_button",
99526
+ className: "navi_copy_button"
99527
+ // The props land on the container; the name belongs to the button,
99528
+ // which shows only an icon.
99529
+ ,
99530
+ "aria-label": ariaLabel,
99036
99531
  icon: true,
99037
99532
  revealOnInteraction: true,
99038
99533
  square: true,