@jsenv/navi 0.29.11 → 0.29.12

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.
@@ -29676,6 +29676,36 @@ const debug$2 = (args) => {
29676
29676
  }
29677
29677
  };
29678
29678
 
29679
+ /**
29680
+ * Creates a reactive REST resource backed by a shared signal store.
29681
+ * Returns a `stateFacade` exposing one action per REST callback provided
29682
+ * (`USER.GET`, `USER.GET_MANY`, `USER.POST`, …) plus `.withParams()` and the
29683
+ * relationship methods `.one()`, `.many()`, `.scopedOne()`, `.scopedMany()`.
29684
+ *
29685
+ * Each REST callback receives the params passed to the action call and must return
29686
+ * the data that will be upserted into the store:
29687
+ * - GET / POST / PUT / PATCH → the full item object, e.g. `{ id, name }`
29688
+ * - DELETE → the id or `{ id }` of the removed item
29689
+ * - GET_MANY / POST_MANY / … → an array of item objects
29690
+ *
29691
+ * A sub-resource of the backend (`/games/:id/candidates`) must be modelled with a
29692
+ * relationship method, never as an `op`/`type` discriminator dispatched inside one
29693
+ * verb's callback.
29694
+ *
29695
+ * @param {string} name - resource name, used in action names and error messages
29696
+ * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
29697
+ * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"` (or the first `uniqueKeys` entry)
29698
+ * @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
29699
+ * @see docs/resource.md — relationships, callback return contracts, decision table
29700
+ *
29701
+ * @example
29702
+ * const USER = resource("user", {
29703
+ * GET: ({ id }) => fetchJson(`/users/${id}`),
29704
+ * GET_MANY: () => fetchJson(`/users`),
29705
+ * POST: (user) => fetchJson(`/users`, { method: "POST", body: user }),
29706
+ * DELETE: ({ id }) => fetchJson(`/users/${id}`, { method: "DELETE" }),
29707
+ * });
29708
+ */
29679
29709
  const resource = (
29680
29710
  name,
29681
29711
  {
@@ -29830,7 +29860,7 @@ const createResource = (
29830
29860
  * @param {Object} params - Parameters to bind to all actions of this resource (required)
29831
29861
  * @param {Object} options - Additional options for the parameterized resource
29832
29862
  * @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
29863
+ * @see docs/resource_with_params.md for detailed documentation and examples
29834
29864
  *
29835
29865
  * @example
29836
29866
  * const ROLE = resource("role", { GET: (params) => fetchRole(params) });
@@ -29877,6 +29907,31 @@ const createResource = (
29877
29907
  };
29878
29908
  stateFacade.withParams = withParams;
29879
29909
 
29910
+ /**
29911
+ * Links a property on each item to a single item in an independent child store.
29912
+ * The property is reactive: updating the child item anywhere propagates immediately.
29913
+ * The child resource exists independently — it is not owned by, nor deleted with, the parent.
29914
+ *
29915
+ * Use it when the child is a first-class entity with its own store, shared across
29916
+ * parents (a user referenced by many games). When the child only exists inside its
29917
+ * owner, use `.scopedOne()` instead.
29918
+ *
29919
+ * Callback return contracts:
29920
+ * - GET / PUT → the parent object with the relationship nested inside:
29921
+ * `async ({ id }) => ({ id, session: { id: 10, token: "abc" } })`; `null` for no relationship
29922
+ * - DELETE → the parent id (or `{ id }`); the property is set to `null`
29923
+ *
29924
+ * The backend may also embed the child inline in a parent GET/POST response — the
29925
+ * setter on the property upserts the nested object into the child store.
29926
+ *
29927
+ * Returns the child relationship resource, itself chainable:
29928
+ * `USER_SESSION.one("device", DEVICE)` adds a reactive `.device` property to each session.
29929
+ *
29930
+ * @param {string} propertyName - property holding the child on each parent item
29931
+ * @param {Object} childResource - the independent resource created by `resource()`
29932
+ * @param {Object} [restCallbacks] - `{ rerunOn, dependencies, GET, PUT, DELETE }`
29933
+ * @see docs/resource.md
29934
+ */
29880
29935
  stateFacade.one = (
29881
29936
  propertyName,
29882
29937
  childResource,
@@ -30044,6 +30099,29 @@ ${originalActionName} source location: ${locationInfo}`,
30044
30099
  });
30045
30100
  };
30046
30101
 
30102
+ /**
30103
+ * Links a property on each item to an array of items in an independent child store.
30104
+ * Items in the array are full entries in the shared child store — if the same item is
30105
+ * referenced by several parents, a single update propagates to all of them.
30106
+ *
30107
+ * Use it when children are first-class entities shared across parents (a game's players,
30108
+ * who are users). When the children only exist inside their owner, or when the relation
30109
+ * itself carries fields (`seen_at`, `slot`), use `.scopedMany()` instead.
30110
+ *
30111
+ * Callback return contracts:
30112
+ * - GET_MANY → the parent object with the array nested inside:
30113
+ * `async ({ id }) => ({ id, friends: [{ id: 2 }, { id: 3 }] })` — a full-parent
30114
+ * response is absorbed as-is; the array replaces the relationship
30115
+ * - GET / POST / PUT / PATCH → the child object; it is upserted into the child store
30116
+ * but does NOT join the parent's array, which only a GET_MANY refresh changes
30117
+ * - DELETE → `[parentId, childId]`
30118
+ * - DELETE_MANY → `[parentId, [childId, childId, …]]`
30119
+ *
30120
+ * @param {string} propertyName - property holding the child array on each parent item
30121
+ * @param {Object} childResource - the independent resource created by `resource()`
30122
+ * @param {Object} [restCallbacks] - `{ rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30123
+ * @see docs/resource.md
30124
+ */
30047
30125
  stateFacade.many = (
30048
30126
  propertyName,
30049
30127
  childResource,
@@ -30340,6 +30418,29 @@ ${originalActionName} source location: ${locationInfo}`,
30340
30418
  });
30341
30419
  };
30342
30420
 
30421
+ /**
30422
+ * Attaches a single private sub-object to each item. The child has no identity outside
30423
+ * its owner and is not shared across items; each owner gets its own private signal.
30424
+ *
30425
+ * Use it for a sub-resource the backend exposes under the parent (`/users/:id/profile`)
30426
+ * whose content is meaningless without that parent.
30427
+ *
30428
+ * All callbacks must return `[ownerId, props | null]`:
30429
+ * - `GET: async ({ id }) => [id, { bio: "Hello", avatar: "alice.png" }]`
30430
+ * - `PATCH: async ({ id, bio }) => [id, { bio, avatar: "alice.png" }]`
30431
+ * - `DELETE: async ({ id }) => [id, null]`
30432
+ *
30433
+ * `ownerId` may also be `{ [uniqueKey]: value }` when the owner is known by an alternate key.
30434
+ * The property is `null` until a callback provides data; setting it to `null` clears it.
30435
+ * Mutations apply directly to the owner's signal, so the parent GET is never rerun.
30436
+ *
30437
+ * Returns the child relationship resource, itself chainable:
30438
+ * `USER_PROFILE.one("theme", THEME)` adds a reactive `.theme` property on each profile.
30439
+ *
30440
+ * @param {string} propertyName - property holding the sub-object on each owner item
30441
+ * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, POST, PUT, PATCH, DELETE }`
30442
+ * @see docs/resource.md
30443
+ */
30343
30444
  stateFacade.scopedOne = (
30344
30445
  propertyName,
30345
30446
  {
@@ -30461,6 +30562,36 @@ ${originalActionName} source location: ${locationInfo}`,
30461
30562
  return childResource;
30462
30563
  };
30463
30564
 
30565
+ /**
30566
+ * Attaches a private ordered collection of sub-objects to each item. The child objects
30567
+ * have no identity outside their owner — two owners can hold items with the same id that
30568
+ * are completely independent. Each owner gets its own private arraySignalStore.
30569
+ *
30570
+ * This is the shape for a backend sub-route (`/games/:id/candidates`,
30571
+ * `…/candidates/:userId/accept`) and for a relation carrying its own fields
30572
+ * (`candidate_since`, `seen_at`): those fields belong to the pair, not to a shared child
30573
+ * store where they would corrupt the entity for every other reader.
30574
+ *
30575
+ * All callbacks must return `[ownerId, ...rest]`:
30576
+ * - `GET_MANY: async ({ id }) => [id, [{ name: "id", type: "int" }, …]]` — replaces the collection
30577
+ * - `POST: async ({ id, name, type }) => [id, { name, type }]`
30578
+ * - `PUT: async ({ id, oldName, name, type }) => [id, oldName, { name, type }]` (id rename)
30579
+ * - `DELETE: async ({ id, name }) => [id, name]`
30580
+ * - `*_MANY: [ownerId, itemArray]` — any plural verb replaces the whole collection,
30581
+ * which is how a backend answering a sub-route with the refreshed parent is absorbed
30582
+ *
30583
+ * `ownerId` may also be `{ [uniqueKey]: value }` when the owner is known by an alternate key.
30584
+ * A singular POST upserts the child but does not append it to the collection; ordering is
30585
+ * the backend's, so the owner's GET is rerun instead (only when its last response embedded
30586
+ * `propertyName`), and the child's own GET_MANY reruns per its `rerunOn`.
30587
+ *
30588
+ * Returns the child relationship resource, itself chainable:
30589
+ * `TABLE_COLUMNS.one("dataType", DATA_TYPE)` adds a reactive `.dataType` property on each column.
30590
+ *
30591
+ * @param {string} propertyName - property holding the collection on each owner item
30592
+ * @param {Object} [restCallbacks] - `{ idKey, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30593
+ * @see docs/resource.md
30594
+ */
30464
30595
  stateFacade.scopedMany = (
30465
30596
  propertyName,
30466
30597
  {
@@ -37600,8 +37731,8 @@ const withPixelUnit = value => {
37600
37731
  * because the document is the scrollport in the common case and an anchor
37601
37732
  * landing under a bar is never what anyone wants.
37602
37733
  *
37603
- * The variables hold the bar's measured size — see the comment where FixedBar
37604
- * sets them.
37734
+ * The variables hold the measured size of the bars on that edge — see the
37735
+ * comment where FixedBar sets them.
37605
37736
  */
37606
37737
 
37607
37738
  const FIXED_BAR_SPACE_CSS = /* css */ `
@@ -37631,17 +37762,42 @@ const FIXED_BAR_SPACE_CSS = /* css */ `
37631
37762
  }
37632
37763
  `;
37633
37764
 
37765
+ // Several bars can share an edge — during a page transition the outgoing and
37766
+ // the incoming one are both mounted. They are all pinned to that same edge, so
37767
+ // they overlap: the room to give back is the largest of them, not their sum,
37768
+ // and one leaving must leave the others' room in place.
37769
+ const sizeMapByArea = new Map();
37770
+
37634
37771
  /**
37635
37772
  * @param {"top"|"bottom"|"left"|"right"} area
37636
- * @param {string|null} value - `null` gives the room back to the content.
37773
+ * @param {Element} barElement - Which bar this size belongs to.
37774
+ * @param {number|null} size - In px; `null` gives that bar's room back to the
37775
+ * content.
37637
37776
  */
37638
- const setFixedBarSpace = (area, value) => {
37777
+ const setFixedBarSpace = (area, barElement, size) => {
37778
+ let sizeMap = sizeMapByArea.get(area);
37779
+ if (!sizeMap) {
37780
+ sizeMap = new Map();
37781
+ sizeMapByArea.set(area, sizeMap);
37782
+ }
37783
+ if (size === null) {
37784
+ sizeMap.delete(barElement);
37785
+ } else {
37786
+ sizeMap.set(barElement, size);
37787
+ }
37788
+
37789
+ let largestSize = 0;
37790
+ for (const barSize of sizeMap.values()) {
37791
+ if (barSize > largestSize) {
37792
+ largestSize = barSize;
37793
+ }
37794
+ }
37639
37795
  const property = `--navi-fixed-bar-space-${area}`;
37640
37796
  const { style } = document.documentElement;
37641
- if (value === null) {
37797
+ if (sizeMap.size === 0) {
37642
37798
  style.removeProperty(property);
37643
37799
  } else {
37644
- style.setProperty(property, value);
37800
+ style.setProperty(property, `${largestSize}px`);
37645
37801
  }
37646
37802
  };
37647
37803
 
@@ -37818,6 +37974,9 @@ const FixedBar = ({
37818
37974
  // rebuilt as a calc() expression, so a size coming from anywhere — a prop, a
37819
37975
  // theme variable, the content itself — is reserved just the same, and each
37820
37976
  // `env()` inset stays the browser's business alone.
37977
+ // And measured again whenever it changes: a ResizeObserver on the bar covers
37978
+ // in one go a size prop that changes, content arriving or leaving, a font
37979
+ // loading late, a rotation moving the notch.
37821
37980
  const vertical = area === "left" || area === "right";
37822
37981
  const {
37823
37982
  ref
@@ -37827,15 +37986,21 @@ const FixedBar = ({
37827
37986
  if (!barElement) {
37828
37987
  return undefined;
37829
37988
  }
37830
- const {
37831
- width,
37832
- height
37833
- } = barElement.getBoundingClientRect();
37834
- setFixedBarSpace(area, `${vertical ? width : height}px`);
37989
+ const publishSize = () => {
37990
+ const {
37991
+ width,
37992
+ height
37993
+ } = barElement.getBoundingClientRect();
37994
+ setFixedBarSpace(area, barElement, vertical ? width : height);
37995
+ };
37996
+ publishSize();
37997
+ const resizeObserver = new ResizeObserver(publishSize);
37998
+ resizeObserver.observe(barElement);
37835
37999
  return () => {
37836
- setFixedBarSpace(area, null);
38000
+ resizeObserver.disconnect();
38001
+ setFixedBarSpace(area, barElement, null);
37837
38002
  };
37838
- }, [area]);
38003
+ }, [area, vertical]);
37839
38004
  return jsx(Box, {
37840
38005
  baseClassName: "navi_fixed_bar",
37841
38006
  "data-area": area,
@@ -38619,7 +38784,7 @@ const useCheckableProps = (props, options) => {
38619
38784
  installImportMetaCssBuild(import.meta);const css$F = /* css */`
38620
38785
  @layer navi {
38621
38786
  .navi_checkbox {
38622
- --border-radius: var(--navi-control-border-radius);
38787
+ --border-radius: var(--navi-checkbox-border-radius);
38623
38788
  --border-width: var(--navi-control-border-width);
38624
38789
  /* Focus outline */
38625
38790
  --outline-width: var(--navi-focus-outline-width);