@jsenv/navi 0.29.11 → 0.29.14

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.
package/README.md CHANGED
@@ -12,6 +12,15 @@ Routes are flexible: you can create route groups to share logic, state, or UI ac
12
12
 
13
13
  Actions are async operations with lifecycle management — pending, success, error. You can declare actions that run when navigating to a route, and any component can subscribe to them via `useAsyncData` to reflect what is happening: loading states, results, errors. No manual wiring.
14
14
 
15
+ ## REST state
16
+
17
+ `resource()` turns a REST endpoint into a reactive store: one action per verb, a
18
+ shared signal store every component reads from, and automatic invalidation
19
+ between related actions. Parent/child relations are first-class — `.one`,
20
+ `.many`, `.scopedOne`, `.scopedMany` model a backend sub-resource such as
21
+ `/games/:id/candidates` rather than leaving you to hand-roll it. See
22
+ [docs/resource.md](./docs/resource.md).
23
+
15
24
  ## Layout & Typography
16
25
 
17
26
  **`Box`** is the main layout primitive. It wraps CSS Flexbox with a friendlier API: `flex` for horizontal layout, `flex="y"` for vertical (no more guessing what `flex-direction: column` does visually). Supports `grid`, `inline`, alignment via `alignX`/`alignY`, and spacing props.
@@ -12042,6 +12042,13 @@ const mergeActionParams = (currentParams, newParams) => {
12042
12042
  if (currentParams === NO_PARAMS) {
12043
12043
  return newParams;
12044
12044
  }
12045
+ if (newParams === undefined) {
12046
+ // Every control binds its action to its UI state signal; a control that carries
12047
+ // no value (a button) has an undefined one. It contributes no params, which must
12048
+ // not be confused with "the params are undefined" — the params bound by
12049
+ // bindParams stay in place.
12050
+ return currentParams;
12051
+ }
12045
12052
  return mergeTwoJsValues(currentParams, newParams);
12046
12053
  };
12047
12054
 
@@ -29676,6 +29683,36 @@ const debug$2 = (args) => {
29676
29683
  }
29677
29684
  };
29678
29685
 
29686
+ /**
29687
+ * Creates a reactive REST resource backed by a shared signal store.
29688
+ * Returns a `stateFacade` exposing one action per REST callback provided
29689
+ * (`USER.GET`, `USER.GET_MANY`, `USER.POST`, …) plus `.withParams()` and the
29690
+ * relationship methods `.one()`, `.many()`, `.scopedOne()`, `.scopedMany()`.
29691
+ *
29692
+ * Each REST callback receives the params passed to the action call and must return
29693
+ * the data that will be upserted into the store:
29694
+ * - GET / POST / PUT / PATCH → the full item object, e.g. `{ id, name }`
29695
+ * - DELETE → the id or `{ id }` of the removed item
29696
+ * - GET_MANY / POST_MANY / … → an array of item objects
29697
+ *
29698
+ * A sub-resource of the backend (`/games/:id/candidates`) must be modelled with a
29699
+ * relationship method, never as an `op`/`type` discriminator dispatched inside one
29700
+ * verb's callback.
29701
+ *
29702
+ * @param {string} name - resource name, used in action names and error messages
29703
+ * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
29704
+ * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"` (or the first `uniqueKeys` entry)
29705
+ * @param {string[]} [restCallbacks.uniqueKeys] - alternate keys the store can find an item by (e.g. `"username"`); a callback may return a different `id` to rename the item's primary key
29706
+ * @see docs/resource.md — relationships, callback return contracts, decision table
29707
+ *
29708
+ * @example
29709
+ * const USER = resource("user", {
29710
+ * GET: ({ id }) => fetchJson(`/users/${id}`),
29711
+ * GET_MANY: () => fetchJson(`/users`),
29712
+ * POST: (user) => fetchJson(`/users`, { method: "POST", body: user }),
29713
+ * DELETE: ({ id }) => fetchJson(`/users/${id}`, { method: "DELETE" }),
29714
+ * });
29715
+ */
29679
29716
  const resource = (
29680
29717
  name,
29681
29718
  {
@@ -29830,7 +29867,7 @@ const createResource = (
29830
29867
  * @param {Object} params - Parameters to bind to all actions of this resource (required)
29831
29868
  * @param {Object} options - Additional options for the parameterized resource
29832
29869
  * @returns {Object} A new resource instance with parameter-bound actions and isolated lifecycle
29833
- * @see {@link ./docs/resource_with_params.md} for detailed documentation and examples
29870
+ * @see docs/resource_with_params.md for detailed documentation and examples
29834
29871
  *
29835
29872
  * @example
29836
29873
  * const ROLE = resource("role", { GET: (params) => fetchRole(params) });
@@ -29877,6 +29914,31 @@ const createResource = (
29877
29914
  };
29878
29915
  stateFacade.withParams = withParams;
29879
29916
 
29917
+ /**
29918
+ * Links a property on each item to a single item in an independent child store.
29919
+ * The property is reactive: updating the child item anywhere propagates immediately.
29920
+ * The child resource exists independently — it is not owned by, nor deleted with, the parent.
29921
+ *
29922
+ * Use it when the child is a first-class entity with its own store, shared across
29923
+ * parents (a user referenced by many games). When the child only exists inside its
29924
+ * owner, use `.scopedOne()` instead.
29925
+ *
29926
+ * Callback return contracts:
29927
+ * - GET / PUT → the parent object with the relationship nested inside:
29928
+ * `async ({ id }) => ({ id, session: { id: 10, token: "abc" } })`; `null` for no relationship
29929
+ * - DELETE → the parent id (or `{ id }`); the property is set to `null`
29930
+ *
29931
+ * The backend may also embed the child inline in a parent GET/POST response — the
29932
+ * setter on the property upserts the nested object into the child store.
29933
+ *
29934
+ * Returns the child relationship resource, itself chainable:
29935
+ * `USER_SESSION.one("device", DEVICE)` adds a reactive `.device` property to each session.
29936
+ *
29937
+ * @param {string} propertyName - property holding the child on each parent item
29938
+ * @param {Object} childResource - the independent resource created by `resource()`
29939
+ * @param {Object} [restCallbacks] - `{ rerunOn, dependencies, GET, PUT, DELETE }`
29940
+ * @see docs/resource.md
29941
+ */
29880
29942
  stateFacade.one = (
29881
29943
  propertyName,
29882
29944
  childResource,
@@ -30044,6 +30106,29 @@ ${originalActionName} source location: ${locationInfo}`,
30044
30106
  });
30045
30107
  };
30046
30108
 
30109
+ /**
30110
+ * Links a property on each item to an array of items in an independent child store.
30111
+ * Items in the array are full entries in the shared child store — if the same item is
30112
+ * referenced by several parents, a single update propagates to all of them.
30113
+ *
30114
+ * Use it when children are first-class entities shared across parents (a game's players,
30115
+ * who are users). When the children only exist inside their owner, or when the relation
30116
+ * itself carries fields (`seen_at`, `slot`), use `.scopedMany()` instead.
30117
+ *
30118
+ * Callback return contracts:
30119
+ * - GET_MANY → the parent object with the array nested inside:
30120
+ * `async ({ id }) => ({ id, friends: [{ id: 2 }, { id: 3 }] })` — a full-parent
30121
+ * response is absorbed as-is; the array replaces the relationship
30122
+ * - GET / POST / PUT / PATCH → the child object; it is upserted into the child store
30123
+ * but does NOT join the parent's array, which only a GET_MANY refresh changes
30124
+ * - DELETE → `[parentId, childId]`
30125
+ * - DELETE_MANY → `[parentId, [childId, childId, …]]`
30126
+ *
30127
+ * @param {string} propertyName - property holding the child array on each parent item
30128
+ * @param {Object} childResource - the independent resource created by `resource()`
30129
+ * @param {Object} [restCallbacks] - `{ rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30130
+ * @see docs/resource.md
30131
+ */
30047
30132
  stateFacade.many = (
30048
30133
  propertyName,
30049
30134
  childResource,
@@ -30340,6 +30425,29 @@ ${originalActionName} source location: ${locationInfo}`,
30340
30425
  });
30341
30426
  };
30342
30427
 
30428
+ /**
30429
+ * Attaches a single private sub-object to each item. The child has no identity outside
30430
+ * its owner and is not shared across items; each owner gets its own private signal.
30431
+ *
30432
+ * Use it for a sub-resource the backend exposes under the parent (`/users/:id/profile`)
30433
+ * whose content is meaningless without that parent.
30434
+ *
30435
+ * All callbacks must return `[ownerId, props | null]`:
30436
+ * - `GET: async ({ id }) => [id, { bio: "Hello", avatar: "alice.png" }]`
30437
+ * - `PATCH: async ({ id, bio }) => [id, { bio, avatar: "alice.png" }]`
30438
+ * - `DELETE: async ({ id }) => [id, null]`
30439
+ *
30440
+ * `ownerId` may also be `{ [uniqueKey]: value }` when the owner is known by an alternate key.
30441
+ * The property is `null` until a callback provides data; setting it to `null` clears it.
30442
+ * Mutations apply directly to the owner's signal, so the parent GET is never rerun.
30443
+ *
30444
+ * Returns the child relationship resource, itself chainable:
30445
+ * `USER_PROFILE.one("theme", THEME)` adds a reactive `.theme` property on each profile.
30446
+ *
30447
+ * @param {string} propertyName - property holding the sub-object on each owner item
30448
+ * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, POST, PUT, PATCH, DELETE }`
30449
+ * @see docs/resource.md
30450
+ */
30343
30451
  stateFacade.scopedOne = (
30344
30452
  propertyName,
30345
30453
  {
@@ -30461,6 +30569,36 @@ ${originalActionName} source location: ${locationInfo}`,
30461
30569
  return childResource;
30462
30570
  };
30463
30571
 
30572
+ /**
30573
+ * Attaches a private ordered collection of sub-objects to each item. The child objects
30574
+ * have no identity outside their owner — two owners can hold items with the same id that
30575
+ * are completely independent. Each owner gets its own private arraySignalStore.
30576
+ *
30577
+ * This is the shape for a backend sub-route (`/games/:id/candidates`,
30578
+ * `…/candidates/:userId/accept`) and for a relation carrying its own fields
30579
+ * (`candidate_since`, `seen_at`): those fields belong to the pair, not to a shared child
30580
+ * store where they would corrupt the entity for every other reader.
30581
+ *
30582
+ * All callbacks must return `[ownerId, ...rest]`:
30583
+ * - `GET_MANY: async ({ id }) => [id, [{ name: "id", type: "int" }, …]]` — replaces the collection
30584
+ * - `POST: async ({ id, name, type }) => [id, { name, type }]`
30585
+ * - `PUT: async ({ id, oldName, name, type }) => [id, oldName, { name, type }]` (id rename)
30586
+ * - `DELETE: async ({ id, name }) => [id, name]`
30587
+ * - `*_MANY: [ownerId, itemArray]` — any plural verb replaces the whole collection,
30588
+ * which is how a backend answering a sub-route with the refreshed parent is absorbed
30589
+ *
30590
+ * `ownerId` may also be `{ [uniqueKey]: value }` when the owner is known by an alternate key.
30591
+ * A singular POST upserts the child but does not append it to the collection; ordering is
30592
+ * the backend's, so the owner's GET is rerun instead (only when its last response embedded
30593
+ * `propertyName`), and the child's own GET_MANY reruns per its `rerunOn`.
30594
+ *
30595
+ * Returns the child relationship resource, itself chainable:
30596
+ * `TABLE_COLUMNS.one("dataType", DATA_TYPE)` adds a reactive `.dataType` property on each column.
30597
+ *
30598
+ * @param {string} propertyName - property holding the collection on each owner item
30599
+ * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30600
+ * @see docs/resource.md
30601
+ */
30464
30602
  stateFacade.scopedMany = (
30465
30603
  propertyName,
30466
30604
  {
@@ -37600,8 +37738,8 @@ const withPixelUnit = value => {
37600
37738
  * because the document is the scrollport in the common case and an anchor
37601
37739
  * landing under a bar is never what anyone wants.
37602
37740
  *
37603
- * The variables hold the bar's measured size — see the comment where FixedBar
37604
- * sets them.
37741
+ * The variables hold the measured size of the bars on that edge — see the
37742
+ * comment where FixedBar sets them.
37605
37743
  */
37606
37744
 
37607
37745
  const FIXED_BAR_SPACE_CSS = /* css */ `
@@ -37631,18 +37769,122 @@ const FIXED_BAR_SPACE_CSS = /* css */ `
37631
37769
  }
37632
37770
  `;
37633
37771
 
37772
+ // Several bars can share an edge — during a page transition the outgoing and
37773
+ // the incoming one are both mounted. They are all pinned to that same edge, so
37774
+ // they overlap: the room to give back is the largest of them, not their sum,
37775
+ // and one leaving must leave the others' room in place.
37776
+ const sizeMapByArea = new Map();
37777
+ // What is currently on <html> for each area (absent = the variable is not set).
37778
+ // Writing the value that is already there would invalidate layout for nothing,
37779
+ // and several bars sharing an edge means most calls compute the same largest
37780
+ // size again — the ones for the smaller bars, and the second half of a page
37781
+ // transition.
37782
+ const writtenValueByArea = new Map();
37783
+
37784
+ // A size that must land before the next paint: a render changed the bar, so
37785
+ // the room it takes is given back in that same commit and the content is never
37786
+ // painted under it.
37634
37787
  /**
37635
37788
  * @param {"top"|"bottom"|"left"|"right"} area
37636
- * @param {string|null} value - `null` gives the room back to the content.
37789
+ * @param {Element} barElement - Which bar this size belongs to.
37790
+ * @param {number|null} size - In px; `null` gives that bar's room back to the
37791
+ * content.
37637
37792
  */
37638
- const setFixedBarSpace = (area, value) => {
37793
+ const setFixedBarSpace = (area, barElement, size) => {
37794
+ dropPendingSize(area, barElement);
37795
+ storeSize(area, barElement, size);
37796
+ writeSpace(area);
37797
+ };
37798
+
37799
+ // A size nothing asked for, coming from a ResizeObserver. Writing the variable
37800
+ // resizes an ANCESTOR of the bars — the scroll container takes its padding
37801
+ // from it — and mutating layout from inside a resize callback is what makes
37802
+ // the browser report "ResizeObserver loop completed with undelivered
37803
+ // notifications". So the write waits for the frame that resize produced.
37804
+ // Queued here rather than deferred by each bar on its own, so the bars sharing
37805
+ // an edge resolve to a single write instead of one per bar.
37806
+ /**
37807
+ * @param {"top"|"bottom"|"left"|"right"} area
37808
+ * @param {Element} barElement - Which bar this size belongs to.
37809
+ * @param {number} size - In px.
37810
+ */
37811
+ const requestFixedBarSpace = (area, barElement, size) => {
37812
+ let pendingSizeMap = pendingSizeMapByArea.get(area);
37813
+ if (!pendingSizeMap) {
37814
+ pendingSizeMap = new Map();
37815
+ pendingSizeMapByArea.set(area, pendingSizeMap);
37816
+ }
37817
+ pendingSizeMap.set(barElement, size);
37818
+ if (flushFrame !== null) {
37819
+ return;
37820
+ }
37821
+ flushFrame = requestAnimationFrame(flushPendingSizes);
37822
+ };
37823
+
37824
+ const pendingSizeMapByArea = new Map();
37825
+ let flushFrame = null;
37826
+
37827
+ const flushPendingSizes = () => {
37828
+ flushFrame = null;
37829
+ for (const [area, pendingSizeMap] of pendingSizeMapByArea) {
37830
+ for (const [barElement, size] of pendingSizeMap) {
37831
+ storeSize(area, barElement, size);
37832
+ }
37833
+ writeSpace(area);
37834
+ }
37835
+ pendingSizeMapByArea.clear();
37836
+ };
37837
+
37838
+ // What the bar itself just said wins over what its observer had queued about
37839
+ // it: a bar unmounting gives its room back, and a size queued for it before
37840
+ // that must not put it back.
37841
+ const dropPendingSize = (area, barElement) => {
37842
+ const pendingSizeMap = pendingSizeMapByArea.get(area);
37843
+ if (!pendingSizeMap) {
37844
+ return;
37845
+ }
37846
+ pendingSizeMap.delete(barElement);
37847
+ if (pendingSizeMap.size === 0) {
37848
+ pendingSizeMapByArea.delete(area);
37849
+ }
37850
+ };
37851
+
37852
+ const storeSize = (area, barElement, size) => {
37853
+ let sizeMap = sizeMapByArea.get(area);
37854
+ if (!sizeMap) {
37855
+ sizeMap = new Map();
37856
+ sizeMapByArea.set(area, sizeMap);
37857
+ }
37858
+ if (size === null) {
37859
+ sizeMap.delete(barElement);
37860
+ } else {
37861
+ sizeMap.set(barElement, size);
37862
+ }
37863
+ };
37864
+
37865
+ const writeSpace = (area) => {
37866
+ const sizeMap = sizeMapByArea.get(area);
37867
+ let largestSize = 0;
37868
+ for (const barSize of sizeMap.values()) {
37869
+ if (barSize > largestSize) {
37870
+ largestSize = barSize;
37871
+ }
37872
+ }
37639
37873
  const property = `--navi-fixed-bar-space-${area}`;
37640
37874
  const { style } = document.documentElement;
37641
- if (value === null) {
37642
- style.removeProperty(property);
37643
- } else {
37644
- style.setProperty(property, value);
37875
+ if (sizeMap.size === 0) {
37876
+ if (writtenValueByArea.has(area)) {
37877
+ writtenValueByArea.delete(area);
37878
+ style.removeProperty(property);
37879
+ }
37880
+ return;
37881
+ }
37882
+ const value = `${largestSize}px`;
37883
+ if (writtenValueByArea.get(area) === value) {
37884
+ return;
37645
37885
  }
37886
+ writtenValueByArea.set(area, value);
37887
+ style.setProperty(property, value);
37646
37888
  };
37647
37889
 
37648
37890
  installImportMetaCssBuild(import.meta);/**
@@ -37822,20 +38064,42 @@ const FixedBar = ({
37822
38064
  const {
37823
38065
  ref
37824
38066
  } = props;
38067
+ const measureSpace = barElement => {
38068
+ const {
38069
+ width,
38070
+ height
38071
+ } = barElement.getBoundingClientRect();
38072
+ return vertical ? width : height;
38073
+ };
38074
+
38075
+ // Anything a render can change — a size prop, the children, a theme variable
38076
+ // — is measured in that same commit, before paint: no frame where the
38077
+ // content sits under the bar, and nothing written from inside an observer.
38078
+ useLayoutEffect(() => {
38079
+ const barElement = ref.current;
38080
+ if (!barElement) {
38081
+ return;
38082
+ }
38083
+ setFixedBarSpace(area, barElement, measureSpace(barElement));
38084
+ });
38085
+
38086
+ // What no render caused: a font loading late, content arriving from outside,
38087
+ // a rotation moving the notch. Measuring here is safe; the write is what has
38088
+ // to wait, and fixed_bar_space.js is the one that holds it back.
37825
38089
  useLayoutEffect(() => {
37826
38090
  const barElement = ref.current;
37827
38091
  if (!barElement) {
37828
38092
  return undefined;
37829
38093
  }
37830
- const {
37831
- width,
37832
- height
37833
- } = barElement.getBoundingClientRect();
37834
- setFixedBarSpace(area, `${vertical ? width : height}px`);
38094
+ const resizeObserver = new ResizeObserver(() => {
38095
+ requestFixedBarSpace(area, barElement, measureSpace(barElement));
38096
+ });
38097
+ resizeObserver.observe(barElement);
37835
38098
  return () => {
37836
- setFixedBarSpace(area, null);
38099
+ resizeObserver.disconnect();
38100
+ setFixedBarSpace(area, barElement, null);
37837
38101
  };
37838
- }, [area]);
38102
+ }, [area, vertical]);
37839
38103
  return jsx(Box, {
37840
38104
  baseClassName: "navi_fixed_bar",
37841
38105
  "data-area": area,
@@ -38619,7 +38883,7 @@ const useCheckableProps = (props, options) => {
38619
38883
  installImportMetaCssBuild(import.meta);const css$F = /* css */`
38620
38884
  @layer navi {
38621
38885
  .navi_checkbox {
38622
- --border-radius: var(--navi-control-border-radius);
38886
+ --border-radius: var(--navi-checkbox-border-radius);
38623
38887
  --border-width: var(--navi-control-border-width);
38624
38888
  /* Focus outline */
38625
38889
  --outline-width: var(--navi-focus-outline-width);