@jsenv/navi 0.29.389 → 0.29.391

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.
@@ -4794,14 +4794,11 @@ const CONTROL_TYPE_BY_SIGNAL_TYPE = {
4794
4794
  // become something plainer — it is what says the value is a number (see
4795
4795
  // isNumberInput), and what lets constraint messages use domain-specific
4796
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.
4797
4801
  const NAVI_TYPE_DEFAULTS = {
4798
- navi_time: {
4799
- "type": "time",
4800
- "navi-input-type": "time",
4801
- "min": 0,
4802
- "max": 24 * 3600 - 1,
4803
- "step": 1,
4804
- },
4805
4802
  navi_percentage: {
4806
4803
  "type": "navi_number",
4807
4804
  "navi-input-type": "percentage",
@@ -4863,7 +4860,6 @@ const isNumberInput = (type, naviInputType) => {
4863
4860
  * Supported navi types and their targets:
4864
4861
  * - `navi_percentage` → `navi_number` (0–100, step 1)
4865
4862
  * - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
4866
- * - `navi_time` → `time` (step in seconds)
4867
4863
  *
4868
4864
  * Standard HTML input types with formatters:
4869
4865
  * - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
@@ -16014,6 +16010,15 @@ const createResource = (
16014
16010
  paramScope,
16015
16011
  rerunOn,
16016
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,
16017
16022
  },
16018
16023
  ) => {
16019
16024
  const params = paramScope.params;
@@ -16039,6 +16044,8 @@ const createResource = (
16039
16044
  // private but exposed for convenience
16040
16045
  store,
16041
16046
  addItemSetup,
16047
+ ownerName,
16048
+ getChildStore,
16042
16049
  };
16043
16050
 
16044
16051
  resourceLifecycleManager.registerResource(stateFacade, {
@@ -16047,6 +16054,17 @@ const createResource = (
16047
16054
  dependencies,
16048
16055
  uniqueKeys,
16049
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
+ }
16050
16068
  const onActionComplete = (actionCompleted) => {
16051
16069
  resourceLifecycleManager.onActionComplete(actionCompleted, {
16052
16070
  resourceScope: stateFacade,
@@ -16059,6 +16077,10 @@ const createResource = (
16059
16077
  * Actions from parameterized resources only trigger rerun/reset for other actions with
16060
16078
  * identical parameters, preventing cross-contamination between different parameter sets.
16061
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
+ *
16062
16084
  * @param {Object} params - Parameters to bind to all actions of this resource (required)
16063
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
16064
16086
  * @returns {Object} A new resource instance with parameter-bound actions and isolated lifecycle
@@ -16084,7 +16106,6 @@ const createResource = (
16084
16106
  paramsToInject,
16085
16107
  { dependencies: withParamsDeps, rerunOn: withParamsRerunOn } = {},
16086
16108
  ) => {
16087
- const declarationSite = getDeclarationSite();
16088
16109
  if (!paramsToInject || Object.keys(paramsToInject).length === 0) {
16089
16110
  throw new Error(`resource(${name}).withParams() requires parameters`);
16090
16111
  }
@@ -16092,11 +16113,6 @@ const createResource = (
16092
16113
  ? { ...params, ...paramsToInject }
16093
16114
  : paramsToInject;
16094
16115
  const resolvedParamScope = getParamScope(resolvedParams);
16095
- const createRestActionWithParams = createRestActionFactoryForRoot(name, {
16096
- idKey,
16097
- store,
16098
- declarationSite,
16099
- });
16100
16116
  return createResource(name, {
16101
16117
  idKey,
16102
16118
  uniqueKeys,
@@ -16104,10 +16120,16 @@ const createResource = (
16104
16120
  restCallbacks,
16105
16121
  store,
16106
16122
  addItemSetup,
16107
- 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,
16108
16127
  paramScope: resolvedParamScope,
16109
16128
  rerunOn: resolveRerunOn(withParamsRerunOn, rerunOn),
16110
16129
  dependencies: withParamsDeps ?? dependencies,
16130
+ ownerName,
16131
+ getChildStore,
16132
+ ownerDependency,
16111
16133
  });
16112
16134
  };
16113
16135
  stateFacade.withParams = withParams;
@@ -16152,6 +16174,13 @@ const createResource = (
16152
16174
  } = {},
16153
16175
  ) => {
16154
16176
  const declarationSite = getDeclarationSite();
16177
+ if (ownerName) {
16178
+ refuseCallbacksNamingScopedItem(
16179
+ `${name}.one("${propertyName}")`,
16180
+ { GET, PUT, DELETE },
16181
+ { name, ownerName, propertyName, declarationSite },
16182
+ );
16183
+ }
16155
16184
  const childName = `${name}.${propertyName}`;
16156
16185
  const childIdKey = childResource.idKey;
16157
16186
  const childStore = childResource.store;
@@ -16212,7 +16241,11 @@ const createResource = (
16212
16241
  );
16213
16242
  });
16214
16243
 
16215
- const createRestActionForOne = (verb, callback, { onActionComplete }) => {
16244
+ const createRestActionForOne = (
16245
+ verb,
16246
+ callback,
16247
+ { onActionComplete, paramScope },
16248
+ ) => {
16216
16249
  const applyResultToValue =
16217
16250
  verb === "DELETE"
16218
16251
  ? (itemIdOrItemProps) => {
@@ -16327,6 +16360,13 @@ const createResource = (
16327
16360
  } = {},
16328
16361
  ) => {
16329
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
+ }
16330
16370
  const childStore = childResource.store;
16331
16371
  const childIdKey = childResource.idKey;
16332
16372
  const childName = `${name}.${propertyName}`;
@@ -16358,21 +16398,23 @@ const createResource = (
16358
16398
  const createRestActionForMany = (
16359
16399
  verb,
16360
16400
  callback,
16361
- { isMany, onActionComplete },
16401
+ { isMany, onActionComplete, paramScope },
16362
16402
  ) => {
16363
16403
  if (!isMany) {
16364
16404
  return createRestActionAffectingOneItem(verb, callback, {
16365
16405
  onActionComplete,
16406
+ paramScope,
16366
16407
  });
16367
16408
  }
16368
16409
  return createRestActionAffectingManyItems(verb, callback, {
16369
16410
  onActionComplete,
16411
+ paramScope,
16370
16412
  });
16371
16413
  };
16372
16414
  const createRestActionAffectingOneItem = (
16373
16415
  verb,
16374
16416
  callback,
16375
- { onActionComplete },
16417
+ { onActionComplete, paramScope },
16376
16418
  ) => {
16377
16419
  const applyResultToValue =
16378
16420
  verb === "DELETE"
@@ -16435,7 +16477,7 @@ const createResource = (
16435
16477
  const createRestActionAffectingManyItems = (
16436
16478
  verb,
16437
16479
  callback,
16438
- { onActionComplete },
16480
+ { onActionComplete, paramScope },
16439
16481
  ) => {
16440
16482
  const applyResultToValue =
16441
16483
  verb === "GET"
@@ -16563,7 +16605,12 @@ const createResource = (
16563
16605
  * Mutations apply directly to the owner's signal, so the parent GET is never rerun.
16564
16606
  *
16565
16607
  * Returns the child relationship resource, itself chainable:
16566
- * `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.
16567
16614
  *
16568
16615
  * @param {string} propertyName - property holding the sub-object on each owner item
16569
16616
  * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, POST, PUT, PATCH, DELETE }`
@@ -16583,6 +16630,14 @@ const createResource = (
16583
16630
  DELETE,
16584
16631
  } = {},
16585
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
+ }
16586
16641
  const childName = `${name}.${propertyName}`;
16587
16642
 
16588
16643
  // Callbacks added by chained .one()/.many() on the child resource,
@@ -16612,7 +16667,12 @@ const createResource = (
16612
16667
  childSignal.value = childItem; // first activation: null → childItem
16613
16668
  }
16614
16669
  };
16615
- 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
+ }
16616
16676
 
16617
16677
  applyProps(ownerItem[propertyName]);
16618
16678
 
@@ -16624,7 +16684,7 @@ const createResource = (
16624
16684
  const createRestActionForScopedOne = (
16625
16685
  verb,
16626
16686
  callback,
16627
- { onActionComplete },
16687
+ { onActionComplete, paramScope },
16628
16688
  ) => {
16629
16689
  const childActionName = `${childName}.${verb}`;
16630
16690
  return createAction(callback, {
@@ -16672,6 +16732,7 @@ const createResource = (
16672
16732
  paramScope,
16673
16733
  rerunOn: resolveRerunOn(scopedOneRerunOn, rerunOn),
16674
16734
  dependencies: scopedOneDependencies ?? dependencies,
16735
+ ownerName: name,
16675
16736
  });
16676
16737
  };
16677
16738
 
@@ -16699,7 +16760,14 @@ const createResource = (
16699
16760
  * `propertyName`), and the child's own GET_MANY reruns per its `rerunOn`.
16700
16761
  *
16701
16762
  * Returns the child relationship resource, itself chainable:
16702
- * `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.
16703
16771
  *
16704
16772
  * @param {string} propertyName - property holding the collection on each owner item
16705
16773
  * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
@@ -16724,6 +16792,25 @@ const createResource = (
16724
16792
  DELETE_MANY,
16725
16793
  } = {},
16726
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
+ }
16727
16814
  const childName = `${name}.${propertyName}`;
16728
16815
 
16729
16816
  // Callbacks added by chained .one()/.many() on the child resource,
@@ -16745,37 +16832,42 @@ const createResource = (
16745
16832
  return childItem;
16746
16833
  },
16747
16834
  });
16748
- const scope = { childStore, idArraySignal: signal([]) };
16749
- scopeMap.set(ownerKey, scope);
16750
- return scope;
16835
+ return { childStore, idArraySignal: signal([]) };
16751
16836
  };
16752
16837
  addItemSetup((item) => {
16753
16838
  const ownerId = item[idKey];
16754
-
16755
- // Reuse an existing scope if one was already created under a uniqueKey
16756
- // value (e.g. rows were fetched by tablename before the full table was loaded).
16757
- let scope = scopeMap.get(ownerId);
16758
- 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);
16759
16864
  for (const uniqueKey of uniqueKeys) {
16760
16865
  const uniqueKeyValue = item[uniqueKey];
16761
- if (uniqueKeyValue !== undefined && scopeMap.has(uniqueKeyValue)) {
16762
- scope = scopeMap.get(uniqueKeyValue);
16763
- break;
16866
+ if (uniqueKeyValue !== undefined) {
16867
+ scopeMap.set(uniqueKeyValue, scope);
16764
16868
  }
16765
16869
  }
16766
16870
  }
16767
- if (!scope) {
16768
- scope = createScope(ownerId);
16769
- }
16770
- // Register the scope under the id and every uniqueKey value so that
16771
- // resolveOwnerId can address it whichever key a callback returns.
16772
- scopeMap.set(ownerId, scope);
16773
- for (const uniqueKey of uniqueKeys) {
16774
- const uniqueKeyValue = item[uniqueKey];
16775
- if (uniqueKeyValue !== undefined) {
16776
- scopeMap.set(uniqueKeyValue, scope);
16777
- }
16778
- }
16779
16871
 
16780
16872
  const { childStore, idArraySignal } = scope;
16781
16873
  const updateChildItemIdArray = createChildIdArrayUpdater(
@@ -16813,7 +16905,7 @@ const createResource = (
16813
16905
  const createRestActionForScopedMany = (
16814
16906
  verb,
16815
16907
  callback,
16816
- { isMany, onActionComplete },
16908
+ { isMany, onActionComplete, paramScope },
16817
16909
  ) => {
16818
16910
  const childActionName = `${childName}.${verb}`;
16819
16911
  return createAction(callback, {
@@ -16835,7 +16927,11 @@ const createResource = (
16835
16927
  );
16836
16928
  // Owner not in store yet: create the scope so actions can run before
16837
16929
  // the parent item has been loaded (e.g. rows fetched before their table).
16838
- 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
+ }
16839
16935
  const { childStore, idArraySignal } = scope;
16840
16936
 
16841
16937
  if (verb === "DELETE") {
@@ -16907,18 +17003,17 @@ const createResource = (
16907
17003
  paramScope,
16908
17004
  rerunOn: resolveRerunOn(scopedManyRerunOn, rerunOn),
16909
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 },
16910
17016
  });
16911
- // When a scoped child collection is mutated (POST etc.), the parent GET must
16912
- // re-fetch: the parent embeds the child array and only the backend knows the
16913
- // new ordering. (scopedOne does not need this: the mutation result contains
16914
- // the updated object directly.)
16915
- resourceLifecycleManager.addDependency(
16916
- childResource,
16917
- stateFacade,
16918
- propertyName,
16919
- );
16920
- childResource.getChildStore = (ownerKey) =>
16921
- scopeMap.get(ownerKey)?.childStore;
16922
17017
  return childResource;
16923
17018
  };
16924
17019
 
@@ -17139,7 +17234,7 @@ const getLastGetItemId = (action) => {
17139
17234
  };
17140
17235
 
17141
17236
  // Captures the "file:line:column" of the user code that invoked the public
17142
- // function (resource(), .one(), .many(), withParams, …), so invalid-result
17237
+ // function (resource(), .one(), .many(), .scopedOne(), …), so invalid-result
17143
17238
  // errors can point at where the callbacks were declared, not at this file.
17144
17239
  // Must be called directly from the public function: the stack offset accounts
17145
17240
  // for exactly two frames (getCallerInfo → getDeclarationSite → public fn → user code).
@@ -17160,6 +17255,36 @@ ${originalActionName} source location: ${declarationSite}`,
17160
17255
  };
17161
17256
  };
17162
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
+
17163
17288
  // Shared by .many() and .scopedMany(): converts a raw relationship value (an
17164
17289
  // array of child props/ids, or anything else meaning "empty") into an array of
17165
17290
  // child ids, upserting each entry into the child store. The id array signal is
@@ -17301,22 +17426,28 @@ Received an object with keys: ${keys.join(", ")}.`,
17301
17426
  * // which in turn triggers the route Signal->URL sync and updates the browser URL.
17302
17427
  */
17303
17428
  const syncResourceToSignals = (resource, propertyToSignalMap) => {
17304
- 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.
17305
17432
  throw new Error(
17306
- `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." : ""}`,
17307
17434
  );
17308
17435
  }
17309
17436
  syncStoreToSignals(resource.store, propertyToSignalMap);
17310
17437
  };
17311
17438
 
17312
17439
  /**
17313
- * The same, for a `scopedOne`/`scopedMany` resource: its items live in one store
17314
- * per owner, so the store to watch is the one of the owner `ownerSignal` names
17315
- * (by id or by any unique key), and it is switched when the signal changes.
17316
- * Nothing is synced while `ownerSignal` holds `null`/`undefined` or names an
17317
- * 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.
17318
17445
  *
17319
- * @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()`
17320
17451
  * @param {import("@preact/signals").Signal} ownerSignal - the owner whose children are watched
17321
17452
  * @param {Object} propertyToSignalMap - `{ [propertyName]: signal }`, as for `syncResourceToSignals`
17322
17453
  */
@@ -17326,8 +17457,13 @@ const syncOwnedResourceToSignals = (
17326
17457
  propertyToSignalMap,
17327
17458
  ) => {
17328
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
+ }
17329
17465
  throw new Error(
17330
- `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.`,
17331
17467
  );
17332
17468
  }
17333
17469
  effect(() => {
@@ -29313,7 +29449,7 @@ const TRANSITION_FURNITURE_CSS = /* css */ `.navi_fixed_bar[data-navi-transition
29313
29449
  clip-path: var(--wall-holes-arriving, var(--wall-holes));
29314
29450
  }
29315
29451
 
29316
- :root[data-navi-route-transition] {
29452
+ :root[data-navi-route-transition-target="area"] {
29317
29453
  & .navi_dialog::backdrop, & .navi_popover_backdrop {
29318
29454
  backdrop-filter: none;
29319
29455
  background: none;
@@ -29458,10 +29594,7 @@ const nameFurnitureAround = (areaElement) => {
29458
29594
  if (!name) {
29459
29595
  // A name the application wrote itself answers for that element, and it
29460
29596
  // is saying something navi is not: it wants that bar moved on the pages'
29461
- // clock, by its own CSS. Asked only of a bar navi has never named — a
29462
- // bar wearing one of ITS names is one this movement inherited from the
29463
- // one it interrupted, and taking it for the application's would leave it
29464
- // named for the rest of the document's life.
29597
+ // clock, by its own CSS.
29465
29598
  if (getComputedStyle(element).viewTransitionName !== "none") {
29466
29599
  continue;
29467
29600
  }
@@ -31283,8 +31416,17 @@ const beginTransition = ({
31283
31416
  url: absoluteUrl(url),
31284
31417
  decision: navigationDecision,
31285
31418
  walkHome: null,
31286
- releaseReverting: null
31419
+ releaseReverting: null,
31420
+ restoreDuration: null
31287
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
+ }
31288
31430
  currentTransition = transition;
31289
31431
  // Said before the picture is taken: whoever names something for a movement
31290
31432
  // between two pages decides on it now (see transition_destination.js).
@@ -31328,11 +31470,10 @@ const beginTransition = ({
31328
31470
  // A duration of this relation's own, worn for the length of the transition —
31329
31471
  // and whatever the application had written inline put back afterwards, not
31330
31472
  // erased.
31331
- let restoreDuration = null;
31332
31473
  if (duration !== undefined) {
31333
31474
  const durationBefore = documentElement.style.getPropertyValue(TRANSITION_DURATION_PROPERTY);
31334
31475
  documentElement.style.setProperty(TRANSITION_DURATION_PROPERTY, typeof duration === "number" ? `${duration}ms` : duration);
31335
- restoreDuration = () => {
31476
+ transition.restoreDuration = () => {
31336
31477
  if (durationBefore) {
31337
31478
  documentElement.style.setProperty(TRANSITION_DURATION_PROPERTY, durationBefore);
31338
31479
  } else {
@@ -31393,8 +31534,11 @@ const beginTransition = ({
31393
31534
  renderWait.stop();
31394
31535
  }
31395
31536
  // The page arriving is in the DOM and the transition has not started
31396
- // playing: the one moment both states of the area can be known.
31397
- 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) {
31398
31542
  holdTransitionFurniture(transition, areaElement);
31399
31543
  holdTransitionWindow(transition, areaElement, areaStateBefore);
31400
31544
  }
@@ -31403,8 +31547,9 @@ const beginTransition = ({
31403
31547
  // Whatever ends it — played out, skipped by another transition starting,
31404
31548
  // failed before its callback ever ran — the hold is given back and the
31405
31549
  // document is handed back to the application. Both are idempotent, and
31406
- // the attributes belong to the LAST transition begun: an earlier one
31407
- // 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.
31408
31553
  renderWait.stop();
31409
31554
  releaseRendering();
31410
31555
  // The hold a way back took, when it is still standing: the pictures were
@@ -31416,16 +31561,7 @@ const beginTransition = ({
31416
31561
  }
31417
31562
  if (currentTransition === transition) {
31418
31563
  currentTransition = null;
31419
- documentElement.removeAttribute(TRANSITION_ATTRIBUTE);
31420
- documentElement.removeAttribute(TRANSITION_TYPE_ATTRIBUTE);
31421
- documentElement.removeAttribute(TRANSITION_TARGET_ATTRIBUTE);
31422
- releaseTransitionWindow(transition);
31423
- releaseTransitionDestination(transition);
31424
- releaseTransitionFurniture(transition);
31425
- releaseTransitionPress(transition);
31426
- if (restoreDuration) {
31427
- restoreDuration();
31428
- }
31564
+ releaseTransitionRoot(transition);
31429
31565
  }
31430
31566
  };
31431
31567
  transition.viewTransition = viewTransition;
@@ -31433,6 +31569,22 @@ const beginTransition = ({
31433
31569
  viewTransition.finished.then(end, end);
31434
31570
  };
31435
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
+
31436
31588
  /**
31437
31589
  * The movement playing, turned round rather than replaced: this navigation is
31438
31590
  * exactly the way back of the one on screen — the door pressed again while the
@@ -49056,10 +49208,10 @@ const css$R = /* css */`.navi_expandable {
49056
49208
  }
49057
49209
 
49058
49210
  & > .navi_expandable_content_container {
49059
- clip-path: inset(-9999px -9999px 0);
49060
49211
  grid-template-rows: 0fr;
49061
49212
  display: grid;
49062
49213
  position: relative;
49214
+ overflow-y: clip;
49063
49215
 
49064
49216
  & > .navi_expandable_content_sizer {
49065
49217
  min-height: 0;
@@ -49072,7 +49224,6 @@ const css$R = /* css */`.navi_expandable {
49072
49224
  }
49073
49225
 
49074
49226
  &[data-open-direction="up"] > .navi_expandable_content_container {
49075
- clip-path: inset(0 -9999px -9999px);
49076
49227
  align-content: end;
49077
49228
 
49078
49229
  & > .navi_expandable_content_sizer {
@@ -49094,9 +49245,9 @@ const css$R = /* css */`.navi_expandable {
49094
49245
  }
49095
49246
 
49096
49247
  & > .navi_expandable_content_container {
49097
- clip-path: inset(-9999px 0 0 -9999px);
49098
49248
  grid-template-rows: 0fr;
49099
49249
  grid-template-columns: 0fr;
49250
+ overflow: clip;
49100
49251
 
49101
49252
  & > .navi_expandable_content_sizer {
49102
49253
  min-width: 0;
@@ -49114,7 +49265,6 @@ const css$R = /* css */`.navi_expandable {
49114
49265
  }
49115
49266
 
49116
49267
  &[data-open-direction="left"] > .navi_expandable_content_container {
49117
- clip-path: inset(-9999px -9999px 0 0);
49118
49268
  justify-content: end;
49119
49269
 
49120
49270
  & > .navi_expandable_content_sizer {
@@ -49136,11 +49286,10 @@ const css$R = /* css */`.navi_expandable {
49136
49286
  }
49137
49287
 
49138
49288
  &[aria-expanded="true"][data-settled] > .navi_expandable_content_container {
49139
- clip-path: none;
49289
+ overflow: visible;
49140
49290
  }
49141
49291
 
49142
49292
  &:not([aria-expanded="true"])[data-settled] > .navi_expandable_content_container {
49143
- clip-path: none;
49144
49293
  overflow: clip;
49145
49294
  }
49146
49295
 
@@ -49237,11 +49386,12 @@ const useExpandableContext = partName => {
49237
49386
  * that can scroll so the content revealed past its edge is on screen: an
49238
49387
  * expandable opening at the bottom of a list otherwise grows below the
49239
49388
  * fold, and the user sees the row grow but not what it grew with. Measured
49240
- * on the open layout and done once per opening, alongside the reveal
49241
- * (smoothly when `animation` plays, at once otherwise). An expandable
49242
- * taller than the scroller keeps its UI part on screen and the content
49243
- * follows as far as it fits. Only the nearest scroller moves — a popup or
49244
- * a page around it stays where it is.
49389
+ * on the open layout, once per opening; when `animation` plays the
49390
+ * scroller rides along with the reveal, at the track's own progress, and
49391
+ * otherwise it goes there at once. A `scroll-margin` on the expandable is
49392
+ * honored. An expandable taller than the scroller keeps its UI part on
49393
+ * screen and the content follows as far as it fits. Only the nearest
49394
+ * scroller moves — a popup or a page around it stays where it is.
49245
49395
  * @param maxContentHeight - Caps the content height; taller content scrolls
49246
49396
  * inside the expandable instead of growing it.
49247
49397
  * @param mount - When the content is built and thrown away, a popup's own
@@ -49364,15 +49514,27 @@ const Expandable = props => {
49364
49514
  cancelSettleWatchRef.current?.();
49365
49515
  cancelSettleWatchRef.current = null;
49366
49516
  };
49367
- const watchSettle = () => {
49517
+ // `stopRide`, when the scroller rides along with the reveal (keepInView):
49518
+ // the ride is part of the movement, so it ends with it — settled or
49519
+ // cancelled — and never on its own clock.
49520
+ const watchSettle = stopRide => {
49368
49521
  const contentContainer = contentContainerRef.current;
49369
49522
  const contentElement = contentContainer.firstElementChild.firstElementChild;
49370
- cancelSettleWatchRef.current = whenTransitionSettles(contentContainer, () => {
49523
+ const cancelWatch = whenTransitionSettles(contentContainer, () => {
49371
49524
  cancelSettleWatchRef.current = null;
49525
+ if (stopRide) {
49526
+ stopRide();
49527
+ }
49372
49528
  contentElement.style.width = "";
49373
49529
  contentElement.style.height = "";
49374
49530
  setSettled(true);
49375
49531
  });
49532
+ cancelSettleWatchRef.current = () => {
49533
+ if (stopRide) {
49534
+ stopRide();
49535
+ }
49536
+ cancelWatch();
49537
+ };
49376
49538
  };
49377
49539
 
49378
49540
  // The reveal needs the content at its final size before the track starts
@@ -49391,17 +49553,22 @@ const Expandable = props => {
49391
49553
  } else {
49392
49554
  contentElement.style.height = `${finalRect.height}px`;
49393
49555
  }
49556
+ let stopRide = null;
49394
49557
  if (keepInView) {
49395
- // Here and not once the reveal settles: the layout is the open one at
49396
- // this point, so where the content ends is known before the movement
49397
- // starts, and the scroll can travel along with it. The frozen content
49398
- // overflowing the collapsed track is still scrollable overflow (the
49399
- // one-sided clip-path, see the CSS), so the scroller can reach that
49400
- // position from the first frame.
49401
- scrollExpandableIntoView(rootRef.current, {
49402
- revealDirection,
49403
- behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"
49404
- });
49558
+ // Measured here and not once the reveal settles: the layout is the open
49559
+ // one at this point, so where the content ends is known before the
49560
+ // movement starts. The scroll then rides the movement rather than
49561
+ // running ahead of it (see rideScrollerAlongReveal).
49562
+ const revealScroll = measureRevealScroll(rootRef.current, revealDirection);
49563
+ if (revealScroll) {
49564
+ const finalContainerRect = contentContainer.getBoundingClientRect();
49565
+ stopRide = rideScrollerAlongReveal(revealScroll, {
49566
+ contentContainer,
49567
+ axis: isColumn ? "x" : "y",
49568
+ startSize: isColumn ? startRect.width : startRect.height,
49569
+ finalSize: isColumn ? finalContainerRect.width : finalContainerRect.height
49570
+ });
49571
+ }
49405
49572
  }
49406
49573
  // Put the tracks back where the last paint left them and let the
49407
49574
  // transition play from there. In fr — px does not interpolate with fr.
@@ -49424,6 +49591,7 @@ const Expandable = props => {
49424
49591
  contentContainer.style.transitionProperty = "";
49425
49592
  contentContainer.style.gridTemplateColumns = "";
49426
49593
  contentContainer.style.gridTemplateRows = "";
49594
+ return stopRide;
49427
49595
  };
49428
49596
 
49429
49597
  // What opening LOOKS like here, and how to undo it — the one thing an
@@ -49455,12 +49623,17 @@ const Expandable = props => {
49455
49623
  // transfer there is nothing to restore on close either.
49456
49624
  const restoreFocus = autoFocus && !silent && contentContainer ? openController.transferFocusOnOpen(contentContainer) : null;
49457
49625
  if (revealing) {
49458
- armReveal(startRect);
49459
- watchSettle();
49626
+ const stopRide = armReveal(startRect);
49627
+ watchSettle(stopRide);
49460
49628
  } else if (keepInView && !silent && contentContainer) {
49461
- scrollExpandableIntoView(rootRef.current, {
49462
- revealDirection
49463
- });
49629
+ const revealScroll = measureRevealScroll(rootRef.current, revealDirection);
49630
+ if (revealScroll) {
49631
+ // No behavior: the scroller's own scroll-behavior decides.
49632
+ revealScroll.scrollerEl.scrollTo({
49633
+ left: revealScroll.left,
49634
+ top: revealScroll.top
49635
+ });
49636
+ }
49464
49637
  }
49465
49638
  if (!silent) {
49466
49639
  rootRef.current.dispatchEvent(createToggleEvent(true));
@@ -49502,7 +49675,7 @@ const Expandable = props => {
49502
49675
  }
49503
49676
  }
49504
49677
  if (collapsing) {
49505
- watchSettle();
49678
+ watchSettle(null);
49506
49679
  }
49507
49680
  rootRef.current.dispatchEvent(createToggleEvent(false));
49508
49681
  };
@@ -49842,16 +50015,14 @@ Expandable.Content = ExpandableContent;
49842
50015
  // the one exception, excluded at the call site.
49843
50016
  const UI_INTERACTIVE_SELECTOR = ["a[href]", "button", "input", "select", "textarea", "label", "[role='button']", "[contenteditable='']", "[contenteditable='true']", "audio[controls]", "video[controls]"].join(", ");
49844
50017
 
49845
- // Scrolls the nearest scroller that can scroll so the whole expandable — UI
49846
- // part and revealed content — is in view. Measured on the layout as it stands,
49847
- // so the caller calls it while the open layout is the one laid out (see
49848
- // openEffect). A root taller than the view is aligned on its UI side instead:
49849
- // what the user pressed stays on screen, and the content follows as far as it
49850
- // fits. One scroller only — a popup or a page around it is left where it is.
49851
- const scrollExpandableIntoView = (rootEl, {
49852
- revealDirection,
49853
- behavior
49854
- }) => {
50018
+ // Where the nearest scroller that can scroll would have to be for the whole
50019
+ // expandable — UI part and revealed content — to be in view. Measured on the
50020
+ // layout as it stands, so the caller measures while the open layout is the
50021
+ // one laid out (see openEffect). A root taller than the view is aligned on
50022
+ // its UI side instead: what the user pressed stays on screen, and the content
50023
+ // follows as far as it fits. One scroller only — a popup or a page around it
50024
+ // is left where it is. Null when there is no scroller, or nothing to scroll.
50025
+ const measureRevealScroll = (rootEl, revealDirection) => {
49855
50026
  let scrollerEl = null;
49856
50027
  for (const scrollContainer of getScrollContainerSet(rootEl)) {
49857
50028
  if (isScrollable(scrollContainer)) {
@@ -49860,7 +50031,7 @@ const scrollExpandableIntoView = (rootEl, {
49860
50031
  }
49861
50032
  }
49862
50033
  if (!scrollerEl) {
49863
- return;
50034
+ return null;
49864
50035
  }
49865
50036
  const rootRect = rootEl.getBoundingClientRect();
49866
50037
  let block = "nearest";
@@ -49872,12 +50043,74 @@ const scrollExpandableIntoView = (rootEl, {
49872
50043
  } else if (rootRect.width > scrollerEl.clientWidth) {
49873
50044
  inline = revealDirection === "right" ? "start" : "end";
49874
50045
  }
49875
- scrollIntoViewScoped(rootEl, {
50046
+ const {
50047
+ left,
50048
+ top
50049
+ } = getScrollIntoViewScopedOffsets(rootEl, {
49876
50050
  container: scrollerEl,
49877
50051
  block,
49878
- inline,
49879
- behavior
50052
+ inline
49880
50053
  });
50054
+ if (left === scrollerEl.scrollLeft && top === scrollerEl.scrollTop) {
50055
+ return null;
50056
+ }
50057
+ return {
50058
+ scrollerEl,
50059
+ left,
50060
+ top
50061
+ };
50062
+ };
50063
+
50064
+ // Moves the scroller toward where the reveal ends at the pace of the reveal
50065
+ // itself: every frame the track's progress is read from the container's size
50066
+ // and the scroll stands the same fraction of its own way. The scroll cannot
50067
+ // run ahead of the track: only the box's own growth extends the scroller's
50068
+ // range, and what the target adds around the box (a parent's padding, a
50069
+ // scroll-margin) becomes reachable as the track grows — a scroll started at
50070
+ // once would be clamped short of it. One clock, the transition's, so the
50071
+ // content never waits below the fold either. Returns what stops the ride.
50072
+ const rideScrollerAlongReveal = ({
50073
+ scrollerEl,
50074
+ left,
50075
+ top
50076
+ }, {
50077
+ contentContainer,
50078
+ axis,
50079
+ startSize,
50080
+ finalSize
50081
+ }) => {
50082
+ const startLeft = scrollerEl.scrollLeft;
50083
+ const startTop = scrollerEl.scrollTop;
50084
+ const leftDelta = left - startLeft;
50085
+ const topDelta = top - startTop;
50086
+ let frameId;
50087
+ const tick = () => {
50088
+ const rect = contentContainer.getBoundingClientRect();
50089
+ const size = axis === "x" ? rect.width : rect.height;
50090
+ let progress = 1;
50091
+ if (finalSize !== startSize) {
50092
+ progress = (size - startSize) / (finalSize - startSize);
50093
+ if (progress < 0) {
50094
+ progress = 0;
50095
+ } else if (progress > 1) {
50096
+ progress = 1;
50097
+ }
50098
+ }
50099
+ scrollerEl.scrollTo({
50100
+ left: startLeft + leftDelta * progress,
50101
+ top: startTop + topDelta * progress,
50102
+ // Driven frame by frame: a smooth scroll-behavior on the scroller is
50103
+ // for a scroll that lands, not for one that is held.
50104
+ behavior: "instant"
50105
+ });
50106
+ if (progress < 1) {
50107
+ frameId = requestAnimationFrame(tick);
50108
+ }
50109
+ };
50110
+ frameId = requestAnimationFrame(tick);
50111
+ return () => {
50112
+ cancelAnimationFrame(frameId);
50113
+ };
49881
50114
  };
49882
50115
  const createToggleEvent = open => {
49883
50116
  const newState = open ? "open" : "closed";
@@ -72675,11 +72908,15 @@ const PickerNaviTime = props => {
72675
72908
  max = "23:30",
72676
72909
  step
72677
72910
  } = props;
72678
- const stepSeconds = timeStringToSeconds(step) ?? 1800;
72911
+ // resolveInputProps has already run (PickerFirstResolver) and left navi_time
72912
+ // as written: what is handed on is a time input, whose step is in seconds.
72913
+ const hostStep = timeStringToSeconds(step);
72914
+ const stepSeconds = hostStep ?? 1800;
72679
72915
  const slots = useMemo(() => generateTimeSlots(min, max, stepSeconds), [min, max, stepSeconds]);
72680
72916
  return jsx(Next, {
72681
72917
  ...props,
72682
72918
  type: "time",
72919
+ step: hostStep,
72683
72920
  children: jsx(List, {
72684
72921
  selectable: true,
72685
72922
  command: "--navi-send",
@@ -75091,6 +75328,10 @@ const PickerFirstResolver = props => {
75091
75328
  ...props
75092
75329
  });
75093
75330
  };
75331
+
75332
+ // PickerFirstResolver comes before the presets so a preset reads the
75333
+ // min/max/step a bound signal fills in (resolveInputProps); a preset type
75334
+ // therefore has no row in NAVI_TYPE_DEFAULTS (resolve_input_props.js).
75094
75335
  const PickerResolved = /*#__PURE__*/createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerConfirmResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
75095
75336
  const PickerUI = /*#__PURE__*/Object.assign(PickerDefaultUI, {
75096
75337
  Date: PickerDateUI,