@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.
@@ -554,7 +554,10 @@ effect(() => {
554
554
  * The active language is read from `languagesSignal` (see lang_signal.js —
555
555
  * combines the browser's own `navigator.languages`, an optional
556
556
  * `setPreferredLanguage()` user override, and an optional
557
- * `setSupportedLanguages()` app-wide allow-list), live on every lookup.
557
+ * `setSupportedLanguages()` app-wide allow-list), live on every lookup. Each
558
+ * key is looked up in those languages in order, then in English, which
559
+ * translates every key: a language shipped for part of the keys only (German
560
+ * has the `time.*` words) never shows a raw key.
558
561
  *
559
562
  * Built-in key namespaces, all overridable — the registrations below are the
560
563
  * exhaustive list, read them to find the exact key to override:
@@ -1474,19 +1477,25 @@ const isNetworkPolicyError = (error) => {
1474
1477
  * is already saying it in words the person understands.
1475
1478
  *
1476
1479
  * navi's own report leaves it alone (action_error_report.js). What is left is
1477
- * the screen displaying it: it does so by throwing the error to a boundary, and
1478
- * `preact/debug` re-emits on `window` every error a boundary caught — on
1479
- * purpose, for React devtools compatibility. Uncancelled, that lands as an
1480
- * uncaught error in the console, over a page calmly explaining why nothing was
1481
- * asked. Cancelling the event is what says it is handled: the browser drops
1480
+ * the error reaching `window` on its own — a run called by hand whose failure
1481
+ * nobody catches, a render throw no boundary took — where it lands as an
1482
+ * uncaught error in the console, about a request the app itself declared would
1483
+ * not leave. Cancelling the event is what says it is handled: the browser drops
1482
1484
  * the console line, and the jsenv supervisor skips prevented events too.
1483
1485
  *
1484
1486
  * Both shapes a failure travels in are covered, since which one it is depends
1485
1487
  * on whether the resource callback happened to be async — the throw reaching
1486
1488
  * `window`, and the rejection nobody caught.
1487
1489
  *
1488
- * Scoped to the policy's own error and to nothing else — every other error a
1489
- * boundary caught, and every other rejection let go, stays exactly as loud as
1490
+ * A boundary displaying it raises no event at all. In dev, `preact/debug` hands
1491
+ * every error a boundary caught to `console.error`, after the boundary took it
1492
+ * and before it rendered anything; it reads no option and no flag, so that line
1493
+ * stays. Muting it from here is not done: wrapping `console.error` rewrites a
1494
+ * global, and wrapping `options._catchError` reaches into preact's private
1495
+ * hooks, both to hide one class of error.
1496
+ *
1497
+ * Scoped to the policy's own error and to nothing else — every other error
1498
+ * thrown at window, and every other rejection let go, stays exactly as loud as
1490
1499
  * it is.
1491
1500
  */
1492
1501
  let networkPolicyErrorSilenced = false;
@@ -1652,8 +1661,8 @@ const markErrorAsStoppingRender = (error) => {
1652
1661
  * An error the network policy produced is never reported: the app declared the
1653
1662
  * state that produced it, the request never left, and there is nothing for a
1654
1663
  * developer to fix. It is data a screen shows, and it stays data whether or not
1655
- * one does (see network_policy.js, which also keeps it out of the browser
1656
- * console).
1664
+ * one does (see network_policy.js, which also cancels it when it reaches
1665
+ * window).
1657
1666
  */
1658
1667
  const errorIsAccountedFor = (error) => {
1659
1668
  return (
@@ -4946,14 +4955,11 @@ const CONTROL_TYPE_BY_SIGNAL_TYPE = {
4946
4955
  // become something plainer — it is what says the value is a number (see
4947
4956
  // isNumberInput), and what lets constraint messages use domain-specific
4948
4957
  // wording instead of the generic "Ce nombre doit être...".
4958
+ // A type a picker preset draws (navi_time, navi_minute: see picker_preset.jsx)
4959
+ // has no row: resolveInputProps runs before the preset resolver, and a row
4960
+ // would turn the type into its host before the preset resolver reads it. The
4961
+ // preset hands the host type on itself.
4949
4962
  const NAVI_TYPE_DEFAULTS = {
4950
- navi_time: {
4951
- "type": "time",
4952
- "navi-input-type": "time",
4953
- "min": 0,
4954
- "max": 24 * 3600 - 1,
4955
- "step": 1,
4956
- },
4957
4963
  navi_percentage: {
4958
4964
  "type": "navi_number",
4959
4965
  "navi-input-type": "percentage",
@@ -5015,7 +5021,6 @@ const isNumberInput = (type, naviInputType) => {
5015
5021
  * Supported navi types and their targets:
5016
5022
  * - `navi_percentage` → `navi_number` (0–100, step 1)
5017
5023
  * - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
5018
- * - `navi_time` → `time` (step in seconds)
5019
5024
  *
5020
5025
  * Standard HTML input types with formatters:
5021
5026
  * - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
@@ -12640,8 +12645,9 @@ defineInteractionDetector({
12640
12645
  name: "surface",
12641
12646
  claims: (type) =>
12642
12647
  type === PAN || type === ZOOM || type === GRAB || type === RELEASE,
12643
- // A press on the surface may be a tap, a hold or the beginning of a pan, and
12644
- // nothing may read it as the first until the pointer has said which.
12648
+ // A press on the surface may be a tap, a hold or the first finger of a pan or
12649
+ // a pinch, and nothing may read it as the first until the pointer has said
12650
+ // which.
12645
12651
  disputesPress: true,
12646
12652
  setup: (element, trigger, { types, readConfig }) => {
12647
12653
  const canPan = types.includes(PAN);
@@ -15763,21 +15769,33 @@ const getActionResultProperties = (action) => {
15763
15769
  return actionResultPropertiesMap.get(action);
15764
15770
  };
15765
15771
 
15766
- // PUT/PATCH results update the UI through the store, and DELETE resets the GET
15767
- // instead of rerunning it, so a GET rerun would only ever cost a request.
15768
- // A POST is the one case the client cannot decide alone: whether a new item
15769
- // belongs to a list depends on filters/pagination the backend owns.
15770
15772
  // Rationale in full, plus when to override: docs/list_refresh.md
15771
15773
  const defaultRerunOn = {
15774
+ // PUT/PATCH results update the UI through the store, and DELETE resets the
15775
+ // GET rather than rerunning it, so a GET rerun would only ever cost a request.
15772
15776
  GET: false,
15777
+ // A POST is the one case the client cannot decide alone: whether a new item
15778
+ // belongs to a list depends on filters/pagination the backend owns.
15773
15779
  GET_MANY: ["POST"],
15780
+ // DELETE is in there, unlike for GET_MANY: an action holds ids and the store
15781
+ // drops the deleted one out of every list holding it, while a list reading
15782
+ // by slices holds places — the row that left takes the ones after it one
15783
+ // rank up, and only the collection knows who fills the last one.
15784
+ GET_RANGE: ["POST", "DELETE"],
15785
+ };
15786
+ // A key a `rerunOn` leaves out keeps the value it would have had without it:
15787
+ // the default for a resource, the value of the resource it is declared on for
15788
+ // a withParams() scope or a relation.
15789
+ const resolveRerunOn = (rerunOn, inheritedRerunOn = defaultRerunOn) => {
15790
+ if (!rerunOn) {
15791
+ return inheritedRerunOn;
15792
+ }
15793
+ return {
15794
+ GET: rerunOn.GET ?? inheritedRerunOn.GET,
15795
+ GET_MANY: rerunOn.GET_MANY ?? inheritedRerunOn.GET_MANY,
15796
+ GET_RANGE: rerunOn.GET_RANGE ?? inheritedRerunOn.GET_RANGE,
15797
+ };
15774
15798
  };
15775
- // What makes a range reader stale (rerunOn.GET_RANGE overrides it). DELETE is
15776
- // in there, unlike for GET_MANY: an action holds ids and the store drops the
15777
- // deleted one out of every list holding it, while a list reading by slices
15778
- // holds places — the row that left takes the ones after it one rank up, and
15779
- // only the collection knows who fills the last one.
15780
- const defaultInvalidateRangeOn = ["POST", "DELETE"];
15781
15799
 
15782
15800
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
15783
15801
  const createResourceLifecycleManager = () => {
@@ -15787,7 +15805,7 @@ const createResourceLifecycleManager = () => {
15787
15805
 
15788
15806
  const registerResource = (resourceScope, config) => {
15789
15807
  const {
15790
- rerunOn = defaultRerunOn,
15808
+ rerunOn,
15791
15809
  paramScope = null,
15792
15810
  dependencies = [],
15793
15811
  uniqueKeys = [],
@@ -16070,8 +16088,7 @@ const createResourceLifecycleManager = () => {
16070
16088
  if (!isSameResource && !isDependent) {
16071
16089
  continue;
16072
16090
  }
16073
- const invalidateOn = config.rerunOn.GET_RANGE ?? defaultInvalidateRangeOn;
16074
- if (!shouldRerunAfter(invalidateOn, triggerVerb)) {
16091
+ if (!shouldRerunAfter(config.rerunOn.GET_RANGE, triggerVerb)) {
16075
16092
  continue;
16076
16093
  }
16077
16094
  for (const rangeReader of config.rangeReaderSet) {
@@ -16572,7 +16589,7 @@ const resource = (
16572
16589
  addItemSetup,
16573
16590
  createRestAction: createRestActionForRoot,
16574
16591
  paramScope: getParamScope(undefined),
16575
- rerunOn,
16592
+ rerunOn: resolveRerunOn(rerunOn),
16576
16593
  dependencies,
16577
16594
  });
16578
16595
  };
@@ -16590,6 +16607,15 @@ const createResource = (
16590
16607
  paramScope,
16591
16608
  rerunOn,
16592
16609
  dependencies,
16610
+ // Set on a scoped child: the resource owning each of its items, which
16611
+ // live in one store per owner (`store` is the owner's).
16612
+ ownerName,
16613
+ // Set on a scopedMany child: its per-owner store looked up by owner key,
16614
+ // and the owner whose GET embeds the collection. Options rather than
16615
+ // properties added afterwards, so every facade of the relation — the one
16616
+ // scopedMany returns and each withParams() of it — carries both.
16617
+ getChildStore,
16618
+ ownerDependency,
16593
16619
  },
16594
16620
  ) => {
16595
16621
  const params = paramScope.params;
@@ -16615,6 +16641,8 @@ const createResource = (
16615
16641
  // private but exposed for convenience
16616
16642
  store,
16617
16643
  addItemSetup,
16644
+ ownerName,
16645
+ getChildStore,
16618
16646
  };
16619
16647
 
16620
16648
  resourceLifecycleManager.registerResource(stateFacade, {
@@ -16623,6 +16651,17 @@ const createResource = (
16623
16651
  dependencies,
16624
16652
  uniqueKeys,
16625
16653
  });
16654
+ if (ownerDependency) {
16655
+ // A child collection mutated (POST etc.) makes the owner GET re-fetch:
16656
+ // the owner embeds the child array and only the backend knows the new
16657
+ // ordering. Registered per facade, since the rerun is looked up by the
16658
+ // facade whose action completed.
16659
+ resourceLifecycleManager.addDependency(
16660
+ stateFacade,
16661
+ ownerDependency.owner,
16662
+ ownerDependency.propertyName,
16663
+ );
16664
+ }
16626
16665
  const onActionComplete = (actionCompleted) => {
16627
16666
  resourceLifecycleManager.onActionComplete(actionCompleted, {
16628
16667
  resourceScope: stateFacade,
@@ -16635,8 +16674,12 @@ const createResource = (
16635
16674
  * Actions from parameterized resources only trigger rerun/reset for other actions with
16636
16675
  * identical parameters, preventing cross-contamination between different parameter sets.
16637
16676
  *
16677
+ * The actions are the resource's own kind: on a relation they keep its callback return
16678
+ * contract and write where it writes (`TABLE_COLUMNS.withParams(…).GET_MANY` returns
16679
+ * `[ownerId, items]` and replaces that owner's collection).
16680
+ *
16638
16681
  * @param {Object} params - Parameters to bind to all actions of this resource (required)
16639
- * @param {{ rerunOn?: Object, dependencies?: Object[] }} [options] - reruns of that scope; left out, the scope inherits the resource's
16682
+ * @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
16640
16683
  * @returns {Object} A new resource instance with parameter-bound actions and isolated lifecycle
16641
16684
  * @see docs/resource.md — what a scope isolates, and `dependencies`
16642
16685
  *
@@ -16660,7 +16703,6 @@ const createResource = (
16660
16703
  paramsToInject,
16661
16704
  { dependencies: withParamsDeps, rerunOn: withParamsRerunOn } = {},
16662
16705
  ) => {
16663
- const declarationSite = getDeclarationSite();
16664
16706
  if (!paramsToInject || Object.keys(paramsToInject).length === 0) {
16665
16707
  throw new Error(`resource(${name}).withParams() requires parameters`);
16666
16708
  }
@@ -16668,11 +16710,6 @@ const createResource = (
16668
16710
  ? { ...params, ...paramsToInject }
16669
16711
  : paramsToInject;
16670
16712
  const resolvedParamScope = getParamScope(resolvedParams);
16671
- const createRestActionWithParams = createRestActionFactoryForRoot(name, {
16672
- idKey,
16673
- store,
16674
- declarationSite,
16675
- });
16676
16713
  return createResource(name, {
16677
16714
  idKey,
16678
16715
  uniqueKeys,
@@ -16680,10 +16717,16 @@ const createResource = (
16680
16717
  restCallbacks,
16681
16718
  store,
16682
16719
  addItemSetup,
16683
- createRestAction: createRestActionWithParams,
16720
+ // The actions of this resource's kind: a relation applies its results to
16721
+ // its relation (a scoped child to the per-owner stores), never as root
16722
+ // items of `store`.
16723
+ createRestAction,
16684
16724
  paramScope: resolvedParamScope,
16685
- rerunOn: withParamsRerunOn ?? rerunOn,
16725
+ rerunOn: resolveRerunOn(withParamsRerunOn, rerunOn),
16686
16726
  dependencies: withParamsDeps ?? dependencies,
16727
+ ownerName,
16728
+ getChildStore,
16729
+ ownerDependency,
16687
16730
  });
16688
16731
  };
16689
16732
  stateFacade.withParams = withParams;
@@ -16700,14 +16743,15 @@ const createResource = (
16700
16743
  * Callback return contracts:
16701
16744
  * - GET / PUT → the parent object with the relationship nested inside:
16702
16745
  * `async ({ id }) => ({ id, session: { id: 10, token: "abc" } })`; `null` for no relationship
16703
- * - DELETE → the parent id; the property is set to `null`
16746
+ * - DELETE → the parent id, or `{ id }`; the property is set to `null`
16704
16747
  *
16705
16748
  * The backend may also embed the child inline in a parent GET/POST response — the
16706
16749
  * setter on the property upserts the nested object into the child store.
16707
16750
  *
16708
- * Returns the relationship resource: its actions (`USER_SESSION.GET`, `.PUT`,
16709
- * `.DELETE`) write the parent's property. A relation of the child itself is
16710
- * declared on the child resource (`SESSION.one("device", DEVICE)`).
16751
+ * Returns the relationship resource, itself chainable. Its items are the child
16752
+ * resource's own, shared by every parent: `USER_SESSION.one("device", DEVICE)`
16753
+ * adds a reactive `.device` property to every session, as
16754
+ * `SESSION.one("device", DEVICE)` would.
16711
16755
  *
16712
16756
  * @param {string} propertyName - property holding the child on each parent item
16713
16757
  * @param {Object} childResource - the independent resource created by `resource()`
@@ -16727,6 +16771,13 @@ const createResource = (
16727
16771
  } = {},
16728
16772
  ) => {
16729
16773
  const declarationSite = getDeclarationSite();
16774
+ if (ownerName) {
16775
+ refuseCallbacksNamingScopedItem(
16776
+ `${name}.one("${propertyName}")`,
16777
+ { GET, PUT, DELETE },
16778
+ { name, ownerName, propertyName, declarationSite },
16779
+ );
16780
+ }
16730
16781
  const childName = `${name}.${propertyName}`;
16731
16782
  const childIdKey = childResource.idKey;
16732
16783
  const childStore = childResource.store;
@@ -16787,14 +16838,18 @@ const createResource = (
16787
16838
  );
16788
16839
  });
16789
16840
 
16790
- const createRestActionForOne = (verb, callback, { onActionComplete }) => {
16841
+ const createRestActionForOne = (
16842
+ verb,
16843
+ callback,
16844
+ { onActionComplete, paramScope },
16845
+ ) => {
16791
16846
  const applyResultToValue =
16792
16847
  verb === "DELETE"
16793
- ? (itemId) => {
16794
- const item = store.select(itemId);
16848
+ ? (itemIdOrItemProps) => {
16849
+ const item = store.select(itemIdOrItemProps);
16795
16850
  const childItemId = item[propertyName][childIdKey];
16796
16851
  store.upsert({
16797
- [idKey]: itemId,
16852
+ [idKey]: item[idKey],
16798
16853
  [propertyName]: null,
16799
16854
  });
16800
16855
  return childItemId;
@@ -16822,7 +16877,7 @@ const createResource = (
16822
16877
  if (!isProps(result) && !primitiveCanBeId(result)) {
16823
16878
  throwInvalidResult(
16824
16879
  action.name,
16825
- `an object (that will be used to drop "${name}" resource)`,
16880
+ `the "${name}" id, or { ${idKey} } (the item whose "${propertyName}" becomes null)`,
16826
16881
  result,
16827
16882
  );
16828
16883
  }
@@ -16847,11 +16902,11 @@ const createResource = (
16847
16902
  PUT,
16848
16903
  DELETE,
16849
16904
  },
16850
- store,
16851
- addItemSetup,
16905
+ store: childStore,
16906
+ addItemSetup: childResource.addItemSetup,
16852
16907
  createRestAction: createRestActionForOne,
16853
16908
  paramScope,
16854
- rerunOn: oneRerunOn ?? rerunOn,
16909
+ rerunOn: resolveRerunOn(oneRerunOn, rerunOn),
16855
16910
  dependencies: oneDependencies ?? dependencies,
16856
16911
  });
16857
16912
  };
@@ -16874,6 +16929,9 @@ const createResource = (
16874
16929
  * - DELETE → `[parentId, childId]`
16875
16930
  * - DELETE_MANY → `[parentId, [childId, childId, …]]`
16876
16931
  *
16932
+ * Returns the relationship resource, itself chainable; its items are the child
16933
+ * resource's own, as for `.one()`.
16934
+ *
16877
16935
  * @param {string} propertyName - property holding the child array on each parent item
16878
16936
  * @param {Object} childResource - the independent resource created by `resource()`
16879
16937
  * @param {Object} [restCallbacks] - `{ rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
@@ -16899,6 +16957,13 @@ const createResource = (
16899
16957
  } = {},
16900
16958
  ) => {
16901
16959
  const declarationSite = getDeclarationSite();
16960
+ if (ownerName) {
16961
+ refuseCallbacksNamingScopedItem(
16962
+ `${name}.many("${propertyName}")`,
16963
+ { GET_MANY, DELETE, DELETE_MANY },
16964
+ { name, ownerName, propertyName, declarationSite },
16965
+ );
16966
+ }
16902
16967
  const childStore = childResource.store;
16903
16968
  const childIdKey = childResource.idKey;
16904
16969
  const childName = `${name}.${propertyName}`;
@@ -16930,21 +16995,23 @@ const createResource = (
16930
16995
  const createRestActionForMany = (
16931
16996
  verb,
16932
16997
  callback,
16933
- { isMany, onActionComplete },
16998
+ { isMany, onActionComplete, paramScope },
16934
16999
  ) => {
16935
17000
  if (!isMany) {
16936
17001
  return createRestActionAffectingOneItem(verb, callback, {
16937
17002
  onActionComplete,
17003
+ paramScope,
16938
17004
  });
16939
17005
  }
16940
17006
  return createRestActionAffectingManyItems(verb, callback, {
16941
17007
  onActionComplete,
17008
+ paramScope,
16942
17009
  });
16943
17010
  };
16944
17011
  const createRestActionAffectingOneItem = (
16945
17012
  verb,
16946
17013
  callback,
16947
- { onActionComplete },
17014
+ { onActionComplete, paramScope },
16948
17015
  ) => {
16949
17016
  const applyResultToValue =
16950
17017
  verb === "DELETE"
@@ -17007,7 +17074,7 @@ const createResource = (
17007
17074
  const createRestActionAffectingManyItems = (
17008
17075
  verb,
17009
17076
  callback,
17010
- { onActionComplete },
17077
+ { onActionComplete, paramScope },
17011
17078
  ) => {
17012
17079
  const applyResultToValue =
17013
17080
  verb === "GET"
@@ -17109,11 +17176,11 @@ const createResource = (
17109
17176
  DELETE,
17110
17177
  DELETE_MANY,
17111
17178
  },
17112
- store,
17113
- addItemSetup,
17179
+ store: childStore,
17180
+ addItemSetup: childResource.addItemSetup,
17114
17181
  createRestAction: createRestActionForMany,
17115
17182
  paramScope,
17116
- rerunOn: manyRerunOn ?? rerunOn,
17183
+ rerunOn: resolveRerunOn(manyRerunOn, rerunOn),
17117
17184
  dependencies: manyDependencies ?? dependencies,
17118
17185
  });
17119
17186
  };
@@ -17135,7 +17202,12 @@ const createResource = (
17135
17202
  * Mutations apply directly to the owner's signal, so the parent GET is never rerun.
17136
17203
  *
17137
17204
  * Returns the child relationship resource, itself chainable:
17138
- * `USER_PROFILE.one("theme", THEME)` adds a reactive `.theme` property on each profile.
17205
+ * `USER_PROFILE.one("theme", THEME)` adds a reactive `.theme` property on each profile,
17206
+ * fed by what this resource's callbacks embed (`[id, { bio, theme: { id: 3 } }]`). A
17207
+ * profile exists only inside its user, so the chained relation declares none of the
17208
+ * callbacks whose result names a profile (`.one()` GET/PUT/DELETE, `.many()`
17209
+ * GET_MANY/DELETE/DELETE_MANY, any `.scopedOne()`/`.scopedMany()` callback): such a
17210
+ * result cannot say which user holds it, and the declaration throws.
17139
17211
  *
17140
17212
  * @param {string} propertyName - property holding the sub-object on each owner item
17141
17213
  * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, POST, PUT, PATCH, DELETE }`
@@ -17155,6 +17227,14 @@ const createResource = (
17155
17227
  DELETE,
17156
17228
  } = {},
17157
17229
  ) => {
17230
+ const declarationSite = getDeclarationSite();
17231
+ if (ownerName) {
17232
+ refuseCallbacksNamingScopedItem(
17233
+ `${name}.scopedOne("${propertyName}")`,
17234
+ { GET, POST, PUT, PATCH, DELETE },
17235
+ { name, ownerName, propertyName, declarationSite },
17236
+ );
17237
+ }
17158
17238
  const childName = `${name}.${propertyName}`;
17159
17239
 
17160
17240
  // Callbacks added by chained .one()/.many() on the child resource,
@@ -17184,7 +17264,12 @@ const createResource = (
17184
17264
  childSignal.value = childItem; // first activation: null → childItem
17185
17265
  }
17186
17266
  };
17187
- applyPropsMap.set(ownerId, applyProps);
17267
+ if (!ownerName) {
17268
+ // Declared on a scoped child, an owner id is only unique inside its
17269
+ // own owner and no callback addresses it (see
17270
+ // refuseCallbacksNamingScopedItem): nothing is looked up by it.
17271
+ applyPropsMap.set(ownerId, applyProps);
17272
+ }
17188
17273
 
17189
17274
  applyProps(ownerItem[propertyName]);
17190
17275
 
@@ -17196,7 +17281,7 @@ const createResource = (
17196
17281
  const createRestActionForScopedOne = (
17197
17282
  verb,
17198
17283
  callback,
17199
- { onActionComplete },
17284
+ { onActionComplete, paramScope },
17200
17285
  ) => {
17201
17286
  const childActionName = `${childName}.${verb}`;
17202
17287
  return createAction(callback, {
@@ -17242,8 +17327,9 @@ const createResource = (
17242
17327
  addItemSetup: childAddItemSetup,
17243
17328
  createRestAction: createRestActionForScopedOne,
17244
17329
  paramScope,
17245
- rerunOn: scopedOneRerunOn ?? rerunOn,
17330
+ rerunOn: resolveRerunOn(scopedOneRerunOn, rerunOn),
17246
17331
  dependencies: scopedOneDependencies ?? dependencies,
17332
+ ownerName: name,
17247
17333
  });
17248
17334
  };
17249
17335
 
@@ -17271,7 +17357,14 @@ const createResource = (
17271
17357
  * `propertyName`), and the child's own GET_MANY reruns per its `rerunOn`.
17272
17358
  *
17273
17359
  * Returns the child relationship resource, itself chainable:
17274
- * `TABLE_COLUMNS.one("dataType", DATA_TYPE)` adds a reactive `.dataType` property on each column.
17360
+ * `TABLE_COLUMNS.one("dataType", DATA_TYPE)` adds a reactive `.dataType` property on each column,
17361
+ * fed by what this resource's callbacks embed (`[id, [{ name, dataType: { id: 2 } }]]`). A
17362
+ * column exists only inside its table — two tables can each have an `email` — so the chained
17363
+ * relation declares none of the callbacks whose result names a column (`.one()`
17364
+ * GET/PUT/DELETE, `.many()` GET_MANY/DELETE/DELETE_MANY, any `.scopedOne()`/`.scopedMany()`
17365
+ * callback): such a result cannot say which table holds it, and the declaration throws.
17366
+ * A `.scopedMany()` chained on it keeps one collection per column: `users.email` and
17367
+ * `admins.email` never share one.
17275
17368
  *
17276
17369
  * @param {string} propertyName - property holding the collection on each owner item
17277
17370
  * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
@@ -17296,6 +17389,25 @@ const createResource = (
17296
17389
  DELETE_MANY,
17297
17390
  } = {},
17298
17391
  ) => {
17392
+ const declarationSite = getDeclarationSite();
17393
+ if (ownerName) {
17394
+ refuseCallbacksNamingScopedItem(
17395
+ `${name}.scopedMany("${propertyName}")`,
17396
+ {
17397
+ GET,
17398
+ GET_MANY,
17399
+ POST,
17400
+ POST_MANY,
17401
+ PUT,
17402
+ PUT_MANY,
17403
+ PATCH,
17404
+ PATCH_MANY,
17405
+ DELETE,
17406
+ DELETE_MANY,
17407
+ },
17408
+ { name, ownerName, propertyName, declarationSite },
17409
+ );
17410
+ }
17299
17411
  const childName = `${name}.${propertyName}`;
17300
17412
 
17301
17413
  // Callbacks added by chained .one()/.many() on the child resource,
@@ -17317,37 +17429,42 @@ const createResource = (
17317
17429
  return childItem;
17318
17430
  },
17319
17431
  });
17320
- const scope = { childStore, idArraySignal: signal([]) };
17321
- scopeMap.set(ownerKey, scope);
17322
- return scope;
17432
+ return { childStore, idArraySignal: signal([]) };
17323
17433
  };
17324
17434
  addItemSetup((item) => {
17325
17435
  const ownerId = item[idKey];
17326
-
17327
- // Reuse an existing scope if one was already created under a uniqueKey
17328
- // value (e.g. rows were fetched by tablename before the full table was loaded).
17329
- let scope = scopeMap.get(ownerId);
17330
- if (!scope) {
17436
+ let scope;
17437
+ if (ownerName) {
17438
+ // The owner is itself a scoped child: its id is only unique inside its
17439
+ // own owner, so the scope is the item's alone, never looked up by id
17440
+ // (no callback addresses it, see refuseCallbacksNamingScopedItem).
17441
+ scope = createScope(ownerId);
17442
+ } else {
17443
+ // Reuse an existing scope if one was already created under a uniqueKey
17444
+ // value (e.g. rows were fetched by tablename before the full table was loaded).
17445
+ scope = scopeMap.get(ownerId);
17446
+ if (!scope) {
17447
+ for (const uniqueKey of uniqueKeys) {
17448
+ const uniqueKeyValue = item[uniqueKey];
17449
+ if (uniqueKeyValue !== undefined && scopeMap.has(uniqueKeyValue)) {
17450
+ scope = scopeMap.get(uniqueKeyValue);
17451
+ break;
17452
+ }
17453
+ }
17454
+ }
17455
+ if (!scope) {
17456
+ scope = createScope(ownerId);
17457
+ }
17458
+ // Register the scope under the id and every uniqueKey value so that
17459
+ // resolveOwnerId can address it whichever key a callback returns.
17460
+ scopeMap.set(ownerId, scope);
17331
17461
  for (const uniqueKey of uniqueKeys) {
17332
17462
  const uniqueKeyValue = item[uniqueKey];
17333
- if (uniqueKeyValue !== undefined && scopeMap.has(uniqueKeyValue)) {
17334
- scope = scopeMap.get(uniqueKeyValue);
17335
- break;
17463
+ if (uniqueKeyValue !== undefined) {
17464
+ scopeMap.set(uniqueKeyValue, scope);
17336
17465
  }
17337
17466
  }
17338
17467
  }
17339
- if (!scope) {
17340
- scope = createScope(ownerId);
17341
- }
17342
- // Register the scope under the id and every uniqueKey value so that
17343
- // resolveOwnerId can address it whichever key a callback returns.
17344
- scopeMap.set(ownerId, scope);
17345
- for (const uniqueKey of uniqueKeys) {
17346
- const uniqueKeyValue = item[uniqueKey];
17347
- if (uniqueKeyValue !== undefined) {
17348
- scopeMap.set(uniqueKeyValue, scope);
17349
- }
17350
- }
17351
17468
 
17352
17469
  const { childStore, idArraySignal } = scope;
17353
17470
  const updateChildItemIdArray = createChildIdArrayUpdater(
@@ -17385,7 +17502,7 @@ const createResource = (
17385
17502
  const createRestActionForScopedMany = (
17386
17503
  verb,
17387
17504
  callback,
17388
- { isMany, onActionComplete },
17505
+ { isMany, onActionComplete, paramScope },
17389
17506
  ) => {
17390
17507
  const childActionName = `${childName}.${verb}`;
17391
17508
  return createAction(callback, {
@@ -17407,7 +17524,11 @@ const createResource = (
17407
17524
  );
17408
17525
  // Owner not in store yet: create the scope so actions can run before
17409
17526
  // the parent item has been loaded (e.g. rows fetched before their table).
17410
- const scope = scopeMap.get(ownerId) || createScope(ownerId);
17527
+ let scope = scopeMap.get(ownerId);
17528
+ if (!scope) {
17529
+ scope = createScope(ownerId);
17530
+ scopeMap.set(ownerId, scope);
17531
+ }
17411
17532
  const { childStore, idArraySignal } = scope;
17412
17533
 
17413
17534
  if (verb === "DELETE") {
@@ -17477,20 +17598,19 @@ const createResource = (
17477
17598
  addItemSetup: childAddItemSetup,
17478
17599
  createRestAction: createRestActionForScopedMany,
17479
17600
  paramScope,
17480
- rerunOn: scopedManyRerunOn ?? rerunOn,
17601
+ rerunOn: resolveRerunOn(scopedManyRerunOn, rerunOn),
17481
17602
  dependencies: scopedManyDependencies ?? dependencies,
17603
+ ownerName: name,
17604
+ // Declared on a scoped child, the scopes are kept per item and never
17605
+ // registered by id (see addItemSetup above): there is no owner key to
17606
+ // find one by.
17607
+ getChildStore: ownerName
17608
+ ? undefined
17609
+ : (ownerKey) => scopeMap.get(ownerKey)?.childStore,
17610
+ // scopedOne needs no such dependency: its mutation result carries the
17611
+ // updated object directly.
17612
+ ownerDependency: { owner: stateFacade, propertyName },
17482
17613
  });
17483
- // When a scoped child collection is mutated (POST etc.), the parent GET must
17484
- // re-fetch: the parent embeds the child array and only the backend knows the
17485
- // new ordering. (scopedOne does not need this: the mutation result contains
17486
- // the updated object directly.)
17487
- resourceLifecycleManager.addDependency(
17488
- childResource,
17489
- stateFacade,
17490
- propertyName,
17491
- );
17492
- childResource.getChildStore = (ownerKey) =>
17493
- scopeMap.get(ownerKey)?.childStore;
17494
17614
  return childResource;
17495
17615
  };
17496
17616
 
@@ -17711,7 +17831,7 @@ const getLastGetItemId = (action) => {
17711
17831
  };
17712
17832
 
17713
17833
  // Captures the "file:line:column" of the user code that invoked the public
17714
- // function (resource(), .one(), .many(), withParams, …), so invalid-result
17834
+ // function (resource(), .one(), .many(), .scopedOne(), …), so invalid-result
17715
17835
  // errors can point at where the callbacks were declared, not at this file.
17716
17836
  // Must be called directly from the public function: the stack offset accounts
17717
17837
  // for exactly two frames (getCallerInfo → getDeclarationSite → public fn → user code).
@@ -17732,6 +17852,36 @@ ${originalActionName} source location: ${declarationSite}`,
17732
17852
  };
17733
17853
  };
17734
17854
 
17855
+ // A scoped child's item exists only inside its owner: two owners can each hold
17856
+ // one with the same id. The callbacks of a relation chained on it whose result
17857
+ // names that item do so by its id alone — `.one()`/`.many()` as the parent
17858
+ // object or id, `.scopedOne()`/`.scopedMany()` as the `ownerId` of
17859
+ // `[ownerId, …]` — so applied they would write the owner's store (`store` of a
17860
+ // scoped child) or the scope of another owner's item. They are refused rather
17861
+ // than routed: routing needs the owner in the result, a contract none of them
17862
+ // has.
17863
+ const refuseCallbacksNamingScopedItem = (
17864
+ relationLabel,
17865
+ callbacks,
17866
+ { name, ownerName, propertyName, declarationSite },
17867
+ ) => {
17868
+ const declaredKeys = [];
17869
+ for (const key of Object.keys(callbacks)) {
17870
+ if (callbacks[key]) {
17871
+ declaredKeys.push(key);
17872
+ }
17873
+ }
17874
+ if (declaredKeys.length === 0) {
17875
+ return;
17876
+ }
17877
+ const declared = declaredKeys.join(", ");
17878
+ throw new Error(
17879
+ `${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.
17880
+ Return "${propertyName}" embedded in what the "${name}" callbacks return ([ownerId, { …, ${propertyName} }]): those say which "${ownerName}".
17881
+ ${relationLabel} source location: ${declarationSite}`,
17882
+ );
17883
+ };
17884
+
17735
17885
  // Shared by .many() and .scopedMany(): converts a raw relationship value (an
17736
17886
  // array of child props/ids, or anything else meaning "empty") into an array of
17737
17887
  // child ids, upserting each entry into the child store. The id array signal is
@@ -17873,22 +18023,28 @@ Received an object with keys: ${keys.join(", ")}.`,
17873
18023
  * // which in turn triggers the route Signal->URL sync and updates the browser URL.
17874
18024
  */
17875
18025
  const syncResourceToSignals = (resource, propertyToSignalMap) => {
17876
- if (resource.getChildStore) {
18026
+ if (resource.ownerName) {
18027
+ // A scoped child's `store` is its owner's: watching it would sync the
18028
+ // owner items' properties, not the child's.
17877
18029
  throw new Error(
17878
- `syncResourceToSignals: "${resource.name}" is a scoped resource (scopedMany/scopedOne). Use syncOwnedResourceToSignals instead.`,
18030
+ `syncResourceToSignals: "${resource.name}" lives inside each "${resource.ownerName}", so it has no store of its own to watch.${resource.getChildStore ? " Use syncOwnedResourceToSignals instead." : ""}`,
17879
18031
  );
17880
18032
  }
17881
18033
  syncStoreToSignals(resource.store, propertyToSignalMap);
17882
18034
  };
17883
18035
 
17884
18036
  /**
17885
- * The same, for a `scopedOne`/`scopedMany` resource: its items live in one store
17886
- * per owner, so the store to watch is the one of the owner `ownerSignal` names
17887
- * (by id or by any unique key), and it is switched when the signal changes.
17888
- * Nothing is synced while `ownerSignal` holds `null`/`undefined` or names an
17889
- * owner nothing has been loaded for yet.
18037
+ * The same, for a `scopedMany` resource (or a `withParams()` of one): its items
18038
+ * live in one store per owner, so the store to watch is the one of the owner
18039
+ * `ownerSignal` names (by id or by any unique key), and it is switched when the
18040
+ * signal changes. Nothing is synced while `ownerSignal` holds `null`/`undefined`
18041
+ * or names an owner nothing has been loaded for yet.
17890
18042
  *
17891
- * @param {Object} resource - a resource made by `.scopedOne()` / `.scopedMany()`
18043
+ * A `scopedOne` child has no store (each owner holds its one item), and a
18044
+ * `scopedMany` declared on a scoped child is kept per item with no owner key to
18045
+ * find it by: both are refused.
18046
+ *
18047
+ * @param {Object} resource - a resource made by `.scopedMany()`
17892
18048
  * @param {import("@preact/signals").Signal} ownerSignal - the owner whose children are watched
17893
18049
  * @param {Object} propertyToSignalMap - `{ [propertyName]: signal }`, as for `syncResourceToSignals`
17894
18050
  */
@@ -17898,8 +18054,13 @@ const syncOwnedResourceToSignals = (
17898
18054
  propertyToSignalMap,
17899
18055
  ) => {
17900
18056
  if (!resource.getChildStore) {
18057
+ if (!resource.ownerName) {
18058
+ throw new Error(
18059
+ `syncOwnedResourceToSignals: "${resource.name}" is not a scoped resource. Use syncResourceToSignals instead.`,
18060
+ );
18061
+ }
17901
18062
  throw new Error(
17902
- `syncOwnedResourceToSignals: "${resource.name}" is not a scoped resource (scopedMany/scopedOne). Use syncResourceToSignals instead.`,
18063
+ `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.`,
17903
18064
  );
17904
18065
  }
17905
18066
  effect(() => {
@@ -20196,9 +20357,9 @@ const isSizeSpacingKey = (key) => {
20196
20357
  // "vvw"/"vvh" are navi's own: the *visual* viewport, which — unlike vw/dvw —
20197
20358
  // shrinks when the mobile virtual keyboard opens (see layout/responsive.js), so
20198
20359
  // they are what a popup meant to stay clear of the keyboard should use.
20199
- // "appw"/"apph" are the same thing narrowed to the app's own screen: identical
20200
- // to vvw/vvh until the app declares --navi-app-max-width, and a share of that
20201
- // width afterwards. A gap meant to read as "a small margin" must use these —
20360
+ // "appw"/"apph" are the same thing narrowed to the app's own screen: the
20361
+ // visual viewport minus the app's bands (--navi-app-inset-*), so identical to
20362
+ // vvw/vvh until the app declares bands. A gap meant to read as "a small margin" must use these —
20202
20363
  // 3vvw on a 1500px window is a 45px gap around a 600px app.
20203
20364
  // Functions rather than the signals themselves: appw/apph are not a signal to
20204
20365
  // read but a value to compute (a signal, then a CSS var read back). Reading the
@@ -30194,7 +30355,7 @@ const TRANSITION_FURNITURE_CSS = /* css */ `.navi_fixed_bar[data-navi-transition
30194
30355
  clip-path: var(--wall-holes-arriving, var(--wall-holes));
30195
30356
  }
30196
30357
 
30197
- :root[data-navi-route-transition] {
30358
+ :root[data-navi-route-transition-target="area"] {
30198
30359
  & .navi_dialog::backdrop, & .navi_popover_backdrop {
30199
30360
  backdrop-filter: none;
30200
30361
  background: none;
@@ -30202,8 +30363,8 @@ const TRANSITION_FURNITURE_CSS = /* css */ `.navi_fixed_bar[data-navi-transition
30202
30363
  }
30203
30364
  `;
30204
30365
 
30205
- // Called from the render of whatever holds the pages, never at module scope:
30206
- // a page that never travels between routes must not carry this sheet, and a
30366
+ // Called as a movement starts over a marked area, never at module scope: a
30367
+ // page that never travels between routes must not carry this sheet, and a
30207
30368
  // build that sees no caller drops the css with the function.
30208
30369
  const installTransitionFurnitureCss = () => {
30209
30370
  import.meta.css = [TRANSITION_FURNITURE_CSS, "@jsenv/navi/src/nav/transition_furniture.js"];
@@ -30339,10 +30500,7 @@ const nameFurnitureAround = (areaElement) => {
30339
30500
  if (!name) {
30340
30501
  // A name the application wrote itself answers for that element, and it
30341
30502
  // is saying something navi is not: it wants that bar moved on the pages'
30342
- // clock, by its own CSS. Asked only of a bar navi has never named — a
30343
- // bar wearing one of ITS names is one this movement inherited from the
30344
- // one it interrupted, and taking it for the application's would leave it
30345
- // named for the rest of the document's life.
30503
+ // clock, by its own CSS.
30346
30504
  if (getComputedStyle(element).viewTransitionName !== "none") {
30347
30505
  continue;
30348
30506
  }
@@ -30666,9 +30824,10 @@ const TRANSITION_WINDOW_CSS = /* css */ `@property --navi-transition-cover-top {
30666
30824
  }
30667
30825
  `;
30668
30826
 
30669
- // Called from the render of whatever needs the window, never at module scope:
30670
- // a page that never travels between routes must not carry this sheet, and a
30671
- // build that sees no caller drops the css with the function.
30827
+ // Called by whatever needs the window — a render, or a movement starting —
30828
+ // never at module scope: a page that never travels between routes must not
30829
+ // carry this sheet, and a build that sees no caller drops the css with the
30830
+ // function.
30672
30831
  const installTransitionWindowCss = () => {
30673
30832
  import.meta.css = [TRANSITION_WINDOW_CSS, "@jsenv/navi/src/nav/transition_window.js"];
30674
30833
  };
@@ -31481,9 +31640,6 @@ const RouteTransitionArea = ({
31481
31640
  children,
31482
31641
  ...rest
31483
31642
  }) => {
31484
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
31485
- installTransitionWindowCss();
31486
- installTransitionFurnitureCss();
31487
31643
  const props = {
31488
31644
  ...rest,
31489
31645
  [TRANSITION_AREA_ATTRIBUTE]: ""
@@ -31552,8 +31708,6 @@ const RouteTransitionArea = ({
31552
31708
  * @returns {() => void} remove this relation.
31553
31709
  */
31554
31710
  const defineRouteTransition = (from, to, transition) => {
31555
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
31556
- installTransitionWindowCss();
31557
31711
  if (!to) {
31558
31712
  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.`);
31559
31713
  }
@@ -31593,8 +31747,6 @@ const defineRouteTransition = (from, to, transition) => {
31593
31747
  * @returns {() => void} remove this default.
31594
31748
  */
31595
31749
  const defineRouteDefaultTransition = transition => {
31596
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
31597
- installTransitionWindowCss();
31598
31750
  const value = normalizeTransition(transition);
31599
31751
  defaultTransition = value;
31600
31752
  return () => {
@@ -32170,12 +32322,25 @@ const beginTransition = ({
32170
32322
  url: absoluteUrl(url),
32171
32323
  decision: navigationDecision,
32172
32324
  walkHome: null,
32173
- releaseReverting: null
32325
+ releaseReverting: null,
32326
+ restoreDuration: null
32174
32327
  };
32328
+ // The document wears one transition at a time, and the one this interrupts
32329
+ // ends only after this one has begun — too late to take off what it wears
32330
+ // (see end). Taking over therefore starts by taking it off, so that what is
32331
+ // put on below describes this transition alone, including what it leaves
32332
+ // unset: a type, an area, a duration.
32333
+ if (currentTransition) {
32334
+ releaseTransitionRoot(currentTransition);
32335
+ }
32175
32336
  currentTransition = transition;
32176
32337
  // Said before the picture is taken: whoever names something for a movement
32177
32338
  // between two pages decides on it now (see transition_destination.js).
32178
32339
  holdTransitionDestination(transition, url);
32340
+ // Adopted by what starts a movement rather than by what declares one: a link
32341
+ // or a navTo() asks for a movement in an app that declared none, and an app
32342
+ // that never moves never carries the sheet.
32343
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
32179
32344
  documentElement.setAttribute(TRANSITION_ATTRIBUTE, direction);
32180
32345
  if (type) {
32181
32346
  documentElement.setAttribute(TRANSITION_TYPE_ATTRIBUTE, type);
@@ -32197,6 +32362,10 @@ const beginTransition = ({
32197
32362
  // left (see transition_window.js).
32198
32363
  let areaStateBefore = null;
32199
32364
  if (areaElement) {
32365
+ // The area is the application's element as often as <RouteTransitionArea>,
32366
+ // so what a movement on it plays with is adopted here, where it is found.
32367
+ installTransitionWindowCss();
32368
+ installTransitionFurnitureCss();
32200
32369
  documentElement.setAttribute(TRANSITION_TARGET_ATTRIBUTE, "area");
32201
32370
  // Said before the picture is taken, like every name (see
32202
32371
  // transition_furniture.js): what the bars are wearing when the transition
@@ -32207,11 +32376,10 @@ const beginTransition = ({
32207
32376
  // A duration of this relation's own, worn for the length of the transition —
32208
32377
  // and whatever the application had written inline put back afterwards, not
32209
32378
  // erased.
32210
- let restoreDuration = null;
32211
32379
  if (duration !== undefined) {
32212
32380
  const durationBefore = documentElement.style.getPropertyValue(TRANSITION_DURATION_PROPERTY);
32213
32381
  documentElement.style.setProperty(TRANSITION_DURATION_PROPERTY, typeof duration === "number" ? `${duration}ms` : duration);
32214
- restoreDuration = () => {
32382
+ transition.restoreDuration = () => {
32215
32383
  if (durationBefore) {
32216
32384
  documentElement.style.setProperty(TRANSITION_DURATION_PROPERTY, durationBefore);
32217
32385
  } else {
@@ -32272,8 +32440,11 @@ const beginTransition = ({
32272
32440
  renderWait.stop();
32273
32441
  }
32274
32442
  // The page arriving is in the DOM and the transition has not started
32275
- // playing: the one moment both states of the area can be known.
32276
- if (areaElement) {
32443
+ // playing: the one moment both states of the area can be known. The
32444
+ // browser runs this callback even for a transition skipped before its
32445
+ // first picture — the next one starting skips it — and a transition
32446
+ // replaced holds nothing: what it wore was taken off at the takeover.
32447
+ if (areaElement && currentTransition === transition) {
32277
32448
  holdTransitionFurniture(transition, areaElement);
32278
32449
  holdTransitionWindow(transition, areaElement, areaStateBefore);
32279
32450
  }
@@ -32282,8 +32453,9 @@ const beginTransition = ({
32282
32453
  // Whatever ends it — played out, skipped by another transition starting,
32283
32454
  // failed before its callback ever ran — the hold is given back and the
32284
32455
  // document is handed back to the application. Both are idempotent, and
32285
- // the attributes belong to the LAST transition begun: an earlier one
32286
- // ending late must not strip what a later one is wearing.
32456
+ // the document is handed back only by the LAST transition begun: an
32457
+ // earlier one ending late was taken off by the one that replaced it, and
32458
+ // must not strip what that one is wearing.
32287
32459
  renderWait.stop();
32288
32460
  releaseRendering();
32289
32461
  // The hold a way back took, when it is still standing: the pictures were
@@ -32295,16 +32467,7 @@ const beginTransition = ({
32295
32467
  }
32296
32468
  if (currentTransition === transition) {
32297
32469
  currentTransition = null;
32298
- documentElement.removeAttribute(TRANSITION_ATTRIBUTE);
32299
- documentElement.removeAttribute(TRANSITION_TYPE_ATTRIBUTE);
32300
- documentElement.removeAttribute(TRANSITION_TARGET_ATTRIBUTE);
32301
- releaseTransitionWindow(transition);
32302
- releaseTransitionDestination(transition);
32303
- releaseTransitionFurniture(transition);
32304
- releaseTransitionPress(transition);
32305
- if (restoreDuration) {
32306
- restoreDuration();
32307
- }
32470
+ releaseTransitionRoot(transition);
32308
32471
  }
32309
32472
  };
32310
32473
  transition.viewTransition = viewTransition;
@@ -32312,6 +32475,22 @@ const beginTransition = ({
32312
32475
  viewTransition.finished.then(end, end);
32313
32476
  };
32314
32477
 
32478
+ // Everything a transition wears on the document for its length, taken off by
32479
+ // its own end or by the transition taking over from it.
32480
+ const releaseTransitionRoot = transition => {
32481
+ const documentElement = document.documentElement;
32482
+ documentElement.removeAttribute(TRANSITION_ATTRIBUTE);
32483
+ documentElement.removeAttribute(TRANSITION_TYPE_ATTRIBUTE);
32484
+ documentElement.removeAttribute(TRANSITION_TARGET_ATTRIBUTE);
32485
+ releaseTransitionWindow(transition);
32486
+ releaseTransitionDestination(transition);
32487
+ releaseTransitionFurniture(transition);
32488
+ releaseTransitionPress(transition);
32489
+ if (transition.restoreDuration) {
32490
+ transition.restoreDuration();
32491
+ }
32492
+ };
32493
+
32315
32494
  /**
32316
32495
  * The movement playing, turned round rather than replaced: this navigation is
32317
32496
  * exactly the way back of the one on screen — the door pressed again while the
@@ -45400,16 +45579,6 @@ const css$Z = /* css */`@layer navi {
45400
45579
  border-radius: .2em;
45401
45580
  }
45402
45581
 
45403
- &[data-capitalize] {
45404
- text-transform: capitalize;
45405
-
45406
- & .navi_text_sizer {
45407
- & .navi_text {
45408
- display: inline-block;
45409
- }
45410
- }
45411
- }
45412
-
45413
45582
  &[data-shrinkwrap] {
45414
45583
  display: inline-block;
45415
45584
  }
@@ -45432,12 +45601,6 @@ time.navi_text {
45432
45601
  }
45433
45602
 
45434
45603
  .navi_text {
45435
- &[data-capitalize] {
45436
- &:first-letter, & .navi_text_sizer_placeholder:first-letter, & .navi_text_sizer_overlay:first-letter {
45437
- text-transform: uppercase;
45438
- }
45439
- }
45440
-
45441
45604
  & .navi_text_sizer, & .navi_text_sizer_placeholder, & .navi_text_sizer_overlay {
45442
45605
  display: inherit;
45443
45606
  width: inherit;
@@ -45885,7 +46048,9 @@ const shouldInjectSpacingBetween = (left, right) => {
45885
46048
  * text block from being wider than its content when inside a flex/grid container.
45886
46049
  *
45887
46050
  * @param {boolean} [capitalize]
45888
- * Uppercases the first letter of the text content via CSS.
46051
+ * Uppercases the first letter of the text, and only that one: "lundi 11 mai"
46052
+ * reads "Lundi 11 mai". Applied to the string the text starts with; a text
46053
+ * starting with an element keeps its first letter as written.
45889
46054
  *
45890
46055
  * @param {string|[number,number]|[string,string]} [selectRange]
45891
46056
  * Selects a portion of the text on mount. Pass a substring to search for, a
@@ -46005,11 +46170,13 @@ const TextUI = props => {
46005
46170
  shrinkWrap,
46006
46171
  ...rest
46007
46172
  } = props;
46173
+ if (capitalize) {
46174
+ children = capitalizeFirstLetter(children);
46175
+ }
46008
46176
  const defaultSpace = preventSpaceUnderlines ? FAKE_SPACE : REGULAR_SPACE;
46009
46177
  const resolvedSpacing = spacing ?? defaultSpace;
46010
46178
  const boxProps = {
46011
46179
  "as": "span",
46012
- "data-capitalize": capitalize ? "" : undefined,
46013
46180
  "data-shrinkwrap": shrinkWrap ? "" : undefined,
46014
46181
  ...rest,
46015
46182
  ref,
@@ -46310,6 +46477,28 @@ const Icon = ({
46310
46477
  });
46311
46478
  };
46312
46479
 
46480
+ // On the string rather than with ::first-letter, which needs a block container:
46481
+ // an inline text made one is an atomic box, and the ellipsis of the line
46482
+ // holding it (a Picker's value) can then no longer cut it. The string is also
46483
+ // what every copy of the children (sizer, bold background) renders, so they
46484
+ // all read the same. Leading spaces and punctuation are skipped, and a text
46485
+ // starting with a digit is left as it is, as ::first-letter would.
46486
+ const capitalizeFirstLetter = children => {
46487
+ if (typeof children === "string") {
46488
+ return children.replace(FIRST_LOWERCASE_LETTER_REGEXP, (match, lead, letter) => `${lead}${letter.toLocaleUpperCase()}`);
46489
+ }
46490
+ if (Array.isArray(children) && children.length > 0) {
46491
+ const first = children[0];
46492
+ const firstCapitalized = capitalizeFirstLetter(first);
46493
+ if (firstCapitalized === first) {
46494
+ return children;
46495
+ }
46496
+ return [firstCapitalized, ...children.slice(1)];
46497
+ }
46498
+ return children;
46499
+ };
46500
+ const FIRST_LOWERCASE_LETTER_REGEXP = /^([\s\p{P}]*)(\p{Ll})/u;
46501
+
46313
46502
  /**
46314
46503
  * Where a link leads, asked for when the pointer or the focus arrives on it:
46315
46504
  * the moment the user signals a destination, before the press. Only what the
@@ -61246,7 +61435,6 @@ const createSwipeToClose = (side, { grip } = {}) => {
61246
61435
  panelEl.style.transform = "";
61247
61436
  panelEl.style.clipPath = "";
61248
61437
  panelEl.style.transitionProperty = "";
61249
- panelEl.style.userSelect = "";
61250
61438
  };
61251
61439
  // The resting value is written first and the animation only covers the way
61252
61440
  // to it: both end on the same number, so nothing is seen changing when the
@@ -61290,10 +61478,6 @@ const createSwipeToClose = (side, { grip } = {}) => {
61290
61478
  return false;
61291
61479
  }
61292
61480
  panelEl.style.transitionProperty = "none";
61293
- panelEl.style.userSelect = "none";
61294
- // What the first pixels of the gesture may have started selecting is
61295
- // not a selection, it is the beginning of this travel.
61296
- window.getSelection().removeAllRanges();
61297
61481
  // The way out is the only way there is anything: pulling the other way
61298
61482
  // leans against a wall and comes back.
61299
61483
  return {
@@ -61999,7 +62183,7 @@ const css$E = /* css */`
61999
62183
  --navi-app-width rather than 3vvw): the gap must read as a small
62000
62184
  margin around the dialog, and 3% of a 1500px window is a 45px gap
62001
62185
  around a 600px app. Identical to 3vvw until the app declares
62002
- --navi-app-max-width. */
62186
+ bands (--navi-app-max-width, or --navi-app-inset-* written by it). */
62003
62187
  --x-dialog-container-spacing: calc(0.03 * var(--navi-app-width));
62004
62188
 
62005
62189
  /* --navi-app-width, not --navi-vvw: a top-layer dialog is calibrated on
@@ -62736,8 +62920,8 @@ const css$E = /* css */`
62736
62920
  * `positionArea`: it both caps the dialog's own size (via
62737
62921
  * `--x-dialog-container-spacing`, written from this prop) and offsets a docked
62738
62922
  * one from the edge it docks to. Accepts a number of pixels, a viewport
62739
- * length — "appw"/"apph" being the app's own screen (the visual viewport, or
62740
- * the narrower one the app declared with --navi-app-max-width) and
62923
+ * length — "appw"/"apph" being the app's own screen (the visual viewport
62924
+ * minus the app's bands, see docs/safe_area.md) and
62741
62925
  * "vvw"/"vvh" the visual viewport itself, which shrinks when the mobile
62742
62926
  * keyboard opens — or a container length ("3cqw", the `layer="local"`
62743
62927
  * default). Pass 0 for a dialog meant to sit flush (a side panel).
@@ -65881,6 +66065,8 @@ const css$C = /* css */`@layer navi {
65881
66065
  * `maxWidth` below.
65882
66066
  * @param {boolean} [props.sizeFromAnchor] - **Dialog-only**, same guard.
65883
66067
  * @param {"box"|"scene"} [props.lift] - **Dialog-only**, same guard.
66068
+ * @param {Element|{current: Element}|string} [props.liftAnchor] -
66069
+ * **Dialog-only**, same guard.
65884
66070
  * @param {string} [props.positionArea] - Forwarded as-is — `Dialog` and
65885
66071
  * `Popover` have different own defaults (`"center"` vs. `"bottom"`),
65886
66072
  * deliberately not homogenized here (each reads best for its own typical
@@ -65914,8 +66100,13 @@ const css$C = /* css */`@layer navi {
65914
66100
  * identically): the wash the backdrop paints over what is behind.
65915
66101
  * @param {string} [props.backdropFilter] - Forwarded as-is: what that wash
65916
66102
  * does to the picture underneath, `"blur(4px)"` and the like.
65917
- * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|"expanding"|`slide-from-${string}`|`expand-${string}`} [props.animation]
65918
- * - Forwarded as-is.
66103
+ * @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]
66104
+ * - Forwarded to whichever renders — see either component's own doc.
66105
+ * `"expanding"`/`"expand-*"` are `Popover`'s own; `"lifting"` and
66106
+ * `{ open, close: "lifting" }` are `Dialog`'s. A popover has no lift, so in
66107
+ * popover mode `"lifting"` plays no movement (what a dialog does with no
66108
+ * anchor to lift out of) and the object form opens with its `open`, which
66109
+ * the close plays backwards like every other kind.
65919
66110
  * @param {string} [props.animationDuration] - Forwarded as-is.
65920
66111
  * @param {string} [props.maxWidth] - Forwarded as-is to both; also read
65921
66112
  * here directly to help decide the automatic `mode` (a fixed length under
@@ -65981,6 +66172,8 @@ const Popup = props => {
65981
66172
  // mode the automatic screen-size resolution happens to pick.
65982
66173
  pressOutside = "close",
65983
66174
  backdrop,
66175
+ // A popover is handed only the values it understands (toPopoverAnimation).
66176
+ animation,
65984
66177
  // Popover-only (see this component's own doc) — destructured out so
65985
66178
  // they're never part of ...rest, and therefore never forwarded to
65986
66179
  // Dialog below, where they'd otherwise leak onto the real <dialog>
@@ -65995,6 +66188,7 @@ const Popup = props => {
65995
66188
  dockedOnSmallTouchScreen,
65996
66189
  sizeFromAnchor,
65997
66190
  lift,
66191
+ liftAnchor,
65998
66192
  ...rest
65999
66193
  } = props;
66000
66194
  const [mode] = useResolvedPopupMode(modeProp, maxWidth, {
@@ -66014,6 +66208,8 @@ const Popup = props => {
66014
66208
  dockedOnSmallTouchScreen: dockedOnSmallTouchScreen,
66015
66209
  sizeFromAnchor: sizeFromAnchor,
66016
66210
  lift: lift,
66211
+ liftAnchor: liftAnchor,
66212
+ animation: animation,
66017
66213
  maxWidth: maxWidth,
66018
66214
  pressOutside: pressOutside,
66019
66215
  backdrop: backdrop,
@@ -66027,6 +66223,7 @@ const Popup = props => {
66027
66223
  }
66028
66224
  return jsx(Popover, {
66029
66225
  ...rest,
66226
+ animation: toPopoverAnimation(animation),
66030
66227
  maxWidth: maxWidth,
66031
66228
  pressOutside: pressOutside,
66032
66229
  backdrop: backdrop,
@@ -66044,6 +66241,18 @@ const Popup = props => {
66044
66241
  };
66045
66242
  Popup.Close = PopupClose;
66046
66243
 
66244
+ // What Dialog's own animation values mean to a popover, which has no lift
66245
+ // (see the `animation` doc above).
66246
+ const toPopoverAnimation = animation => {
66247
+ if (animation === "lifting") {
66248
+ return undefined;
66249
+ }
66250
+ if (animation !== null && typeof animation === "object") {
66251
+ return animation.open;
66252
+ }
66253
+ return animation;
66254
+ };
66255
+
66047
66256
  installImportMetaCssBuild(import.meta);
66048
66257
  const css$B = /* css */`.navi_picker_callout_dock > [data-picker-content] {
66049
66258
  display: none;
@@ -67368,9 +67577,14 @@ const PickerNaviMinute = props => {
67368
67577
  step,
67369
67578
  value
67370
67579
  } = props;
67580
+
67581
+ // resolveInputProps has already run (PickerFirstResolver), so the type
67582
+ // handed on is one the picker renders as is — the text field a navi numeric
67583
+ // type resolves to, told what its value is.
67371
67584
  return jsx(Next, {
67372
67585
  ...props,
67373
- type: "minute",
67586
+ type: "text",
67587
+ "navi-input-type": "minute",
67374
67588
  children: jsxs(Box, {
67375
67589
  flex: "y",
67376
67590
  spacing: "s",
@@ -74077,11 +74291,15 @@ const PickerNaviTime = props => {
74077
74291
  max = "23:30",
74078
74292
  step
74079
74293
  } = props;
74080
- const stepSeconds = timeStringToSeconds(step) ?? 1800;
74294
+ // resolveInputProps has already run (PickerFirstResolver) and left navi_time
74295
+ // as written: what is handed on is a time input, whose step is in seconds.
74296
+ const hostStep = timeStringToSeconds(step);
74297
+ const stepSeconds = hostStep ?? 1800;
74081
74298
  const slots = useMemo(() => generateTimeSlots(min, max, stepSeconds), [min, max, stepSeconds]);
74082
74299
  return jsx(Next, {
74083
74300
  ...props,
74084
74301
  type: "time",
74302
+ step: hostStep,
74085
74303
  children: jsx(List, {
74086
74304
  selectable: true,
74087
74305
  command: "--navi-send",
@@ -76525,7 +76743,7 @@ const PickerFirstResolver = props => {
76525
76743
  * `focusOnOpen` below.
76526
76744
  *
76527
76745
  * @type {import("ignore:preact").FunctionComponent<{
76528
- * type?: "date" | "month" | "week" | "time" | "datetime" | "duration" | "color" | "file" | "text" | "object" | "array" | "confirm" | "navi_time" | "navi_number" | "navi_percentage",
76746
+ * type?: "date" | "month" | "week" | "time" | "datetime" | "duration" | "color" | "file" | "text" | "object" | "array" | "confirm" | "navi_time" | "navi_minute" | "navi_number" | "navi_percentage",
76529
76747
  * value?: any,
76530
76748
  * defaultValue?: any,
76531
76749
  * name?: string,
@@ -77127,6 +77345,10 @@ const warnOnUnknownPickerType = props => {
77127
77345
  pickerTypeWarnedSet.add(type);
77128
77346
  console.warn(`[navi] <Picker type="${type}"> — "${type}" is not a picker type. ` + `The picker holds a single text value instead, so an object given to it is written as "[object Object]". ` + `Types are: date, month, week, time, datetime, duration, color, file, text, array, object, confirm — ` + `"object" being the one for a popup holding several named controls.`);
77129
77347
  };
77348
+
77349
+ // PickerFirstResolver comes before the presets so a preset reads the
77350
+ // min/max/step a bound signal fills in (resolveInputProps); a preset type
77351
+ // therefore has no row in NAVI_TYPE_DEFAULTS (resolve_input_props.js).
77130
77352
  const PickerResolved = /*#__PURE__*/createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerConfirmResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
77131
77353
  const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
77132
77354
  Date: PickerDateUI,
@@ -88548,6 +88770,8 @@ const css$3 = /* css */`@layer navi {
88548
88770
  }
88549
88771
 
88550
88772
  .navi_card_layout {
88773
+ box-sizing: border-box;
88774
+ min-height: 100%;
88551
88775
  padding-top: var(--layout-margin-top, var(--layout-margin-y, var(--layout-margin)));
88552
88776
  padding-right: var(--layout-margin-right, var(--layout-margin-x, var(--layout-margin)));
88553
88777
  padding-bottom: var(--layout-margin-bottom, var(--layout-margin-y, var(--layout-margin)));
@@ -88589,6 +88813,11 @@ const CardLayoutStyleCSSVars = {
88589
88813
  minHeight: "--layout-min-height"
88590
88814
  };
88591
88815
  /**
88816
+ * The card is centered in the area both ways and is as wide as its content,
88817
+ * `minWidth` at least. Down the area, the layout takes at least the height of
88818
+ * what holds it, so how tall that area is is up to the page around it, as for
88819
+ * `ViewportLayout`.
88820
+ *
88592
88821
  * @type {import("ignore:preact").FunctionComponent<{
88593
88822
  * alignX?: string,
88594
88823
  * alignY?: string,
@@ -88618,6 +88847,11 @@ const CardLayout = ({
88618
88847
  }) => {
88619
88848
  import.meta.css = [css$3, "@jsenv/navi/src/layout/card_layout.jsx"];
88620
88849
  return jsx(Box, {
88850
+ flex: true
88851
+ // The card's place in the area; alignX/alignY below are its content's.
88852
+ ,
88853
+ alignX: "center",
88854
+ alignY: "center",
88621
88855
  baseClassName: "navi_card_layout",
88622
88856
  styleCSSVars: CardLayoutStyleCSSVars,
88623
88857
  visualSelector: ".navi_card",