@jsenv/navi 0.29.388 → 0.29.390

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.
@@ -527,7 +527,10 @@ effect(() => {
527
527
  * The active language is read from `languagesSignal` (see lang_signal.js —
528
528
  * combines the browser's own `navigator.languages`, an optional
529
529
  * `setPreferredLanguage()` user override, and an optional
530
- * `setSupportedLanguages()` app-wide allow-list), live on every lookup.
530
+ * `setSupportedLanguages()` app-wide allow-list), live on every lookup. Each
531
+ * key is looked up in those languages in order, then in English, which
532
+ * translates every key: a language shipped for part of the keys only (German
533
+ * has the `time.*` words) never shows a raw key.
531
534
  *
532
535
  * Built-in key namespaces, all overridable — the registrations below are the
533
536
  * exhaustive list, read them to find the exact key to override:
@@ -1447,19 +1450,25 @@ const isNetworkPolicyError = (error) => {
1447
1450
  * is already saying it in words the person understands.
1448
1451
  *
1449
1452
  * navi's own report leaves it alone (action_error_report.js). What is left is
1450
- * the screen displaying it: it does so by throwing the error to a boundary, and
1451
- * `preact/debug` re-emits on `window` every error a boundary caught — on
1452
- * purpose, for React devtools compatibility. Uncancelled, that lands as an
1453
- * uncaught error in the console, over a page calmly explaining why nothing was
1454
- * asked. Cancelling the event is what says it is handled: the browser drops
1453
+ * the error reaching `window` on its own — a run called by hand whose failure
1454
+ * nobody catches, a render throw no boundary took — where it lands as an
1455
+ * uncaught error in the console, about a request the app itself declared would
1456
+ * not leave. Cancelling the event is what says it is handled: the browser drops
1455
1457
  * the console line, and the jsenv supervisor skips prevented events too.
1456
1458
  *
1457
1459
  * Both shapes a failure travels in are covered, since which one it is depends
1458
1460
  * on whether the resource callback happened to be async — the throw reaching
1459
1461
  * `window`, and the rejection nobody caught.
1460
1462
  *
1461
- * Scoped to the policy's own error and to nothing else — every other error a
1462
- * boundary caught, and every other rejection let go, stays exactly as loud as
1463
+ * A boundary displaying it raises no event at all. In dev, `preact/debug` hands
1464
+ * every error a boundary caught to `console.error`, after the boundary took it
1465
+ * and before it rendered anything; it reads no option and no flag, so that line
1466
+ * stays. Muting it from here is not done: wrapping `console.error` rewrites a
1467
+ * global, and wrapping `options._catchError` reaches into preact's private
1468
+ * hooks, both to hide one class of error.
1469
+ *
1470
+ * Scoped to the policy's own error and to nothing else — every other error
1471
+ * thrown at window, and every other rejection let go, stays exactly as loud as
1463
1472
  * it is.
1464
1473
  */
1465
1474
  let networkPolicyErrorSilenced = false;
@@ -1620,8 +1629,8 @@ const markErrorAsStoppingRender = (error) => {
1620
1629
  * An error the network policy produced is never reported: the app declared the
1621
1630
  * state that produced it, the request never left, and there is nothing for a
1622
1631
  * developer to fix. It is data a screen shows, and it stays data whether or not
1623
- * one does (see network_policy.js, which also keeps it out of the browser
1624
- * console).
1632
+ * one does (see network_policy.js, which also cancels it when it reaches
1633
+ * window).
1625
1634
  */
1626
1635
  const errorIsAccountedFor = (error) => {
1627
1636
  return (
@@ -4785,14 +4794,11 @@ const CONTROL_TYPE_BY_SIGNAL_TYPE = {
4785
4794
  // become something plainer — it is what says the value is a number (see
4786
4795
  // isNumberInput), and what lets constraint messages use domain-specific
4787
4796
  // wording instead of the generic "Ce nombre doit être...".
4797
+ // A type a picker preset draws (navi_time, navi_minute: see picker_preset.jsx)
4798
+ // has no row: resolveInputProps runs before the preset resolver, and a row
4799
+ // would turn the type into its host before the preset resolver reads it. The
4800
+ // preset hands the host type on itself.
4788
4801
  const NAVI_TYPE_DEFAULTS = {
4789
- navi_time: {
4790
- "type": "time",
4791
- "navi-input-type": "time",
4792
- "min": 0,
4793
- "max": 24 * 3600 - 1,
4794
- "step": 1,
4795
- },
4796
4802
  navi_percentage: {
4797
4803
  "type": "navi_number",
4798
4804
  "navi-input-type": "percentage",
@@ -4854,7 +4860,6 @@ const isNumberInput = (type, naviInputType) => {
4854
4860
  * Supported navi types and their targets:
4855
4861
  * - `navi_percentage` → `navi_number` (0–100, step 1)
4856
4862
  * - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
4857
- * - `navi_time` → `time` (step in seconds)
4858
4863
  *
4859
4864
  * Standard HTML input types with formatters:
4860
4865
  * - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
@@ -12162,8 +12167,9 @@ defineInteractionDetector({
12162
12167
  name: "surface",
12163
12168
  claims: (type) =>
12164
12169
  type === PAN || type === ZOOM || type === GRAB || type === RELEASE,
12165
- // A press on the surface may be a tap, a hold or the beginning of a pan, and
12166
- // nothing may read it as the first until the pointer has said which.
12170
+ // A press on the surface may be a tap, a hold or the first finger of a pan or
12171
+ // a pinch, and nothing may read it as the first until the pointer has said
12172
+ // which.
12167
12173
  disputesPress: true,
12168
12174
  setup: (element, trigger, { types, readConfig }) => {
12169
12175
  const canPan = types.includes(PAN);
@@ -15166,21 +15172,33 @@ const getActionResultProperties = (action) => {
15166
15172
  return actionResultPropertiesMap.get(action);
15167
15173
  };
15168
15174
 
15169
- // PUT/PATCH results update the UI through the store, and DELETE resets the GET
15170
- // instead of rerunning it, so a GET rerun would only ever cost a request.
15171
- // A POST is the one case the client cannot decide alone: whether a new item
15172
- // belongs to a list depends on filters/pagination the backend owns.
15173
15175
  // Rationale in full, plus when to override: docs/list_refresh.md
15174
15176
  const defaultRerunOn = {
15177
+ // PUT/PATCH results update the UI through the store, and DELETE resets the
15178
+ // GET rather than rerunning it, so a GET rerun would only ever cost a request.
15175
15179
  GET: false,
15180
+ // A POST is the one case the client cannot decide alone: whether a new item
15181
+ // belongs to a list depends on filters/pagination the backend owns.
15176
15182
  GET_MANY: ["POST"],
15183
+ // DELETE is in there, unlike for GET_MANY: an action holds ids and the store
15184
+ // drops the deleted one out of every list holding it, while a list reading
15185
+ // by slices holds places — the row that left takes the ones after it one
15186
+ // rank up, and only the collection knows who fills the last one.
15187
+ GET_RANGE: ["POST", "DELETE"],
15188
+ };
15189
+ // A key a `rerunOn` leaves out keeps the value it would have had without it:
15190
+ // the default for a resource, the value of the resource it is declared on for
15191
+ // a withParams() scope or a relation.
15192
+ const resolveRerunOn = (rerunOn, inheritedRerunOn = defaultRerunOn) => {
15193
+ if (!rerunOn) {
15194
+ return inheritedRerunOn;
15195
+ }
15196
+ return {
15197
+ GET: rerunOn.GET ?? inheritedRerunOn.GET,
15198
+ GET_MANY: rerunOn.GET_MANY ?? inheritedRerunOn.GET_MANY,
15199
+ GET_RANGE: rerunOn.GET_RANGE ?? inheritedRerunOn.GET_RANGE,
15200
+ };
15177
15201
  };
15178
- // What makes a range reader stale (rerunOn.GET_RANGE overrides it). DELETE is
15179
- // in there, unlike for GET_MANY: an action holds ids and the store drops the
15180
- // deleted one out of every list holding it, while a list reading by slices
15181
- // holds places — the row that left takes the ones after it one rank up, and
15182
- // only the collection knows who fills the last one.
15183
- const defaultInvalidateRangeOn = ["POST", "DELETE"];
15184
15202
 
15185
15203
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
15186
15204
  const createResourceLifecycleManager = () => {
@@ -15190,7 +15208,7 @@ const createResourceLifecycleManager = () => {
15190
15208
 
15191
15209
  const registerResource = (resourceScope, config) => {
15192
15210
  const {
15193
- rerunOn = defaultRerunOn,
15211
+ rerunOn,
15194
15212
  paramScope = null,
15195
15213
  dependencies = [],
15196
15214
  uniqueKeys = [],
@@ -15473,8 +15491,7 @@ const createResourceLifecycleManager = () => {
15473
15491
  if (!isSameResource && !isDependent) {
15474
15492
  continue;
15475
15493
  }
15476
- const invalidateOn = config.rerunOn.GET_RANGE ?? defaultInvalidateRangeOn;
15477
- if (!shouldRerunAfter(invalidateOn, triggerVerb)) {
15494
+ if (!shouldRerunAfter(config.rerunOn.GET_RANGE, triggerVerb)) {
15478
15495
  continue;
15479
15496
  }
15480
15497
  for (const rangeReader of config.rangeReaderSet) {
@@ -15975,7 +15992,7 @@ const resource = (
15975
15992
  addItemSetup,
15976
15993
  createRestAction: createRestActionForRoot,
15977
15994
  paramScope: getParamScope(undefined),
15978
- rerunOn,
15995
+ rerunOn: resolveRerunOn(rerunOn),
15979
15996
  dependencies,
15980
15997
  });
15981
15998
  };
@@ -15993,6 +16010,15 @@ const createResource = (
15993
16010
  paramScope,
15994
16011
  rerunOn,
15995
16012
  dependencies,
16013
+ // Set on a scoped child: the resource owning each of its items, which
16014
+ // live in one store per owner (`store` is the owner's).
16015
+ ownerName,
16016
+ // Set on a scopedMany child: its per-owner store looked up by owner key,
16017
+ // and the owner whose GET embeds the collection. Options rather than
16018
+ // properties added afterwards, so every facade of the relation — the one
16019
+ // scopedMany returns and each withParams() of it — carries both.
16020
+ getChildStore,
16021
+ ownerDependency,
15996
16022
  },
15997
16023
  ) => {
15998
16024
  const params = paramScope.params;
@@ -16018,6 +16044,8 @@ const createResource = (
16018
16044
  // private but exposed for convenience
16019
16045
  store,
16020
16046
  addItemSetup,
16047
+ ownerName,
16048
+ getChildStore,
16021
16049
  };
16022
16050
 
16023
16051
  resourceLifecycleManager.registerResource(stateFacade, {
@@ -16026,6 +16054,17 @@ const createResource = (
16026
16054
  dependencies,
16027
16055
  uniqueKeys,
16028
16056
  });
16057
+ if (ownerDependency) {
16058
+ // A child collection mutated (POST etc.) makes the owner GET re-fetch:
16059
+ // the owner embeds the child array and only the backend knows the new
16060
+ // ordering. Registered per facade, since the rerun is looked up by the
16061
+ // facade whose action completed.
16062
+ resourceLifecycleManager.addDependency(
16063
+ stateFacade,
16064
+ ownerDependency.owner,
16065
+ ownerDependency.propertyName,
16066
+ );
16067
+ }
16029
16068
  const onActionComplete = (actionCompleted) => {
16030
16069
  resourceLifecycleManager.onActionComplete(actionCompleted, {
16031
16070
  resourceScope: stateFacade,
@@ -16038,8 +16077,12 @@ const createResource = (
16038
16077
  * Actions from parameterized resources only trigger rerun/reset for other actions with
16039
16078
  * identical parameters, preventing cross-contamination between different parameter sets.
16040
16079
  *
16080
+ * The actions are the resource's own kind: on a relation they keep its callback return
16081
+ * contract and write where it writes (`TABLE_COLUMNS.withParams(…).GET_MANY` returns
16082
+ * `[ownerId, items]` and replaces that owner's collection).
16083
+ *
16041
16084
  * @param {Object} params - Parameters to bind to all actions of this resource (required)
16042
- * @param {{ rerunOn?: Object, dependencies?: Object[] }} [options] - reruns of that scope; left out, the scope inherits the resource's
16085
+ * @param {{ rerunOn?: Object, dependencies?: Object[] }} [options] - reruns of that scope; a `rerunOn` key left out keeps the resource's value, and `dependencies` left out are the resource's
16043
16086
  * @returns {Object} A new resource instance with parameter-bound actions and isolated lifecycle
16044
16087
  * @see docs/resource.md — what a scope isolates, and `dependencies`
16045
16088
  *
@@ -16063,7 +16106,6 @@ const createResource = (
16063
16106
  paramsToInject,
16064
16107
  { dependencies: withParamsDeps, rerunOn: withParamsRerunOn } = {},
16065
16108
  ) => {
16066
- const declarationSite = getDeclarationSite();
16067
16109
  if (!paramsToInject || Object.keys(paramsToInject).length === 0) {
16068
16110
  throw new Error(`resource(${name}).withParams() requires parameters`);
16069
16111
  }
@@ -16071,11 +16113,6 @@ const createResource = (
16071
16113
  ? { ...params, ...paramsToInject }
16072
16114
  : paramsToInject;
16073
16115
  const resolvedParamScope = getParamScope(resolvedParams);
16074
- const createRestActionWithParams = createRestActionFactoryForRoot(name, {
16075
- idKey,
16076
- store,
16077
- declarationSite,
16078
- });
16079
16116
  return createResource(name, {
16080
16117
  idKey,
16081
16118
  uniqueKeys,
@@ -16083,10 +16120,16 @@ const createResource = (
16083
16120
  restCallbacks,
16084
16121
  store,
16085
16122
  addItemSetup,
16086
- createRestAction: createRestActionWithParams,
16123
+ // The actions of this resource's kind: a relation applies its results to
16124
+ // its relation (a scoped child to the per-owner stores), never as root
16125
+ // items of `store`.
16126
+ createRestAction,
16087
16127
  paramScope: resolvedParamScope,
16088
- rerunOn: withParamsRerunOn ?? rerunOn,
16128
+ rerunOn: resolveRerunOn(withParamsRerunOn, rerunOn),
16089
16129
  dependencies: withParamsDeps ?? dependencies,
16130
+ ownerName,
16131
+ getChildStore,
16132
+ ownerDependency,
16090
16133
  });
16091
16134
  };
16092
16135
  stateFacade.withParams = withParams;
@@ -16103,14 +16146,15 @@ const createResource = (
16103
16146
  * Callback return contracts:
16104
16147
  * - GET / PUT → the parent object with the relationship nested inside:
16105
16148
  * `async ({ id }) => ({ id, session: { id: 10, token: "abc" } })`; `null` for no relationship
16106
- * - DELETE → the parent id; the property is set to `null`
16149
+ * - DELETE → the parent id, or `{ id }`; the property is set to `null`
16107
16150
  *
16108
16151
  * The backend may also embed the child inline in a parent GET/POST response — the
16109
16152
  * setter on the property upserts the nested object into the child store.
16110
16153
  *
16111
- * Returns the relationship resource: its actions (`USER_SESSION.GET`, `.PUT`,
16112
- * `.DELETE`) write the parent's property. A relation of the child itself is
16113
- * declared on the child resource (`SESSION.one("device", DEVICE)`).
16154
+ * Returns the relationship resource, itself chainable. Its items are the child
16155
+ * resource's own, shared by every parent: `USER_SESSION.one("device", DEVICE)`
16156
+ * adds a reactive `.device` property to every session, as
16157
+ * `SESSION.one("device", DEVICE)` would.
16114
16158
  *
16115
16159
  * @param {string} propertyName - property holding the child on each parent item
16116
16160
  * @param {Object} childResource - the independent resource created by `resource()`
@@ -16130,6 +16174,13 @@ const createResource = (
16130
16174
  } = {},
16131
16175
  ) => {
16132
16176
  const declarationSite = getDeclarationSite();
16177
+ if (ownerName) {
16178
+ refuseCallbacksNamingScopedItem(
16179
+ `${name}.one("${propertyName}")`,
16180
+ { GET, PUT, DELETE },
16181
+ { name, ownerName, propertyName, declarationSite },
16182
+ );
16183
+ }
16133
16184
  const childName = `${name}.${propertyName}`;
16134
16185
  const childIdKey = childResource.idKey;
16135
16186
  const childStore = childResource.store;
@@ -16190,14 +16241,18 @@ const createResource = (
16190
16241
  );
16191
16242
  });
16192
16243
 
16193
- const createRestActionForOne = (verb, callback, { onActionComplete }) => {
16244
+ const createRestActionForOne = (
16245
+ verb,
16246
+ callback,
16247
+ { onActionComplete, paramScope },
16248
+ ) => {
16194
16249
  const applyResultToValue =
16195
16250
  verb === "DELETE"
16196
- ? (itemId) => {
16197
- const item = store.select(itemId);
16251
+ ? (itemIdOrItemProps) => {
16252
+ const item = store.select(itemIdOrItemProps);
16198
16253
  const childItemId = item[propertyName][childIdKey];
16199
16254
  store.upsert({
16200
- [idKey]: itemId,
16255
+ [idKey]: item[idKey],
16201
16256
  [propertyName]: null,
16202
16257
  });
16203
16258
  return childItemId;
@@ -16225,7 +16280,7 @@ const createResource = (
16225
16280
  if (!isProps(result) && !primitiveCanBeId(result)) {
16226
16281
  throwInvalidResult(
16227
16282
  action.name,
16228
- `an object (that will be used to drop "${name}" resource)`,
16283
+ `the "${name}" id, or { ${idKey} } (the item whose "${propertyName}" becomes null)`,
16229
16284
  result,
16230
16285
  );
16231
16286
  }
@@ -16250,11 +16305,11 @@ const createResource = (
16250
16305
  PUT,
16251
16306
  DELETE,
16252
16307
  },
16253
- store,
16254
- addItemSetup,
16308
+ store: childStore,
16309
+ addItemSetup: childResource.addItemSetup,
16255
16310
  createRestAction: createRestActionForOne,
16256
16311
  paramScope,
16257
- rerunOn: oneRerunOn ?? rerunOn,
16312
+ rerunOn: resolveRerunOn(oneRerunOn, rerunOn),
16258
16313
  dependencies: oneDependencies ?? dependencies,
16259
16314
  });
16260
16315
  };
@@ -16277,6 +16332,9 @@ const createResource = (
16277
16332
  * - DELETE → `[parentId, childId]`
16278
16333
  * - DELETE_MANY → `[parentId, [childId, childId, …]]`
16279
16334
  *
16335
+ * Returns the relationship resource, itself chainable; its items are the child
16336
+ * resource's own, as for `.one()`.
16337
+ *
16280
16338
  * @param {string} propertyName - property holding the child array on each parent item
16281
16339
  * @param {Object} childResource - the independent resource created by `resource()`
16282
16340
  * @param {Object} [restCallbacks] - `{ rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
@@ -16302,6 +16360,13 @@ const createResource = (
16302
16360
  } = {},
16303
16361
  ) => {
16304
16362
  const declarationSite = getDeclarationSite();
16363
+ if (ownerName) {
16364
+ refuseCallbacksNamingScopedItem(
16365
+ `${name}.many("${propertyName}")`,
16366
+ { GET_MANY, DELETE, DELETE_MANY },
16367
+ { name, ownerName, propertyName, declarationSite },
16368
+ );
16369
+ }
16305
16370
  const childStore = childResource.store;
16306
16371
  const childIdKey = childResource.idKey;
16307
16372
  const childName = `${name}.${propertyName}`;
@@ -16333,21 +16398,23 @@ const createResource = (
16333
16398
  const createRestActionForMany = (
16334
16399
  verb,
16335
16400
  callback,
16336
- { isMany, onActionComplete },
16401
+ { isMany, onActionComplete, paramScope },
16337
16402
  ) => {
16338
16403
  if (!isMany) {
16339
16404
  return createRestActionAffectingOneItem(verb, callback, {
16340
16405
  onActionComplete,
16406
+ paramScope,
16341
16407
  });
16342
16408
  }
16343
16409
  return createRestActionAffectingManyItems(verb, callback, {
16344
16410
  onActionComplete,
16411
+ paramScope,
16345
16412
  });
16346
16413
  };
16347
16414
  const createRestActionAffectingOneItem = (
16348
16415
  verb,
16349
16416
  callback,
16350
- { onActionComplete },
16417
+ { onActionComplete, paramScope },
16351
16418
  ) => {
16352
16419
  const applyResultToValue =
16353
16420
  verb === "DELETE"
@@ -16410,7 +16477,7 @@ const createResource = (
16410
16477
  const createRestActionAffectingManyItems = (
16411
16478
  verb,
16412
16479
  callback,
16413
- { onActionComplete },
16480
+ { onActionComplete, paramScope },
16414
16481
  ) => {
16415
16482
  const applyResultToValue =
16416
16483
  verb === "GET"
@@ -16512,11 +16579,11 @@ const createResource = (
16512
16579
  DELETE,
16513
16580
  DELETE_MANY,
16514
16581
  },
16515
- store,
16516
- addItemSetup,
16582
+ store: childStore,
16583
+ addItemSetup: childResource.addItemSetup,
16517
16584
  createRestAction: createRestActionForMany,
16518
16585
  paramScope,
16519
- rerunOn: manyRerunOn ?? rerunOn,
16586
+ rerunOn: resolveRerunOn(manyRerunOn, rerunOn),
16520
16587
  dependencies: manyDependencies ?? dependencies,
16521
16588
  });
16522
16589
  };
@@ -16538,7 +16605,12 @@ const createResource = (
16538
16605
  * Mutations apply directly to the owner's signal, so the parent GET is never rerun.
16539
16606
  *
16540
16607
  * Returns the child relationship resource, itself chainable:
16541
- * `USER_PROFILE.one("theme", THEME)` adds a reactive `.theme` property on each profile.
16608
+ * `USER_PROFILE.one("theme", THEME)` adds a reactive `.theme` property on each profile,
16609
+ * fed by what this resource's callbacks embed (`[id, { bio, theme: { id: 3 } }]`). A
16610
+ * profile exists only inside its user, so the chained relation declares none of the
16611
+ * callbacks whose result names a profile (`.one()` GET/PUT/DELETE, `.many()`
16612
+ * GET_MANY/DELETE/DELETE_MANY, any `.scopedOne()`/`.scopedMany()` callback): such a
16613
+ * result cannot say which user holds it, and the declaration throws.
16542
16614
  *
16543
16615
  * @param {string} propertyName - property holding the sub-object on each owner item
16544
16616
  * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, POST, PUT, PATCH, DELETE }`
@@ -16558,6 +16630,14 @@ const createResource = (
16558
16630
  DELETE,
16559
16631
  } = {},
16560
16632
  ) => {
16633
+ const declarationSite = getDeclarationSite();
16634
+ if (ownerName) {
16635
+ refuseCallbacksNamingScopedItem(
16636
+ `${name}.scopedOne("${propertyName}")`,
16637
+ { GET, POST, PUT, PATCH, DELETE },
16638
+ { name, ownerName, propertyName, declarationSite },
16639
+ );
16640
+ }
16561
16641
  const childName = `${name}.${propertyName}`;
16562
16642
 
16563
16643
  // Callbacks added by chained .one()/.many() on the child resource,
@@ -16587,7 +16667,12 @@ const createResource = (
16587
16667
  childSignal.value = childItem; // first activation: null → childItem
16588
16668
  }
16589
16669
  };
16590
- applyPropsMap.set(ownerId, applyProps);
16670
+ if (!ownerName) {
16671
+ // Declared on a scoped child, an owner id is only unique inside its
16672
+ // own owner and no callback addresses it (see
16673
+ // refuseCallbacksNamingScopedItem): nothing is looked up by it.
16674
+ applyPropsMap.set(ownerId, applyProps);
16675
+ }
16591
16676
 
16592
16677
  applyProps(ownerItem[propertyName]);
16593
16678
 
@@ -16599,7 +16684,7 @@ const createResource = (
16599
16684
  const createRestActionForScopedOne = (
16600
16685
  verb,
16601
16686
  callback,
16602
- { onActionComplete },
16687
+ { onActionComplete, paramScope },
16603
16688
  ) => {
16604
16689
  const childActionName = `${childName}.${verb}`;
16605
16690
  return createAction(callback, {
@@ -16645,8 +16730,9 @@ const createResource = (
16645
16730
  addItemSetup: childAddItemSetup,
16646
16731
  createRestAction: createRestActionForScopedOne,
16647
16732
  paramScope,
16648
- rerunOn: scopedOneRerunOn ?? rerunOn,
16733
+ rerunOn: resolveRerunOn(scopedOneRerunOn, rerunOn),
16649
16734
  dependencies: scopedOneDependencies ?? dependencies,
16735
+ ownerName: name,
16650
16736
  });
16651
16737
  };
16652
16738
 
@@ -16674,7 +16760,14 @@ const createResource = (
16674
16760
  * `propertyName`), and the child's own GET_MANY reruns per its `rerunOn`.
16675
16761
  *
16676
16762
  * Returns the child relationship resource, itself chainable:
16677
- * `TABLE_COLUMNS.one("dataType", DATA_TYPE)` adds a reactive `.dataType` property on each column.
16763
+ * `TABLE_COLUMNS.one("dataType", DATA_TYPE)` adds a reactive `.dataType` property on each column,
16764
+ * fed by what this resource's callbacks embed (`[id, [{ name, dataType: { id: 2 } }]]`). A
16765
+ * column exists only inside its table — two tables can each have an `email` — so the chained
16766
+ * relation declares none of the callbacks whose result names a column (`.one()`
16767
+ * GET/PUT/DELETE, `.many()` GET_MANY/DELETE/DELETE_MANY, any `.scopedOne()`/`.scopedMany()`
16768
+ * callback): such a result cannot say which table holds it, and the declaration throws.
16769
+ * A `.scopedMany()` chained on it keeps one collection per column: `users.email` and
16770
+ * `admins.email` never share one.
16678
16771
  *
16679
16772
  * @param {string} propertyName - property holding the collection on each owner item
16680
16773
  * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
@@ -16699,6 +16792,25 @@ const createResource = (
16699
16792
  DELETE_MANY,
16700
16793
  } = {},
16701
16794
  ) => {
16795
+ const declarationSite = getDeclarationSite();
16796
+ if (ownerName) {
16797
+ refuseCallbacksNamingScopedItem(
16798
+ `${name}.scopedMany("${propertyName}")`,
16799
+ {
16800
+ GET,
16801
+ GET_MANY,
16802
+ POST,
16803
+ POST_MANY,
16804
+ PUT,
16805
+ PUT_MANY,
16806
+ PATCH,
16807
+ PATCH_MANY,
16808
+ DELETE,
16809
+ DELETE_MANY,
16810
+ },
16811
+ { name, ownerName, propertyName, declarationSite },
16812
+ );
16813
+ }
16702
16814
  const childName = `${name}.${propertyName}`;
16703
16815
 
16704
16816
  // Callbacks added by chained .one()/.many() on the child resource,
@@ -16720,37 +16832,42 @@ const createResource = (
16720
16832
  return childItem;
16721
16833
  },
16722
16834
  });
16723
- const scope = { childStore, idArraySignal: signal([]) };
16724
- scopeMap.set(ownerKey, scope);
16725
- return scope;
16835
+ return { childStore, idArraySignal: signal([]) };
16726
16836
  };
16727
16837
  addItemSetup((item) => {
16728
16838
  const ownerId = item[idKey];
16729
-
16730
- // Reuse an existing scope if one was already created under a uniqueKey
16731
- // value (e.g. rows were fetched by tablename before the full table was loaded).
16732
- let scope = scopeMap.get(ownerId);
16733
- if (!scope) {
16839
+ let scope;
16840
+ if (ownerName) {
16841
+ // The owner is itself a scoped child: its id is only unique inside its
16842
+ // own owner, so the scope is the item's alone, never looked up by id
16843
+ // (no callback addresses it, see refuseCallbacksNamingScopedItem).
16844
+ scope = createScope(ownerId);
16845
+ } else {
16846
+ // Reuse an existing scope if one was already created under a uniqueKey
16847
+ // value (e.g. rows were fetched by tablename before the full table was loaded).
16848
+ scope = scopeMap.get(ownerId);
16849
+ if (!scope) {
16850
+ for (const uniqueKey of uniqueKeys) {
16851
+ const uniqueKeyValue = item[uniqueKey];
16852
+ if (uniqueKeyValue !== undefined && scopeMap.has(uniqueKeyValue)) {
16853
+ scope = scopeMap.get(uniqueKeyValue);
16854
+ break;
16855
+ }
16856
+ }
16857
+ }
16858
+ if (!scope) {
16859
+ scope = createScope(ownerId);
16860
+ }
16861
+ // Register the scope under the id and every uniqueKey value so that
16862
+ // resolveOwnerId can address it whichever key a callback returns.
16863
+ scopeMap.set(ownerId, scope);
16734
16864
  for (const uniqueKey of uniqueKeys) {
16735
16865
  const uniqueKeyValue = item[uniqueKey];
16736
- if (uniqueKeyValue !== undefined && scopeMap.has(uniqueKeyValue)) {
16737
- scope = scopeMap.get(uniqueKeyValue);
16738
- break;
16866
+ if (uniqueKeyValue !== undefined) {
16867
+ scopeMap.set(uniqueKeyValue, scope);
16739
16868
  }
16740
16869
  }
16741
16870
  }
16742
- if (!scope) {
16743
- scope = createScope(ownerId);
16744
- }
16745
- // Register the scope under the id and every uniqueKey value so that
16746
- // resolveOwnerId can address it whichever key a callback returns.
16747
- scopeMap.set(ownerId, scope);
16748
- for (const uniqueKey of uniqueKeys) {
16749
- const uniqueKeyValue = item[uniqueKey];
16750
- if (uniqueKeyValue !== undefined) {
16751
- scopeMap.set(uniqueKeyValue, scope);
16752
- }
16753
- }
16754
16871
 
16755
16872
  const { childStore, idArraySignal } = scope;
16756
16873
  const updateChildItemIdArray = createChildIdArrayUpdater(
@@ -16788,7 +16905,7 @@ const createResource = (
16788
16905
  const createRestActionForScopedMany = (
16789
16906
  verb,
16790
16907
  callback,
16791
- { isMany, onActionComplete },
16908
+ { isMany, onActionComplete, paramScope },
16792
16909
  ) => {
16793
16910
  const childActionName = `${childName}.${verb}`;
16794
16911
  return createAction(callback, {
@@ -16810,7 +16927,11 @@ const createResource = (
16810
16927
  );
16811
16928
  // Owner not in store yet: create the scope so actions can run before
16812
16929
  // the parent item has been loaded (e.g. rows fetched before their table).
16813
- const scope = scopeMap.get(ownerId) || createScope(ownerId);
16930
+ let scope = scopeMap.get(ownerId);
16931
+ if (!scope) {
16932
+ scope = createScope(ownerId);
16933
+ scopeMap.set(ownerId, scope);
16934
+ }
16814
16935
  const { childStore, idArraySignal } = scope;
16815
16936
 
16816
16937
  if (verb === "DELETE") {
@@ -16880,20 +17001,19 @@ const createResource = (
16880
17001
  addItemSetup: childAddItemSetup,
16881
17002
  createRestAction: createRestActionForScopedMany,
16882
17003
  paramScope,
16883
- rerunOn: scopedManyRerunOn ?? rerunOn,
17004
+ rerunOn: resolveRerunOn(scopedManyRerunOn, rerunOn),
16884
17005
  dependencies: scopedManyDependencies ?? dependencies,
17006
+ ownerName: name,
17007
+ // Declared on a scoped child, the scopes are kept per item and never
17008
+ // registered by id (see addItemSetup above): there is no owner key to
17009
+ // find one by.
17010
+ getChildStore: ownerName
17011
+ ? undefined
17012
+ : (ownerKey) => scopeMap.get(ownerKey)?.childStore,
17013
+ // scopedOne needs no such dependency: its mutation result carries the
17014
+ // updated object directly.
17015
+ ownerDependency: { owner: stateFacade, propertyName },
16885
17016
  });
16886
- // When a scoped child collection is mutated (POST etc.), the parent GET must
16887
- // re-fetch: the parent embeds the child array and only the backend knows the
16888
- // new ordering. (scopedOne does not need this: the mutation result contains
16889
- // the updated object directly.)
16890
- resourceLifecycleManager.addDependency(
16891
- childResource,
16892
- stateFacade,
16893
- propertyName,
16894
- );
16895
- childResource.getChildStore = (ownerKey) =>
16896
- scopeMap.get(ownerKey)?.childStore;
16897
17017
  return childResource;
16898
17018
  };
16899
17019
 
@@ -17114,7 +17234,7 @@ const getLastGetItemId = (action) => {
17114
17234
  };
17115
17235
 
17116
17236
  // Captures the "file:line:column" of the user code that invoked the public
17117
- // function (resource(), .one(), .many(), withParams, …), so invalid-result
17237
+ // function (resource(), .one(), .many(), .scopedOne(), …), so invalid-result
17118
17238
  // errors can point at where the callbacks were declared, not at this file.
17119
17239
  // Must be called directly from the public function: the stack offset accounts
17120
17240
  // for exactly two frames (getCallerInfo → getDeclarationSite → public fn → user code).
@@ -17135,6 +17255,36 @@ ${originalActionName} source location: ${declarationSite}`,
17135
17255
  };
17136
17256
  };
17137
17257
 
17258
+ // A scoped child's item exists only inside its owner: two owners can each hold
17259
+ // one with the same id. The callbacks of a relation chained on it whose result
17260
+ // names that item do so by its id alone — `.one()`/`.many()` as the parent
17261
+ // object or id, `.scopedOne()`/`.scopedMany()` as the `ownerId` of
17262
+ // `[ownerId, …]` — so applied they would write the owner's store (`store` of a
17263
+ // scoped child) or the scope of another owner's item. They are refused rather
17264
+ // than routed: routing needs the owner in the result, a contract none of them
17265
+ // has.
17266
+ const refuseCallbacksNamingScopedItem = (
17267
+ relationLabel,
17268
+ callbacks,
17269
+ { name, ownerName, propertyName, declarationSite },
17270
+ ) => {
17271
+ const declaredKeys = [];
17272
+ for (const key of Object.keys(callbacks)) {
17273
+ if (callbacks[key]) {
17274
+ declaredKeys.push(key);
17275
+ }
17276
+ }
17277
+ if (declaredKeys.length === 0) {
17278
+ return;
17279
+ }
17280
+ const declared = declaredKeys.join(", ");
17281
+ throw new Error(
17282
+ `${relationLabel} cannot declare ${declared}: a "${name}" item exists only inside its "${ownerName}", and the result of ${declared} names it without saying which "${ownerName}" holds it.
17283
+ Return "${propertyName}" embedded in what the "${name}" callbacks return ([ownerId, { …, ${propertyName} }]): those say which "${ownerName}".
17284
+ ${relationLabel} source location: ${declarationSite}`,
17285
+ );
17286
+ };
17287
+
17138
17288
  // Shared by .many() and .scopedMany(): converts a raw relationship value (an
17139
17289
  // array of child props/ids, or anything else meaning "empty") into an array of
17140
17290
  // child ids, upserting each entry into the child store. The id array signal is
@@ -17276,22 +17426,28 @@ Received an object with keys: ${keys.join(", ")}.`,
17276
17426
  * // which in turn triggers the route Signal->URL sync and updates the browser URL.
17277
17427
  */
17278
17428
  const syncResourceToSignals = (resource, propertyToSignalMap) => {
17279
- if (resource.getChildStore) {
17429
+ if (resource.ownerName) {
17430
+ // A scoped child's `store` is its owner's: watching it would sync the
17431
+ // owner items' properties, not the child's.
17280
17432
  throw new Error(
17281
- `syncResourceToSignals: "${resource.name}" is a scoped resource (scopedMany/scopedOne). Use syncOwnedResourceToSignals instead.`,
17433
+ `syncResourceToSignals: "${resource.name}" lives inside each "${resource.ownerName}", so it has no store of its own to watch.${resource.getChildStore ? " Use syncOwnedResourceToSignals instead." : ""}`,
17282
17434
  );
17283
17435
  }
17284
17436
  syncStoreToSignals(resource.store, propertyToSignalMap);
17285
17437
  };
17286
17438
 
17287
17439
  /**
17288
- * The same, for a `scopedOne`/`scopedMany` resource: its items live in one store
17289
- * per owner, so the store to watch is the one of the owner `ownerSignal` names
17290
- * (by id or by any unique key), and it is switched when the signal changes.
17291
- * Nothing is synced while `ownerSignal` holds `null`/`undefined` or names an
17292
- * owner nothing has been loaded for yet.
17440
+ * The same, for a `scopedMany` resource (or a `withParams()` of one): its items
17441
+ * live in one store per owner, so the store to watch is the one of the owner
17442
+ * `ownerSignal` names (by id or by any unique key), and it is switched when the
17443
+ * signal changes. Nothing is synced while `ownerSignal` holds `null`/`undefined`
17444
+ * or names an owner nothing has been loaded for yet.
17293
17445
  *
17294
- * @param {Object} resource - a resource made by `.scopedOne()` / `.scopedMany()`
17446
+ * A `scopedOne` child has no store (each owner holds its one item), and a
17447
+ * `scopedMany` declared on a scoped child is kept per item with no owner key to
17448
+ * find it by: both are refused.
17449
+ *
17450
+ * @param {Object} resource - a resource made by `.scopedMany()`
17295
17451
  * @param {import("@preact/signals").Signal} ownerSignal - the owner whose children are watched
17296
17452
  * @param {Object} propertyToSignalMap - `{ [propertyName]: signal }`, as for `syncResourceToSignals`
17297
17453
  */
@@ -17301,8 +17457,13 @@ const syncOwnedResourceToSignals = (
17301
17457
  propertyToSignalMap,
17302
17458
  ) => {
17303
17459
  if (!resource.getChildStore) {
17460
+ if (!resource.ownerName) {
17461
+ throw new Error(
17462
+ `syncOwnedResourceToSignals: "${resource.name}" is not a scoped resource. Use syncResourceToSignals instead.`,
17463
+ );
17464
+ }
17304
17465
  throw new Error(
17305
- `syncOwnedResourceToSignals: "${resource.name}" is not a scoped resource (scopedMany/scopedOne). Use syncResourceToSignals instead.`,
17466
+ `syncOwnedResourceToSignals: "${resource.name}" has no store per "${resource.ownerName}" to watch: a scopedOne child is held by each owner, and a scopedMany declared on a scoped child has no owner key to find its store by.`,
17306
17467
  );
17307
17468
  }
17308
17469
  effect(() => {
@@ -19592,9 +19753,9 @@ const isSizeSpacingKey = (key) => {
19592
19753
  // "vvw"/"vvh" are navi's own: the *visual* viewport, which — unlike vw/dvw —
19593
19754
  // shrinks when the mobile virtual keyboard opens (see layout/responsive.js), so
19594
19755
  // they are what a popup meant to stay clear of the keyboard should use.
19595
- // "appw"/"apph" are the same thing narrowed to the app's own screen: identical
19596
- // to vvw/vvh until the app declares --navi-app-max-width, and a share of that
19597
- // width afterwards. A gap meant to read as "a small margin" must use these —
19756
+ // "appw"/"apph" are the same thing narrowed to the app's own screen: the
19757
+ // visual viewport minus the app's bands (--navi-app-inset-*), so identical to
19758
+ // vvw/vvh until the app declares bands. A gap meant to read as "a small margin" must use these —
19598
19759
  // 3vvw on a 1500px window is a 45px gap around a 600px app.
19599
19760
  // Functions rather than the signals themselves: appw/apph are not a signal to
19600
19761
  // read but a value to compute (a signal, then a CSS var read back). Reading the
@@ -29288,7 +29449,7 @@ const TRANSITION_FURNITURE_CSS = /* css */ `.navi_fixed_bar[data-navi-transition
29288
29449
  clip-path: var(--wall-holes-arriving, var(--wall-holes));
29289
29450
  }
29290
29451
 
29291
- :root[data-navi-route-transition] {
29452
+ :root[data-navi-route-transition-target="area"] {
29292
29453
  & .navi_dialog::backdrop, & .navi_popover_backdrop {
29293
29454
  backdrop-filter: none;
29294
29455
  background: none;
@@ -29296,8 +29457,8 @@ const TRANSITION_FURNITURE_CSS = /* css */ `.navi_fixed_bar[data-navi-transition
29296
29457
  }
29297
29458
  `;
29298
29459
 
29299
- // Called from the render of whatever holds the pages, never at module scope:
29300
- // a page that never travels between routes must not carry this sheet, and a
29460
+ // Called as a movement starts over a marked area, never at module scope: a
29461
+ // page that never travels between routes must not carry this sheet, and a
29301
29462
  // build that sees no caller drops the css with the function.
29302
29463
  const installTransitionFurnitureCss = () => {
29303
29464
  import.meta.css = [TRANSITION_FURNITURE_CSS, "@jsenv/navi/src/nav/transition_furniture.js"];
@@ -29433,10 +29594,7 @@ const nameFurnitureAround = (areaElement) => {
29433
29594
  if (!name) {
29434
29595
  // A name the application wrote itself answers for that element, and it
29435
29596
  // is saying something navi is not: it wants that bar moved on the pages'
29436
- // clock, by its own CSS. Asked only of a bar navi has never named — a
29437
- // bar wearing one of ITS names is one this movement inherited from the
29438
- // one it interrupted, and taking it for the application's would leave it
29439
- // named for the rest of the document's life.
29597
+ // clock, by its own CSS.
29440
29598
  if (getComputedStyle(element).viewTransitionName !== "none") {
29441
29599
  continue;
29442
29600
  }
@@ -29760,9 +29918,10 @@ const TRANSITION_WINDOW_CSS = /* css */ `@property --navi-transition-cover-top {
29760
29918
  }
29761
29919
  `;
29762
29920
 
29763
- // Called from the render of whatever needs the window, never at module scope:
29764
- // a page that never travels between routes must not carry this sheet, and a
29765
- // build that sees no caller drops the css with the function.
29921
+ // Called by whatever needs the window — a render, or a movement starting —
29922
+ // never at module scope: a page that never travels between routes must not
29923
+ // carry this sheet, and a build that sees no caller drops the css with the
29924
+ // function.
29766
29925
  const installTransitionWindowCss = () => {
29767
29926
  import.meta.css = [TRANSITION_WINDOW_CSS, "@jsenv/navi/src/nav/transition_window.js"];
29768
29927
  };
@@ -30575,9 +30734,6 @@ const RouteTransitionArea = ({
30575
30734
  children,
30576
30735
  ...rest
30577
30736
  }) => {
30578
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
30579
- installTransitionWindowCss();
30580
- installTransitionFurnitureCss();
30581
30737
  const props = {
30582
30738
  ...rest,
30583
30739
  [TRANSITION_AREA_ATTRIBUTE]: ""
@@ -30646,8 +30802,6 @@ const RouteTransitionArea = ({
30646
30802
  * @returns {() => void} remove this relation.
30647
30803
  */
30648
30804
  const defineRouteTransition = (from, to, transition) => {
30649
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
30650
- installTransitionWindowCss();
30651
30805
  if (!to) {
30652
30806
  throw new TypeError(`defineRouteTransition needs a destination: "to" is ${to}. The page reached from anywhere is written defineRouteTransition(null, THAT_PAGE, ...) — there is no relation the other way round, a page LEFT for anywhere being the back half of that one.`);
30653
30807
  }
@@ -30687,8 +30841,6 @@ const defineRouteTransition = (from, to, transition) => {
30687
30841
  * @returns {() => void} remove this default.
30688
30842
  */
30689
30843
  const defineRouteDefaultTransition = transition => {
30690
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
30691
- installTransitionWindowCss();
30692
30844
  const value = normalizeTransition(transition);
30693
30845
  defaultTransition = value;
30694
30846
  return () => {
@@ -31264,12 +31416,25 @@ const beginTransition = ({
31264
31416
  url: absoluteUrl(url),
31265
31417
  decision: navigationDecision,
31266
31418
  walkHome: null,
31267
- releaseReverting: null
31419
+ releaseReverting: null,
31420
+ restoreDuration: null
31268
31421
  };
31422
+ // The document wears one transition at a time, and the one this interrupts
31423
+ // ends only after this one has begun — too late to take off what it wears
31424
+ // (see end). Taking over therefore starts by taking it off, so that what is
31425
+ // put on below describes this transition alone, including what it leaves
31426
+ // unset: a type, an area, a duration.
31427
+ if (currentTransition) {
31428
+ releaseTransitionRoot(currentTransition);
31429
+ }
31269
31430
  currentTransition = transition;
31270
31431
  // Said before the picture is taken: whoever names something for a movement
31271
31432
  // between two pages decides on it now (see transition_destination.js).
31272
31433
  holdTransitionDestination(transition, url);
31434
+ // Adopted by what starts a movement rather than by what declares one: a link
31435
+ // or a navTo() asks for a movement in an app that declared none, and an app
31436
+ // that never moves never carries the sheet.
31437
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
31273
31438
  documentElement.setAttribute(TRANSITION_ATTRIBUTE, direction);
31274
31439
  if (type) {
31275
31440
  documentElement.setAttribute(TRANSITION_TYPE_ATTRIBUTE, type);
@@ -31291,6 +31456,10 @@ const beginTransition = ({
31291
31456
  // left (see transition_window.js).
31292
31457
  let areaStateBefore = null;
31293
31458
  if (areaElement) {
31459
+ // The area is the application's element as often as <RouteTransitionArea>,
31460
+ // so what a movement on it plays with is adopted here, where it is found.
31461
+ installTransitionWindowCss();
31462
+ installTransitionFurnitureCss();
31294
31463
  documentElement.setAttribute(TRANSITION_TARGET_ATTRIBUTE, "area");
31295
31464
  // Said before the picture is taken, like every name (see
31296
31465
  // transition_furniture.js): what the bars are wearing when the transition
@@ -31301,11 +31470,10 @@ const beginTransition = ({
31301
31470
  // A duration of this relation's own, worn for the length of the transition —
31302
31471
  // and whatever the application had written inline put back afterwards, not
31303
31472
  // erased.
31304
- let restoreDuration = null;
31305
31473
  if (duration !== undefined) {
31306
31474
  const durationBefore = documentElement.style.getPropertyValue(TRANSITION_DURATION_PROPERTY);
31307
31475
  documentElement.style.setProperty(TRANSITION_DURATION_PROPERTY, typeof duration === "number" ? `${duration}ms` : duration);
31308
- restoreDuration = () => {
31476
+ transition.restoreDuration = () => {
31309
31477
  if (durationBefore) {
31310
31478
  documentElement.style.setProperty(TRANSITION_DURATION_PROPERTY, durationBefore);
31311
31479
  } else {
@@ -31366,8 +31534,11 @@ const beginTransition = ({
31366
31534
  renderWait.stop();
31367
31535
  }
31368
31536
  // The page arriving is in the DOM and the transition has not started
31369
- // playing: the one moment both states of the area can be known.
31370
- if (areaElement) {
31537
+ // playing: the one moment both states of the area can be known. The
31538
+ // browser runs this callback even for a transition skipped before its
31539
+ // first picture — the next one starting skips it — and a transition
31540
+ // replaced holds nothing: what it wore was taken off at the takeover.
31541
+ if (areaElement && currentTransition === transition) {
31371
31542
  holdTransitionFurniture(transition, areaElement);
31372
31543
  holdTransitionWindow(transition, areaElement, areaStateBefore);
31373
31544
  }
@@ -31376,8 +31547,9 @@ const beginTransition = ({
31376
31547
  // Whatever ends it — played out, skipped by another transition starting,
31377
31548
  // failed before its callback ever ran — the hold is given back and the
31378
31549
  // document is handed back to the application. Both are idempotent, and
31379
- // the attributes belong to the LAST transition begun: an earlier one
31380
- // ending late must not strip what a later one is wearing.
31550
+ // the document is handed back only by the LAST transition begun: an
31551
+ // earlier one ending late was taken off by the one that replaced it, and
31552
+ // must not strip what that one is wearing.
31381
31553
  renderWait.stop();
31382
31554
  releaseRendering();
31383
31555
  // The hold a way back took, when it is still standing: the pictures were
@@ -31389,16 +31561,7 @@ const beginTransition = ({
31389
31561
  }
31390
31562
  if (currentTransition === transition) {
31391
31563
  currentTransition = null;
31392
- documentElement.removeAttribute(TRANSITION_ATTRIBUTE);
31393
- documentElement.removeAttribute(TRANSITION_TYPE_ATTRIBUTE);
31394
- documentElement.removeAttribute(TRANSITION_TARGET_ATTRIBUTE);
31395
- releaseTransitionWindow(transition);
31396
- releaseTransitionDestination(transition);
31397
- releaseTransitionFurniture(transition);
31398
- releaseTransitionPress(transition);
31399
- if (restoreDuration) {
31400
- restoreDuration();
31401
- }
31564
+ releaseTransitionRoot(transition);
31402
31565
  }
31403
31566
  };
31404
31567
  transition.viewTransition = viewTransition;
@@ -31406,6 +31569,22 @@ const beginTransition = ({
31406
31569
  viewTransition.finished.then(end, end);
31407
31570
  };
31408
31571
 
31572
+ // Everything a transition wears on the document for its length, taken off by
31573
+ // its own end or by the transition taking over from it.
31574
+ const releaseTransitionRoot = transition => {
31575
+ const documentElement = document.documentElement;
31576
+ documentElement.removeAttribute(TRANSITION_ATTRIBUTE);
31577
+ documentElement.removeAttribute(TRANSITION_TYPE_ATTRIBUTE);
31578
+ documentElement.removeAttribute(TRANSITION_TARGET_ATTRIBUTE);
31579
+ releaseTransitionWindow(transition);
31580
+ releaseTransitionDestination(transition);
31581
+ releaseTransitionFurniture(transition);
31582
+ releaseTransitionPress(transition);
31583
+ if (transition.restoreDuration) {
31584
+ transition.restoreDuration();
31585
+ }
31586
+ };
31587
+
31409
31588
  /**
31410
31589
  * The movement playing, turned round rather than replaced: this navigation is
31411
31590
  * exactly the way back of the one on screen — the door pressed again while the
@@ -44279,16 +44458,6 @@ const css$Z = /* css */`@layer navi {
44279
44458
  border-radius: .2em;
44280
44459
  }
44281
44460
 
44282
- &[data-capitalize] {
44283
- text-transform: capitalize;
44284
-
44285
- & .navi_text_sizer {
44286
- & .navi_text {
44287
- display: inline-block;
44288
- }
44289
- }
44290
- }
44291
-
44292
44461
  &[data-shrinkwrap] {
44293
44462
  display: inline-block;
44294
44463
  }
@@ -44311,12 +44480,6 @@ time.navi_text {
44311
44480
  }
44312
44481
 
44313
44482
  .navi_text {
44314
- &[data-capitalize] {
44315
- &:first-letter, & .navi_text_sizer_placeholder:first-letter, & .navi_text_sizer_overlay:first-letter {
44316
- text-transform: uppercase;
44317
- }
44318
- }
44319
-
44320
44483
  & .navi_text_sizer, & .navi_text_sizer_placeholder, & .navi_text_sizer_overlay {
44321
44484
  display: inherit;
44322
44485
  width: inherit;
@@ -44764,7 +44927,9 @@ const shouldInjectSpacingBetween = (left, right) => {
44764
44927
  * text block from being wider than its content when inside a flex/grid container.
44765
44928
  *
44766
44929
  * @param {boolean} [capitalize]
44767
- * Uppercases the first letter of the text content via CSS.
44930
+ * Uppercases the first letter of the text, and only that one: "lundi 11 mai"
44931
+ * reads "Lundi 11 mai". Applied to the string the text starts with; a text
44932
+ * starting with an element keeps its first letter as written.
44768
44933
  *
44769
44934
  * @param {string|[number,number]|[string,string]} [selectRange]
44770
44935
  * Selects a portion of the text on mount. Pass a substring to search for, a
@@ -44884,11 +45049,13 @@ const TextUI = props => {
44884
45049
  shrinkWrap,
44885
45050
  ...rest
44886
45051
  } = props;
45052
+ if (capitalize) {
45053
+ children = capitalizeFirstLetter(children);
45054
+ }
44887
45055
  const defaultSpace = preventSpaceUnderlines ? FAKE_SPACE : REGULAR_SPACE;
44888
45056
  const resolvedSpacing = spacing ?? defaultSpace;
44889
45057
  const boxProps = {
44890
45058
  "as": "span",
44891
- "data-capitalize": capitalize ? "" : undefined,
44892
45059
  "data-shrinkwrap": shrinkWrap ? "" : undefined,
44893
45060
  ...rest,
44894
45061
  ref,
@@ -45189,6 +45356,28 @@ const Icon = ({
45189
45356
  });
45190
45357
  };
45191
45358
 
45359
+ // On the string rather than with ::first-letter, which needs a block container:
45360
+ // an inline text made one is an atomic box, and the ellipsis of the line
45361
+ // holding it (a Picker's value) can then no longer cut it. The string is also
45362
+ // what every copy of the children (sizer, bold background) renders, so they
45363
+ // all read the same. Leading spaces and punctuation are skipped, and a text
45364
+ // starting with a digit is left as it is, as ::first-letter would.
45365
+ const capitalizeFirstLetter = children => {
45366
+ if (typeof children === "string") {
45367
+ return children.replace(FIRST_LOWERCASE_LETTER_REGEXP, (match, lead, letter) => `${lead}${letter.toLocaleUpperCase()}`);
45368
+ }
45369
+ if (Array.isArray(children) && children.length > 0) {
45370
+ const first = children[0];
45371
+ const firstCapitalized = capitalizeFirstLetter(first);
45372
+ if (firstCapitalized === first) {
45373
+ return children;
45374
+ }
45375
+ return [firstCapitalized, ...children.slice(1)];
45376
+ }
45377
+ return children;
45378
+ };
45379
+ const FIRST_LOWERCASE_LETTER_REGEXP = /^([\s\p{P}]*)(\p{Ll})/u;
45380
+
45192
45381
  /**
45193
45382
  * Where a link leads, asked for when the pointer or the focus arrives on it:
45194
45383
  * the moment the user signals a destination, before the press. Only what the
@@ -60033,7 +60222,6 @@ const createSwipeToClose = (side, { grip } = {}) => {
60033
60222
  panelEl.style.transform = "";
60034
60223
  panelEl.style.clipPath = "";
60035
60224
  panelEl.style.transitionProperty = "";
60036
- panelEl.style.userSelect = "";
60037
60225
  };
60038
60226
  // The resting value is written first and the animation only covers the way
60039
60227
  // to it: both end on the same number, so nothing is seen changing when the
@@ -60077,10 +60265,6 @@ const createSwipeToClose = (side, { grip } = {}) => {
60077
60265
  return false;
60078
60266
  }
60079
60267
  panelEl.style.transitionProperty = "none";
60080
- panelEl.style.userSelect = "none";
60081
- // What the first pixels of the gesture may have started selecting is
60082
- // not a selection, it is the beginning of this travel.
60083
- window.getSelection().removeAllRanges();
60084
60268
  // The way out is the only way there is anything: pulling the other way
60085
60269
  // leans against a wall and comes back.
60086
60270
  return {
@@ -60751,7 +60935,7 @@ const css$E = /* css */`
60751
60935
  --navi-app-width rather than 3vvw): the gap must read as a small
60752
60936
  margin around the dialog, and 3% of a 1500px window is a 45px gap
60753
60937
  around a 600px app. Identical to 3vvw until the app declares
60754
- --navi-app-max-width. */
60938
+ bands (--navi-app-max-width, or --navi-app-inset-* written by it). */
60755
60939
  --x-dialog-container-spacing: calc(0.03 * var(--navi-app-width));
60756
60940
 
60757
60941
  /* --navi-app-width, not --navi-vvw: a top-layer dialog is calibrated on
@@ -61488,8 +61672,8 @@ const css$E = /* css */`
61488
61672
  * `positionArea`: it both caps the dialog's own size (via
61489
61673
  * `--x-dialog-container-spacing`, written from this prop) and offsets a docked
61490
61674
  * one from the edge it docks to. Accepts a number of pixels, a viewport
61491
- * length — "appw"/"apph" being the app's own screen (the visual viewport, or
61492
- * the narrower one the app declared with --navi-app-max-width) and
61675
+ * length — "appw"/"apph" being the app's own screen (the visual viewport
61676
+ * minus the app's bands, see docs/safe_area.md) and
61493
61677
  * "vvw"/"vvh" the visual viewport itself, which shrinks when the mobile
61494
61678
  * keyboard opens — or a container length ("3cqw", the `layer="local"`
61495
61679
  * default). Pass 0 for a dialog meant to sit flush (a side panel).
@@ -64542,6 +64726,8 @@ const css$C = /* css */`@layer navi {
64542
64726
  * `maxWidth` below.
64543
64727
  * @param {boolean} [props.sizeFromAnchor] - **Dialog-only**, same guard.
64544
64728
  * @param {"box"|"scene"} [props.lift] - **Dialog-only**, same guard.
64729
+ * @param {Element|{current: Element}|string} [props.liftAnchor] -
64730
+ * **Dialog-only**, same guard.
64545
64731
  * @param {string} [props.positionArea] - Forwarded as-is — `Dialog` and
64546
64732
  * `Popover` have different own defaults (`"center"` vs. `"bottom"`),
64547
64733
  * deliberately not homogenized here (each reads best for its own typical
@@ -64575,8 +64761,13 @@ const css$C = /* css */`@layer navi {
64575
64761
  * identically): the wash the backdrop paints over what is behind.
64576
64762
  * @param {string} [props.backdropFilter] - Forwarded as-is: what that wash
64577
64763
  * does to the picture underneath, `"blur(4px)"` and the like.
64578
- * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|"expanding"|`slide-from-${string}`|`expand-${string}`} [props.animation]
64579
- * - Forwarded as-is.
64764
+ * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`|"expanding"|`expand-${string}`|"lifting"|{open: boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`, close: "lifting"}} [props.animation]
64765
+ * - Forwarded to whichever renders — see either component's own doc.
64766
+ * `"expanding"`/`"expand-*"` are `Popover`'s own; `"lifting"` and
64767
+ * `{ open, close: "lifting" }` are `Dialog`'s. A popover has no lift, so in
64768
+ * popover mode `"lifting"` plays no movement (what a dialog does with no
64769
+ * anchor to lift out of) and the object form opens with its `open`, which
64770
+ * the close plays backwards like every other kind.
64580
64771
  * @param {string} [props.animationDuration] - Forwarded as-is.
64581
64772
  * @param {string} [props.maxWidth] - Forwarded as-is to both; also read
64582
64773
  * here directly to help decide the automatic `mode` (a fixed length under
@@ -64642,6 +64833,8 @@ const Popup = props => {
64642
64833
  // mode the automatic screen-size resolution happens to pick.
64643
64834
  pressOutside = "close",
64644
64835
  backdrop,
64836
+ // A popover is handed only the values it understands (toPopoverAnimation).
64837
+ animation,
64645
64838
  // Popover-only (see this component's own doc) — destructured out so
64646
64839
  // they're never part of ...rest, and therefore never forwarded to
64647
64840
  // Dialog below, where they'd otherwise leak onto the real <dialog>
@@ -64656,6 +64849,7 @@ const Popup = props => {
64656
64849
  dockedOnSmallTouchScreen,
64657
64850
  sizeFromAnchor,
64658
64851
  lift,
64852
+ liftAnchor,
64659
64853
  ...rest
64660
64854
  } = props;
64661
64855
  const [mode] = useResolvedPopupMode(modeProp, maxWidth, {
@@ -64675,6 +64869,8 @@ const Popup = props => {
64675
64869
  dockedOnSmallTouchScreen: dockedOnSmallTouchScreen,
64676
64870
  sizeFromAnchor: sizeFromAnchor,
64677
64871
  lift: lift,
64872
+ liftAnchor: liftAnchor,
64873
+ animation: animation,
64678
64874
  maxWidth: maxWidth,
64679
64875
  pressOutside: pressOutside,
64680
64876
  backdrop: backdrop,
@@ -64688,6 +64884,7 @@ const Popup = props => {
64688
64884
  }
64689
64885
  return jsx(Popover, {
64690
64886
  ...rest,
64887
+ animation: toPopoverAnimation(animation),
64691
64888
  maxWidth: maxWidth,
64692
64889
  pressOutside: pressOutside,
64693
64890
  backdrop: backdrop,
@@ -64705,6 +64902,18 @@ const Popup = props => {
64705
64902
  };
64706
64903
  Popup.Close = PopupClose;
64707
64904
 
64905
+ // What Dialog's own animation values mean to a popover, which has no lift
64906
+ // (see the `animation` doc above).
64907
+ const toPopoverAnimation = animation => {
64908
+ if (animation === "lifting") {
64909
+ return undefined;
64910
+ }
64911
+ if (animation !== null && typeof animation === "object") {
64912
+ return animation.open;
64913
+ }
64914
+ return animation;
64915
+ };
64916
+
64708
64917
  installImportMetaCssBuild(import.meta);
64709
64918
  const css$B = /* css */`.navi_picker_callout_dock > [data-picker-content] {
64710
64919
  display: none;
@@ -66015,9 +66224,14 @@ const PickerNaviMinute = props => {
66015
66224
  step,
66016
66225
  value
66017
66226
  } = props;
66227
+
66228
+ // resolveInputProps has already run (PickerFirstResolver), so the type
66229
+ // handed on is one the picker renders as is — the text field a navi numeric
66230
+ // type resolves to, told what its value is.
66018
66231
  return jsx(Next, {
66019
66232
  ...props,
66020
- type: "minute",
66233
+ type: "text",
66234
+ "navi-input-type": "minute",
66021
66235
  children: jsxs(Box, {
66022
66236
  flex: "y",
66023
66237
  spacing: "s",
@@ -72613,11 +72827,15 @@ const PickerNaviTime = props => {
72613
72827
  max = "23:30",
72614
72828
  step
72615
72829
  } = props;
72616
- const stepSeconds = timeStringToSeconds(step) ?? 1800;
72830
+ // resolveInputProps has already run (PickerFirstResolver) and left navi_time
72831
+ // as written: what is handed on is a time input, whose step is in seconds.
72832
+ const hostStep = timeStringToSeconds(step);
72833
+ const stepSeconds = hostStep ?? 1800;
72617
72834
  const slots = useMemo(() => generateTimeSlots(min, max, stepSeconds), [min, max, stepSeconds]);
72618
72835
  return jsx(Next, {
72619
72836
  ...props,
72620
72837
  type: "time",
72838
+ step: hostStep,
72621
72839
  children: jsx(List, {
72622
72840
  selectable: true,
72623
72841
  command: "--navi-send",
@@ -75029,6 +75247,10 @@ const PickerFirstResolver = props => {
75029
75247
  ...props
75030
75248
  });
75031
75249
  };
75250
+
75251
+ // PickerFirstResolver comes before the presets so a preset reads the
75252
+ // min/max/step a bound signal fills in (resolveInputProps); a preset type
75253
+ // therefore has no row in NAVI_TYPE_DEFAULTS (resolve_input_props.js).
75032
75254
  const PickerResolved = /*#__PURE__*/createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerConfirmResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
75033
75255
  const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
75034
75256
  Date: PickerDateUI,
@@ -86439,6 +86661,8 @@ const css$3 = /* css */`@layer navi {
86439
86661
  }
86440
86662
 
86441
86663
  .navi_card_layout {
86664
+ box-sizing: border-box;
86665
+ min-height: 100%;
86442
86666
  padding-top: var(--layout-margin-top, var(--layout-margin-y, var(--layout-margin)));
86443
86667
  padding-right: var(--layout-margin-right, var(--layout-margin-x, var(--layout-margin)));
86444
86668
  padding-bottom: var(--layout-margin-bottom, var(--layout-margin-y, var(--layout-margin)));
@@ -86480,6 +86704,11 @@ const CardLayoutStyleCSSVars = {
86480
86704
  minHeight: "--layout-min-height"
86481
86705
  };
86482
86706
  /**
86707
+ * The card is centered in the area both ways and is as wide as its content,
86708
+ * `minWidth` at least. Down the area, the layout takes at least the height of
86709
+ * what holds it, so how tall that area is is up to the page around it, as for
86710
+ * `ViewportLayout`.
86711
+ *
86483
86712
  * @type {import("ignore:preact").FunctionComponent<{
86484
86713
  * alignX?: string,
86485
86714
  * alignY?: string,
@@ -86509,6 +86738,11 @@ const CardLayout = ({
86509
86738
  }) => {
86510
86739
  import.meta.css = [css$3, "@jsenv/navi/src/layout/card_layout.jsx"];
86511
86740
  return jsx(Box, {
86741
+ flex: true
86742
+ // The card's place in the area; alignX/alignY below are its content's.
86743
+ ,
86744
+ alignX: "center",
86745
+ alignY: "center",
86512
86746
  baseClassName: "navi_card_layout",
86513
86747
  styleCSSVars: CardLayoutStyleCSSVars,
86514
86748
  visualSelector: ".navi_card",