@colixsystems/widget-sdk 0.79.0 → 0.81.0
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 +6 -1
- package/dist/container-width.js +28 -0
- package/dist/contract.cjs +103 -2
- package/dist/contract.js +103 -2
- package/dist/hooks.js +81 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/manifest.cjs +75 -0
- package/dist/manifest.js +75 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,8 +24,11 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
24
24
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
25
25
|
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
|
|
26
26
|
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
|
|
27
|
+
| **CORE** | `useWidgetInput(inputName)` | the published payload, or `undefined` | `ctx.inputs` — no scope. Reads a value ANOTHER widget on the same page published with `useWidgetEvent`. Declare the input in `manifest.inputs`; the page author wires it to one sibling's declared event. The channel retains the last payload, so a widget that mounts later still reads it. `undefined` while unwired or before the first publish — always render a sensible default. Page-scoped and ephemeral: use `useRouteParams()` for state that must survive navigation, the datastore for state that must persist. |
|
|
27
28
|
| **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
|
|
28
29
|
| **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
|
|
30
|
+
| **CORE** | `useContainerWidth()` | `[width, onLayout]` | No context slice, no scope. Measures the width the widget's OWN box has, so it can lay itself out for the space it is in rather than for the screen. Spread the handler onto your outermost primitive. Use it for any widget with a wide form and a narrow one (a table, a toolbar, a row of tiles) — never switch on the device or window width, because a widget in a one-of-three grid cell on desktop has phone-width room and a widget filling a phone page does not. Width is 0 until the first layout: render the WIDE form then. One implementation covers web (react-native-web) and the native export. |
|
|
31
|
+
| **CORE** | `isNarrowWidth(width)` | `boolean` | No context slice, no scope. True when a MEASURED width is below `NARROW_WIDTH_PX` (480) — the one threshold every widget switches at, so a page reflows together rather than raggedly. An unmeasured width (0) is NOT narrow, so nothing flashes through the narrow form on first paint. |
|
|
29
32
|
| **CORE** | `useSectionEmpty(isEmpty)` | `void` | `ctx.section.reportEmpty` — no scope. Declares that the widget has NO content to show, so the host drops its layout slot instead of reserving space (and its parent's `gap`) for it. Returning `null` is not enough: the host wraps every widget in an entrance element, so a widget rendering nothing still leaves an empty box the parent stack gaps around. For a CONDITIONALLY ABSENT section (a per-record child collection with no rows for this record), never to suppress a genuine empty state. Stays mounted while collapsed, so passing `false` brings it back. Authoring surfaces never collapse. No-op on a host that doesn't implement it. |
|
|
30
33
|
| **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
|
|
31
34
|
| **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
|
|
@@ -302,6 +305,8 @@ useEffect(() => {
|
|
|
302
305
|
|
|
303
306
|
### What's new in 0.41.0
|
|
304
307
|
|
|
308
|
+
**New `useContainerWidth()` hook + `isNarrowWidth(width)` / `NARROW_WIDTH_PX` (sc-4399).** A widget can now measure the width of its OWN box and lay itself out for the space it is in. This is the capability that was missing for every widget except Gallery, which had hand-rolled the same `onLayout` measurement for its carousel — that copy is now gone and Gallery reads the hook. It matters because the screen is the wrong question: a widget in a one-of-three grid cell on a desktop page has phone-width room, and a widget filling a phone page does not, so a table-shaped widget that switches on the device is wrong in both directions. `isNarrowWidth` gives every widget one threshold (480) to switch at, so a page of them reflows together instead of raggedly, and an unmeasured width (0) is deliberately not narrow so nothing flashes through its narrow form on first paint. `onLayout` is a react-native primitive callback, so ONE implementation serves the web Player and the exported app. Additive — `CONTRACT.version` bumped to the next minor for two new hooks.
|
|
309
|
+
|
|
305
310
|
**New `useSectionEmpty(isEmpty)` hook + optional `ctx.section` slice (sc-4416).** A widget can now tell the host it has no content to show, and the host removes its layout slot rather than reserving space for it. This closes a gap that `null` alone could not: the host wraps every widget node in an entrance element, so a widget that rendered nothing still left an empty box its parent stack put `gap` around — a dead band of whitespace exactly where the content would have been. It matters most for a per-record child collection (a policy detail page whose quiz section only exists for policies that have questions): the widget owns the rows, so only the widget can say, and `visibleWhen` cannot reach it because "has related rows" is not a field the record carries. The widget stays MOUNTED while collapsed, so when rows arrive it reports `false` and the section returns on its own — no measurement, no second pass. The slot (`ctx.section.reportEmpty`) is optional and deliberately omitted on authoring surfaces: on the Studio canvas and in the Agent Mode edit preview an empty widget must stay visible and selectable, or an absent section could never be edited. Additive — `CONTRACT.version` bumped to the next minor for a new hook plus a new optional context slice.
|
|
306
311
|
|
|
307
312
|
**New `useRefresh(handler)` hook + page-level refresh signal (sc-1179).** Pull-to-refresh on the mobile web Player + the native Expo export's `RefreshControl` now fans a page-level refresh tick out to every widget on the page. The three datastore hooks — `useDatastoreQuery`, `useDatastoreRecord`, `useAsset` — auto-subscribe their own `refetch`, so a widget built on those hooks gets refreshed for free. Widgets that need to re-run other work (a third-party `fetch`, a derived calculation) call `useRefresh(async () => { … })` directly. The handler may return a Promise — the host waits on `Promise.allSettled` of every subscriber before clearing the spinner. The slot (`ctx.refresh.subscribe`) is optional on the WidgetContext: a host that does not implement refresh (the Studio canvas preview) simply omits it and the hook collapses to a no-op. Additive — `CONTRACT.version` bumped to the next minor since the contract grew a new hook + a new (optional) context slice.
|
|
@@ -615,7 +620,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
615
620
|
|
|
616
621
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
617
622
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
618
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
|
|
623
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
|
|
619
624
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
620
625
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
621
626
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// sc-4399 — the narrow-form threshold, in its own dependency-free module.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately NOT in hooks.js: that file's test harnesses load it through a
|
|
4
|
+
// hand-rolled transform that only understands `export function`, so an
|
|
5
|
+
// `export const` there breaks every hooks test at once. Keeping the constant
|
|
6
|
+
// here also means a consumer that just wants the number pays no React import.
|
|
7
|
+
//
|
|
8
|
+
// `useContainerWidth` itself stays in hooks.js, where React is already
|
|
9
|
+
// imported — it is the hook that measures; these two only classify.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The width below which a widget should adopt its narrow form — a table
|
|
13
|
+
* becomes stacked label/value blocks, a toolbar wraps. ONE number so a page of
|
|
14
|
+
* widgets reflows together instead of raggedly.
|
|
15
|
+
*/
|
|
16
|
+
export const NARROW_WIDTH_PX = 480;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when a MEASURED width is below the threshold.
|
|
20
|
+
*
|
|
21
|
+
* An unmeasured width (0, before the first `onLayout`) is deliberately NOT
|
|
22
|
+
* narrow: the wide rendering is the historical one, so a widget that never
|
|
23
|
+
* lays out is unchanged, and no widget flashes through its narrow form on
|
|
24
|
+
* first paint.
|
|
25
|
+
*/
|
|
26
|
+
export function isNarrowWidth(width) {
|
|
27
|
+
return typeof width === "number" && width > 0 && width < NARROW_WIDTH_PX;
|
|
28
|
+
}
|
package/dist/contract.cjs
CHANGED
|
@@ -226,6 +226,17 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
226
226
|
}),
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
+
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
230
|
+
// than propertySchema VALID_TYPES on purpose: a payload is DATA that crosses to
|
|
231
|
+
// the native export, so it is the JSON-serializable set, not the editor set.
|
|
232
|
+
const PAYLOAD_VALUE_TYPES = Object.freeze([
|
|
233
|
+
"string",
|
|
234
|
+
"number",
|
|
235
|
+
"boolean",
|
|
236
|
+
"object",
|
|
237
|
+
"array",
|
|
238
|
+
]);
|
|
239
|
+
|
|
229
240
|
const HOOKS = [
|
|
230
241
|
{
|
|
231
242
|
name: "useTheme",
|
|
@@ -358,6 +369,45 @@ const HOOKS = [
|
|
|
358
369
|
requiredContextSlice: ["refresh.subscribe"],
|
|
359
370
|
scopes: null,
|
|
360
371
|
},
|
|
372
|
+
{
|
|
373
|
+
name: "useContainerWidth",
|
|
374
|
+
signature: "useContainerWidth()",
|
|
375
|
+
description:
|
|
376
|
+
"Measure the width the widget's OWN box has, so it can lay itself out " +
|
|
377
|
+
"for the space it is in rather than for the screen. Returns " +
|
|
378
|
+
"`[width, onLayout]`; spread the handler onto the widget's outermost " +
|
|
379
|
+
"primitive. A widget in a 1-of-3 grid cell on a desktop page has " +
|
|
380
|
+
"phone-width room while a widget filling a phone page does not, and the " +
|
|
381
|
+
"window width answers neither question — this is why a table-shaped " +
|
|
382
|
+
"widget must not switch on the device. `onLayout` is the react-native " +
|
|
383
|
+
"callback, so ONE implementation serves the web Player (through " +
|
|
384
|
+
"react-native-web) and the native export. Before the first layout the " +
|
|
385
|
+
"width is 0: treat that as \"not yet measured\" and render the wide " +
|
|
386
|
+
"form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
|
|
387
|
+
"the same threshold. Takes no host context, so it is safe everywhere " +
|
|
388
|
+
"including the Studio canvas.",
|
|
389
|
+
returnShape: {
|
|
390
|
+
"(returns)": "[number, function]",
|
|
391
|
+
},
|
|
392
|
+
requiredContextSlice: [],
|
|
393
|
+
scopes: null,
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "isNarrowWidth",
|
|
397
|
+
signature: "isNarrowWidth(width)",
|
|
398
|
+
description:
|
|
399
|
+
"True when a MEASURED width is below `NARROW_WIDTH_PX` (480), the one " +
|
|
400
|
+
"threshold at which a widget adopts its narrow form — a table becomes " +
|
|
401
|
+
"stacked cards, a toolbar wraps. One number so a page of widgets " +
|
|
402
|
+
"reflows together instead of raggedly. An unmeasured width (0) is " +
|
|
403
|
+
"deliberately NOT narrow, so a widget that never lays out keeps its " +
|
|
404
|
+
"historical wide rendering.",
|
|
405
|
+
returnShape: {
|
|
406
|
+
"(returns)": "boolean",
|
|
407
|
+
},
|
|
408
|
+
requiredContextSlice: [],
|
|
409
|
+
scopes: null,
|
|
410
|
+
},
|
|
361
411
|
{
|
|
362
412
|
name: "useSectionEmpty",
|
|
363
413
|
signature: "useSectionEmpty(isEmpty)",
|
|
@@ -736,6 +786,18 @@ const HOOKS = [
|
|
|
736
786
|
requiredContextSlice: ["events.emit"],
|
|
737
787
|
scopes: null,
|
|
738
788
|
},
|
|
789
|
+
{
|
|
790
|
+
name: "useWidgetInput",
|
|
791
|
+
signature: "useWidgetInput(inputName)",
|
|
792
|
+
returnShape: {
|
|
793
|
+
value:
|
|
794
|
+
"The latest payload published on the sibling event this input is " +
|
|
795
|
+
"wired to, or undefined until the producer has published. The hook " +
|
|
796
|
+
"returns the value itself, not a wrapper.",
|
|
797
|
+
},
|
|
798
|
+
requiredContextSlice: ["inputs.subscribe"],
|
|
799
|
+
scopes: null,
|
|
800
|
+
},
|
|
739
801
|
{
|
|
740
802
|
name: "usePayments",
|
|
741
803
|
signature: "usePayments()",
|
|
@@ -1313,7 +1375,25 @@ const MANIFEST_SCHEMA = {
|
|
|
1313
1375
|
events: {
|
|
1314
1376
|
type: "object[]",
|
|
1315
1377
|
required: true,
|
|
1316
|
-
description:
|
|
1378
|
+
description:
|
|
1379
|
+
"Declared events the widget publishes. Each entry { name, description?, " +
|
|
1380
|
+
"payloadSchema? }. payloadSchema is a FLAT map of payload field name to " +
|
|
1381
|
+
"{ type, label? } — the SAME shape as propertySchema, NOT JSON Schema — " +
|
|
1382
|
+
"where type is one of string, number, boolean, object, array. The payload reaches " +
|
|
1383
|
+
"any sibling widget on the page whose inputs entry the page author wired " +
|
|
1384
|
+
"to this event.",
|
|
1385
|
+
default: [],
|
|
1386
|
+
},
|
|
1387
|
+
inputs: {
|
|
1388
|
+
type: "object[]",
|
|
1389
|
+
required: false,
|
|
1390
|
+
description:
|
|
1391
|
+
"Optional. Values this widget CONSUMES from a sibling widget on the same " +
|
|
1392
|
+
"page. Each entry { name, description?, schema? }; schema is the same flat " +
|
|
1393
|
+
"{ field: { type, label? } } map as events[].payloadSchema. The page author " +
|
|
1394
|
+
"wires each input to ONE sibling widget event; the widget reads the latest " +
|
|
1395
|
+
"published payload with useWidgetInput(name). An unwired input reads " +
|
|
1396
|
+
"undefined, so a widget must always render a sensible default.",
|
|
1317
1397
|
default: [],
|
|
1318
1398
|
},
|
|
1319
1399
|
datastoreTemplate: {
|
|
@@ -1481,6 +1561,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1481
1561
|
required: true,
|
|
1482
1562
|
fields: { emit: "function" },
|
|
1483
1563
|
},
|
|
1564
|
+
inputs: {
|
|
1565
|
+
description:
|
|
1566
|
+
"{ get(name), subscribe(name, handler) } — the page-scoped input wiring " +
|
|
1567
|
+
"ALREADY RESOLVED for this widget instance, so widget code never sees node " +
|
|
1568
|
+
"ids. get() returns the retained value (undefined when unwired); " +
|
|
1569
|
+
"subscribe() returns an unsubscribe function. The host MUST keep this " +
|
|
1570
|
+
"slice referentially STABLE for a given set of author bindings — " +
|
|
1571
|
+
"useWidgetInput depends on the reference to decide when to resubscribe. " +
|
|
1572
|
+
"Backs useWidgetInput().",
|
|
1573
|
+
required: true,
|
|
1574
|
+
fields: { get: "function", subscribe: "function" },
|
|
1575
|
+
},
|
|
1484
1576
|
payments: {
|
|
1485
1577
|
description:
|
|
1486
1578
|
"Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment> }; wire is snake_case (amount_cents, return_path). Backs usePayments(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
|
|
@@ -2406,7 +2498,15 @@ const CONTRACT = deepFreeze({
|
|
|
2406
2498
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2407
2499
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2408
2500
|
// the Developer guide and the agent prompt reading one source.
|
|
2409
|
-
|
|
2501
|
+
// 1.54.0: additive (sc-4399, epic 4395) — `useContainerWidth()` +
|
|
2502
|
+
// `isNarrowWidth(width)` / `NARROW_WIDTH_PX`. Built-in widgets laid
|
|
2503
|
+
// themselves out at a fixed size — UserManagement's rows alone carried
|
|
2504
|
+
// ~800px of minWidth — so a page that reflowed around them still
|
|
2505
|
+
// overflowed, because the widget inside demanded desktop width. Gallery
|
|
2506
|
+
// already measured its own box with onLayout; this promotes that one
|
|
2507
|
+
// pattern to the SDK so custom and marketplace widgets get it too,
|
|
2508
|
+
// instead of each rolling its own.
|
|
2509
|
+
version: "1.55.0",
|
|
2410
2510
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2411
2511
|
hooks: HOOKS,
|
|
2412
2512
|
primitives: PRIMITIVES,
|
|
@@ -2427,6 +2527,7 @@ const CONTRACT = deepFreeze({
|
|
|
2427
2527
|
vettedImports: VETTED_IMPORTS,
|
|
2428
2528
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
|
2429
2529
|
hostApiUrlPatterns: HOST_API_URL_PATTERNS,
|
|
2530
|
+
payloadValueTypes: PAYLOAD_VALUE_TYPES,
|
|
2430
2531
|
translationApiHosts: TRANSLATION_API_HOSTS,
|
|
2431
2532
|
});
|
|
2432
2533
|
|
package/dist/contract.js
CHANGED
|
@@ -226,6 +226,17 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
226
226
|
}),
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
+
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
230
|
+
// than propertySchema VALID_TYPES on purpose: a payload is DATA that crosses to
|
|
231
|
+
// the native export, so it is the JSON-serializable set, not the editor set.
|
|
232
|
+
const PAYLOAD_VALUE_TYPES = Object.freeze([
|
|
233
|
+
"string",
|
|
234
|
+
"number",
|
|
235
|
+
"boolean",
|
|
236
|
+
"object",
|
|
237
|
+
"array",
|
|
238
|
+
]);
|
|
239
|
+
|
|
229
240
|
const HOOKS = [
|
|
230
241
|
{
|
|
231
242
|
name: "useTheme",
|
|
@@ -358,6 +369,45 @@ const HOOKS = [
|
|
|
358
369
|
requiredContextSlice: ["refresh.subscribe"],
|
|
359
370
|
scopes: null,
|
|
360
371
|
},
|
|
372
|
+
{
|
|
373
|
+
name: "useContainerWidth",
|
|
374
|
+
signature: "useContainerWidth()",
|
|
375
|
+
description:
|
|
376
|
+
"Measure the width the widget's OWN box has, so it can lay itself out " +
|
|
377
|
+
"for the space it is in rather than for the screen. Returns " +
|
|
378
|
+
"`[width, onLayout]`; spread the handler onto the widget's outermost " +
|
|
379
|
+
"primitive. A widget in a 1-of-3 grid cell on a desktop page has " +
|
|
380
|
+
"phone-width room while a widget filling a phone page does not, and the " +
|
|
381
|
+
"window width answers neither question — this is why a table-shaped " +
|
|
382
|
+
"widget must not switch on the device. `onLayout` is the react-native " +
|
|
383
|
+
"callback, so ONE implementation serves the web Player (through " +
|
|
384
|
+
"react-native-web) and the native export. Before the first layout the " +
|
|
385
|
+
"width is 0: treat that as \"not yet measured\" and render the wide " +
|
|
386
|
+
"form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
|
|
387
|
+
"the same threshold. Takes no host context, so it is safe everywhere " +
|
|
388
|
+
"including the Studio canvas.",
|
|
389
|
+
returnShape: {
|
|
390
|
+
"(returns)": "[number, function]",
|
|
391
|
+
},
|
|
392
|
+
requiredContextSlice: [],
|
|
393
|
+
scopes: null,
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "isNarrowWidth",
|
|
397
|
+
signature: "isNarrowWidth(width)",
|
|
398
|
+
description:
|
|
399
|
+
"True when a MEASURED width is below `NARROW_WIDTH_PX` (480), the one " +
|
|
400
|
+
"threshold at which a widget adopts its narrow form — a table becomes " +
|
|
401
|
+
"stacked cards, a toolbar wraps. One number so a page of widgets " +
|
|
402
|
+
"reflows together instead of raggedly. An unmeasured width (0) is " +
|
|
403
|
+
"deliberately NOT narrow, so a widget that never lays out keeps its " +
|
|
404
|
+
"historical wide rendering.",
|
|
405
|
+
returnShape: {
|
|
406
|
+
"(returns)": "boolean",
|
|
407
|
+
},
|
|
408
|
+
requiredContextSlice: [],
|
|
409
|
+
scopes: null,
|
|
410
|
+
},
|
|
361
411
|
{
|
|
362
412
|
name: "useSectionEmpty",
|
|
363
413
|
signature: "useSectionEmpty(isEmpty)",
|
|
@@ -736,6 +786,18 @@ const HOOKS = [
|
|
|
736
786
|
requiredContextSlice: ["events.emit"],
|
|
737
787
|
scopes: null,
|
|
738
788
|
},
|
|
789
|
+
{
|
|
790
|
+
name: "useWidgetInput",
|
|
791
|
+
signature: "useWidgetInput(inputName)",
|
|
792
|
+
returnShape: {
|
|
793
|
+
value:
|
|
794
|
+
"The latest payload published on the sibling event this input is " +
|
|
795
|
+
"wired to, or undefined until the producer has published. The hook " +
|
|
796
|
+
"returns the value itself, not a wrapper.",
|
|
797
|
+
},
|
|
798
|
+
requiredContextSlice: ["inputs.subscribe"],
|
|
799
|
+
scopes: null,
|
|
800
|
+
},
|
|
739
801
|
{
|
|
740
802
|
name: "usePayments",
|
|
741
803
|
signature: "usePayments()",
|
|
@@ -1313,7 +1375,25 @@ const MANIFEST_SCHEMA = {
|
|
|
1313
1375
|
events: {
|
|
1314
1376
|
type: "object[]",
|
|
1315
1377
|
required: true,
|
|
1316
|
-
description:
|
|
1378
|
+
description:
|
|
1379
|
+
"Declared events the widget publishes. Each entry { name, description?, " +
|
|
1380
|
+
"payloadSchema? }. payloadSchema is a FLAT map of payload field name to " +
|
|
1381
|
+
"{ type, label? } — the SAME shape as propertySchema, NOT JSON Schema — " +
|
|
1382
|
+
"where type is one of string, number, boolean, object, array. The payload reaches " +
|
|
1383
|
+
"any sibling widget on the page whose inputs entry the page author wired " +
|
|
1384
|
+
"to this event.",
|
|
1385
|
+
default: [],
|
|
1386
|
+
},
|
|
1387
|
+
inputs: {
|
|
1388
|
+
type: "object[]",
|
|
1389
|
+
required: false,
|
|
1390
|
+
description:
|
|
1391
|
+
"Optional. Values this widget CONSUMES from a sibling widget on the same " +
|
|
1392
|
+
"page. Each entry { name, description?, schema? }; schema is the same flat " +
|
|
1393
|
+
"{ field: { type, label? } } map as events[].payloadSchema. The page author " +
|
|
1394
|
+
"wires each input to ONE sibling widget event; the widget reads the latest " +
|
|
1395
|
+
"published payload with useWidgetInput(name). An unwired input reads " +
|
|
1396
|
+
"undefined, so a widget must always render a sensible default.",
|
|
1317
1397
|
default: [],
|
|
1318
1398
|
},
|
|
1319
1399
|
datastoreTemplate: {
|
|
@@ -1481,6 +1561,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1481
1561
|
required: true,
|
|
1482
1562
|
fields: { emit: "function" },
|
|
1483
1563
|
},
|
|
1564
|
+
inputs: {
|
|
1565
|
+
description:
|
|
1566
|
+
"{ get(name), subscribe(name, handler) } — the page-scoped input wiring " +
|
|
1567
|
+
"ALREADY RESOLVED for this widget instance, so widget code never sees node " +
|
|
1568
|
+
"ids. get() returns the retained value (undefined when unwired); " +
|
|
1569
|
+
"subscribe() returns an unsubscribe function. The host MUST keep this " +
|
|
1570
|
+
"slice referentially STABLE for a given set of author bindings — " +
|
|
1571
|
+
"useWidgetInput depends on the reference to decide when to resubscribe. " +
|
|
1572
|
+
"Backs useWidgetInput().",
|
|
1573
|
+
required: true,
|
|
1574
|
+
fields: { get: "function", subscribe: "function" },
|
|
1575
|
+
},
|
|
1484
1576
|
payments: {
|
|
1485
1577
|
description:
|
|
1486
1578
|
"Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment> }; wire is snake_case (amount_cents, return_path). Backs usePayments(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
|
|
@@ -2406,7 +2498,15 @@ const CONTRACT = deepFreeze({
|
|
|
2406
2498
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2407
2499
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2408
2500
|
// the Developer guide and the agent prompt reading one source.
|
|
2409
|
-
|
|
2501
|
+
// 1.54.0: additive (sc-4399, epic 4395) — `useContainerWidth()` +
|
|
2502
|
+
// `isNarrowWidth(width)` / `NARROW_WIDTH_PX`. Built-in widgets laid
|
|
2503
|
+
// themselves out at a fixed size — UserManagement's rows alone carried
|
|
2504
|
+
// ~800px of minWidth — so a page that reflowed around them still
|
|
2505
|
+
// overflowed, because the widget inside demanded desktop width. Gallery
|
|
2506
|
+
// already measured its own box with onLayout; this promotes that one
|
|
2507
|
+
// pattern to the SDK so custom and marketplace widgets get it too,
|
|
2508
|
+
// instead of each rolling its own.
|
|
2509
|
+
version: "1.55.0",
|
|
2410
2510
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2411
2511
|
hooks: HOOKS,
|
|
2412
2512
|
primitives: PRIMITIVES,
|
|
@@ -2427,6 +2527,7 @@ const CONTRACT = deepFreeze({
|
|
|
2427
2527
|
vettedImports: VETTED_IMPORTS,
|
|
2428
2528
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
|
2429
2529
|
hostApiUrlPatterns: HOST_API_URL_PATTERNS,
|
|
2530
|
+
payloadValueTypes: PAYLOAD_VALUE_TYPES,
|
|
2430
2531
|
translationApiHosts: TRANSLATION_API_HOSTS,
|
|
2431
2532
|
});
|
|
2432
2533
|
|
package/dist/hooks.js
CHANGED
|
@@ -80,6 +80,55 @@ export function useWidgetEvent(name) {
|
|
|
80
80
|
return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* sc-4505 — read a value published by ANOTHER widget on the same page.
|
|
85
|
+
*
|
|
86
|
+
* The page author wires this widget's manifest-declared `inputs` entry to one
|
|
87
|
+
* sibling widget's declared event, so widget code names only its OWN input and
|
|
88
|
+
* never learns which widget feeds it:
|
|
89
|
+
*
|
|
90
|
+
* const period = useWidgetInput("period"); // { month: "2027-02" } | undefined
|
|
91
|
+
*
|
|
92
|
+
* The channel RETAINS the last published payload, so a widget that mounts after
|
|
93
|
+
* the producer published (a tab switch, a lazily rendered section) still reads
|
|
94
|
+
* it. An input the author has not wired — and one whose producer has not
|
|
95
|
+
* published yet — reads `undefined`, so a widget must always render a sensible
|
|
96
|
+
* default rather than an empty state.
|
|
97
|
+
*
|
|
98
|
+
* The value is page-scoped and ephemeral: it is gone once the page unmounts.
|
|
99
|
+
* For state that must survive navigation read `useRouteParams()`; for state
|
|
100
|
+
* that must persist, write it to the datastore.
|
|
101
|
+
*/
|
|
102
|
+
export function useWidgetInput(inputName) {
|
|
103
|
+
const ctx = useWidgetContextOrThrow("useWidgetInput");
|
|
104
|
+
// The host keeps `ctx.inputs` referentially stable for a given set of author
|
|
105
|
+
// bindings (CONTRACT.widgetContextShape.inputs), so depending on it here
|
|
106
|
+
// resubscribes when — and only when — the wiring actually changes.
|
|
107
|
+
const inputs = ctx.inputs;
|
|
108
|
+
const [value, setValue] = useState(() => readInput(inputs, inputName));
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!inputs || typeof inputs.subscribe !== "function") {
|
|
112
|
+
setValue(undefined);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
// The producer may have published between the initial render and this
|
|
116
|
+
// effect, so re-read rather than waiting for the next publish.
|
|
117
|
+
setValue(readInput(inputs, inputName));
|
|
118
|
+
// `() => next` so a payload that is itself a function can never be
|
|
119
|
+
// mistaken for a React state updater.
|
|
120
|
+
return inputs.subscribe(inputName, (next) => setValue(() => next));
|
|
121
|
+
}, [inputs, inputName]);
|
|
122
|
+
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readInput(inputs, inputName) {
|
|
127
|
+
return inputs && typeof inputs.get === "function"
|
|
128
|
+
? inputs.get(inputName)
|
|
129
|
+
: undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
83
132
|
/**
|
|
84
133
|
* Returns the host-provided theme tokens. The host guarantees every field
|
|
85
134
|
* documented in CONTRACT.themeTokens is present (defaults merged with
|
|
@@ -289,6 +338,38 @@ export function useSectionEmpty(isEmpty) {
|
|
|
289
338
|
}, [empty]);
|
|
290
339
|
}
|
|
291
340
|
|
|
341
|
+
/**
|
|
342
|
+
* sc-4399 — measure the width the widget's own box actually has, so it can lay
|
|
343
|
+
* itself out for the space it is in rather than for the screen. A widget in a
|
|
344
|
+
* 1-of-3 grid cell on a desktop page has phone-width room; a widget filling a
|
|
345
|
+
* phone page does not. The window width answers neither question.
|
|
346
|
+
*
|
|
347
|
+
* Spread the returned handler onto the widget's outermost primitive:
|
|
348
|
+
*
|
|
349
|
+
* const [width, onLayout] = useContainerWidth();
|
|
350
|
+
* return <View onLayout={onLayout}>{isNarrowWidth(width) ? <Cards/> : <Table/>}</View>;
|
|
351
|
+
*
|
|
352
|
+
* `onLayout` is the react-native primitive callback, so ONE implementation
|
|
353
|
+
* serves the web Player (through react-native-web) and the native export.
|
|
354
|
+
* Before the first layout the width is 0 — treat that as "not yet measured"
|
|
355
|
+
* and render the wide form, which is what `isNarrowWidth`
|
|
356
|
+
* (./container-width.js) does.
|
|
357
|
+
*
|
|
358
|
+
* Takes no context, so it is safe on every host including the Studio canvas.
|
|
359
|
+
*/
|
|
360
|
+
export function useContainerWidth() {
|
|
361
|
+
const [width, setWidth] = useState(0);
|
|
362
|
+
const onLayout = useCallback((event) => {
|
|
363
|
+
const next =
|
|
364
|
+
event && event.nativeEvent && event.nativeEvent.layout
|
|
365
|
+
? event.nativeEvent.layout.width
|
|
366
|
+
: undefined;
|
|
367
|
+
if (typeof next !== "number" || !(next > 0)) return;
|
|
368
|
+
setWidth((prev) => (prev === next ? prev : next));
|
|
369
|
+
}, []);
|
|
370
|
+
return [width, onLayout];
|
|
371
|
+
}
|
|
372
|
+
|
|
292
373
|
/**
|
|
293
374
|
* Returns the host-provided navigation surface:
|
|
294
375
|
* `{ goTo, goBack, push, replace, back, currentRoute }`.
|
package/dist/index.d.ts
CHANGED
|
@@ -124,6 +124,17 @@ export interface WidgetEventDescriptor {
|
|
|
124
124
|
payloadSchema?: WidgetPropertySchema;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A value the widget CONSUMES from a sibling widget on the same page. The page
|
|
129
|
+
* author wires each input to one sibling event; the widget reads the latest
|
|
130
|
+
* published payload with `useWidgetInput(name)`.
|
|
131
|
+
*/
|
|
132
|
+
export interface WidgetInputDescriptor {
|
|
133
|
+
name: string;
|
|
134
|
+
description?: string;
|
|
135
|
+
schema?: WidgetPropertySchema;
|
|
136
|
+
}
|
|
137
|
+
|
|
127
138
|
/**
|
|
128
139
|
* Optional datastore template a widget can ship in its manifest. When the
|
|
129
140
|
* tenant installs the widget, every declared table is created in their
|
|
@@ -249,6 +260,11 @@ export interface WidgetManifest {
|
|
|
249
260
|
*/
|
|
250
261
|
styleSchema?: WidgetPropertySchema;
|
|
251
262
|
events: WidgetEventDescriptor[];
|
|
263
|
+
/**
|
|
264
|
+
* Optional (sc-4505). Values this widget reads from a sibling widget on the
|
|
265
|
+
* same page, wired by the page author. Read each with `useWidgetInput(name)`.
|
|
266
|
+
*/
|
|
267
|
+
inputs?: WidgetInputDescriptor[];
|
|
252
268
|
/**
|
|
253
269
|
* Optional datastore template seeded into the tenant's workspace when
|
|
254
270
|
* the widget is installed. The author wires the resulting tables into
|
|
@@ -703,6 +719,13 @@ export function useDirectory(query?: DirectoryQuery): DirectoryResult;
|
|
|
703
719
|
|
|
704
720
|
export function useWidgetEvent(name: string): (payload?: unknown) => void;
|
|
705
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Reads the latest payload published on the sibling event this widget input is
|
|
724
|
+
* wired to. `undefined` until the producer publishes, and for an unwired input
|
|
725
|
+
* — so always render a sensible default. Page-scoped and ephemeral.
|
|
726
|
+
*/
|
|
727
|
+
export function useWidgetInput(inputName: string): unknown;
|
|
728
|
+
|
|
706
729
|
/**
|
|
707
730
|
* Arguments for `usePayments().requestPayment(...)`. snake_case VERBATIM —
|
|
708
731
|
* this is the wire contract (REQ-GEN-09). `amount_cents` is the charge in the
|
|
@@ -1110,6 +1133,34 @@ export function useRefresh(
|
|
|
1110
1133
|
*/
|
|
1111
1134
|
export function useSectionEmpty(isEmpty: boolean): void;
|
|
1112
1135
|
|
|
1136
|
+
/** The layout event a react-native primitive passes to `onLayout`. */
|
|
1137
|
+
export interface WidgetLayoutEvent {
|
|
1138
|
+
nativeEvent: { layout: { width: number; height: number; x: number; y: number } };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* sc-4399 — measure the width the widget's OWN box has, so it can lay itself
|
|
1143
|
+
* out for the space it is in rather than for the screen. Spread the returned
|
|
1144
|
+
* handler onto the widget's outermost primitive; ONE implementation serves the
|
|
1145
|
+
* web Player (via react-native-web) and the native export. The width is 0
|
|
1146
|
+
* before the first layout — treat that as "not yet measured" and render the
|
|
1147
|
+
* wide form, which is what `isNarrowWidth` does.
|
|
1148
|
+
*/
|
|
1149
|
+
export function useContainerWidth(): [
|
|
1150
|
+
number,
|
|
1151
|
+
(event: WidgetLayoutEvent) => void,
|
|
1152
|
+
];
|
|
1153
|
+
|
|
1154
|
+
/** The width below which a widget should adopt its narrow form (480). */
|
|
1155
|
+
export const NARROW_WIDTH_PX: number;
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* True when a MEASURED width is below `NARROW_WIDTH_PX`. An unmeasured width
|
|
1159
|
+
* (0) is deliberately NOT narrow, so a widget that never lays out keeps its
|
|
1160
|
+
* historical wide rendering.
|
|
1161
|
+
*/
|
|
1162
|
+
export function isNarrowWidth(width: number): boolean;
|
|
1163
|
+
|
|
1113
1164
|
/** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */
|
|
1114
1165
|
export interface GeolocationOptions {
|
|
1115
1166
|
enableHighAccuracy?: boolean;
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export {
|
|
|
36
36
|
useRecordPermissions,
|
|
37
37
|
useDatastoreSubscription,
|
|
38
38
|
useWidgetEvent,
|
|
39
|
+
useWidgetInput,
|
|
39
40
|
usePayments,
|
|
40
41
|
useSendNotification,
|
|
41
42
|
useTheme,
|
|
@@ -51,10 +52,12 @@ export {
|
|
|
51
52
|
useChildRenderer,
|
|
52
53
|
useRefresh,
|
|
53
54
|
useSectionEmpty,
|
|
55
|
+
useContainerWidth,
|
|
54
56
|
useGeolocation,
|
|
55
57
|
GeolocationError,
|
|
56
58
|
WidgetTree,
|
|
57
59
|
} from "./hooks.js";
|
|
60
|
+
export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
|
|
58
61
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
|
|
59
62
|
// (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
|
|
60
63
|
// the web variant and index.native.js picks the native variant.
|
package/dist/index.native.js
CHANGED
|
@@ -36,6 +36,7 @@ export {
|
|
|
36
36
|
useRecordPermissions,
|
|
37
37
|
useDatastoreSubscription,
|
|
38
38
|
useWidgetEvent,
|
|
39
|
+
useWidgetInput,
|
|
39
40
|
usePayments,
|
|
40
41
|
useSendNotification,
|
|
41
42
|
useTheme,
|
|
@@ -51,10 +52,12 @@ export {
|
|
|
51
52
|
useChildRenderer,
|
|
52
53
|
useRefresh,
|
|
53
54
|
useSectionEmpty,
|
|
55
|
+
useContainerWidth,
|
|
54
56
|
useGeolocation,
|
|
55
57
|
GeolocationError,
|
|
56
58
|
WidgetTree,
|
|
57
59
|
} from "./hooks.js";
|
|
60
|
+
export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
|
|
58
61
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
|
|
59
62
|
export { useClipboard, ClipboardError } from "./clipboard.native.js";
|
|
60
63
|
export { useToast } from "./toast.native.js";
|
package/dist/manifest.cjs
CHANGED
|
@@ -4,6 +4,35 @@
|
|
|
4
4
|
// re-exports the same functions defined here so there is exactly one
|
|
5
5
|
// implementation.
|
|
6
6
|
|
|
7
|
+
const { CONTRACT } = require("./contract.cjs");
|
|
8
|
+
|
|
9
|
+
const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
|
|
10
|
+
|
|
11
|
+
// sc-4505 — a payload / input field map: { <field>: { type, label? } }. This is
|
|
12
|
+
// the FLAT propertySchema-style shape index.d.ts already types, NOT JSON Schema.
|
|
13
|
+
// `type` is restricted to the JSON-serializable set because a payload crosses
|
|
14
|
+
// to the exported Expo app (CLAUDE.md §8).
|
|
15
|
+
function validatePayloadFieldMap(schema, label, errors) {
|
|
16
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
17
|
+
errors.push(`${label} must be an object mapping field name to { type }`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
21
|
+
if (def === null || typeof def !== "object" || Array.isArray(def)) {
|
|
22
|
+
errors.push(`${label}.${field} must be an object`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!PAYLOAD_VALUE_TYPES.includes(def.type)) {
|
|
26
|
+
errors.push(
|
|
27
|
+
`${label}.${field}.type must be one of ${PAYLOAD_VALUE_TYPES.join(", ")}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (def.label !== undefined && typeof def.label !== "string") {
|
|
31
|
+
errors.push(`${label}.${field}.label must be a string when present`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
const VALID_CATEGORIES = new Set([
|
|
8
37
|
"input", "display", "layout", "data", "media", "communication", "administration", "custom",
|
|
9
38
|
"INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "ADMINISTRATION", "CUSTOM",
|
|
@@ -275,6 +304,52 @@ function validateManifest(m) {
|
|
|
275
304
|
}
|
|
276
305
|
if (!isNonEmptyString(e.name)) {
|
|
277
306
|
errors.push("manifest.events[].name must be a non-empty string");
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (e.description !== undefined && !isNonEmptyString(e.description)) {
|
|
310
|
+
errors.push(
|
|
311
|
+
`manifest.events[${e.name}].description must be a non-empty string when present`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (e.payloadSchema !== undefined) {
|
|
315
|
+
validatePayloadFieldMap(
|
|
316
|
+
e.payloadSchema,
|
|
317
|
+
`manifest.events[${e.name}].payloadSchema`,
|
|
318
|
+
errors,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// sc-4505 — `inputs` is optional (additive). Declares the values this widget
|
|
325
|
+
// CONSUMES from a sibling widget event, wired by the page author.
|
|
326
|
+
if (manifest.inputs !== undefined) {
|
|
327
|
+
if (!Array.isArray(manifest.inputs)) {
|
|
328
|
+
errors.push("manifest.inputs must be an array (use [] for none)");
|
|
329
|
+
} else {
|
|
330
|
+
const seenInputs = new Set();
|
|
331
|
+
for (const i of manifest.inputs) {
|
|
332
|
+
if (i === null || typeof i !== "object" || Array.isArray(i)) {
|
|
333
|
+
errors.push("manifest.inputs entries must be objects");
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (!isNonEmptyString(i.name)) {
|
|
337
|
+
errors.push("manifest.inputs[].name must be a non-empty string");
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// A duplicate name would make the author-facing wiring ambiguous.
|
|
341
|
+
if (seenInputs.has(i.name)) {
|
|
342
|
+
errors.push(`manifest.inputs[].name "${i.name}" is declared twice`);
|
|
343
|
+
}
|
|
344
|
+
seenInputs.add(i.name);
|
|
345
|
+
if (i.description !== undefined && !isNonEmptyString(i.description)) {
|
|
346
|
+
errors.push(
|
|
347
|
+
`manifest.inputs[${i.name}].description must be a non-empty string when present`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (i.schema !== undefined) {
|
|
351
|
+
validatePayloadFieldMap(i.schema, `manifest.inputs[${i.name}].schema`, errors);
|
|
352
|
+
}
|
|
278
353
|
}
|
|
279
354
|
}
|
|
280
355
|
}
|
package/dist/manifest.js
CHANGED
|
@@ -4,6 +4,35 @@
|
|
|
4
4
|
// re-exports the same functions defined here so there is exactly one
|
|
5
5
|
// implementation.
|
|
6
6
|
|
|
7
|
+
import { CONTRACT } from "./contract.js";
|
|
8
|
+
|
|
9
|
+
const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
|
|
10
|
+
|
|
11
|
+
// sc-4505 — a payload / input field map: { <field>: { type, label? } }. This is
|
|
12
|
+
// the FLAT propertySchema-style shape index.d.ts already types, NOT JSON Schema.
|
|
13
|
+
// `type` is restricted to the JSON-serializable set because a payload crosses
|
|
14
|
+
// to the exported Expo app (CLAUDE.md §8).
|
|
15
|
+
function validatePayloadFieldMap(schema, label, errors) {
|
|
16
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
17
|
+
errors.push(`${label} must be an object mapping field name to { type }`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
21
|
+
if (def === null || typeof def !== "object" || Array.isArray(def)) {
|
|
22
|
+
errors.push(`${label}.${field} must be an object`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!PAYLOAD_VALUE_TYPES.includes(def.type)) {
|
|
26
|
+
errors.push(
|
|
27
|
+
`${label}.${field}.type must be one of ${PAYLOAD_VALUE_TYPES.join(", ")}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (def.label !== undefined && typeof def.label !== "string") {
|
|
31
|
+
errors.push(`${label}.${field}.label must be a string when present`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
const VALID_CATEGORIES = new Set([
|
|
8
37
|
"input", "display", "layout", "data", "media", "communication", "administration", "custom",
|
|
9
38
|
"INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "ADMINISTRATION", "CUSTOM",
|
|
@@ -275,6 +304,52 @@ function validateManifest(m) {
|
|
|
275
304
|
}
|
|
276
305
|
if (!isNonEmptyString(e.name)) {
|
|
277
306
|
errors.push("manifest.events[].name must be a non-empty string");
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (e.description !== undefined && !isNonEmptyString(e.description)) {
|
|
310
|
+
errors.push(
|
|
311
|
+
`manifest.events[${e.name}].description must be a non-empty string when present`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (e.payloadSchema !== undefined) {
|
|
315
|
+
validatePayloadFieldMap(
|
|
316
|
+
e.payloadSchema,
|
|
317
|
+
`manifest.events[${e.name}].payloadSchema`,
|
|
318
|
+
errors,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// sc-4505 — `inputs` is optional (additive). Declares the values this widget
|
|
325
|
+
// CONSUMES from a sibling widget event, wired by the page author.
|
|
326
|
+
if (manifest.inputs !== undefined) {
|
|
327
|
+
if (!Array.isArray(manifest.inputs)) {
|
|
328
|
+
errors.push("manifest.inputs must be an array (use [] for none)");
|
|
329
|
+
} else {
|
|
330
|
+
const seenInputs = new Set();
|
|
331
|
+
for (const i of manifest.inputs) {
|
|
332
|
+
if (i === null || typeof i !== "object" || Array.isArray(i)) {
|
|
333
|
+
errors.push("manifest.inputs entries must be objects");
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (!isNonEmptyString(i.name)) {
|
|
337
|
+
errors.push("manifest.inputs[].name must be a non-empty string");
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// A duplicate name would make the author-facing wiring ambiguous.
|
|
341
|
+
if (seenInputs.has(i.name)) {
|
|
342
|
+
errors.push(`manifest.inputs[].name "${i.name}" is declared twice`);
|
|
343
|
+
}
|
|
344
|
+
seenInputs.add(i.name);
|
|
345
|
+
if (i.description !== undefined && !isNonEmptyString(i.description)) {
|
|
346
|
+
errors.push(
|
|
347
|
+
`manifest.inputs[${i.name}].description must be a non-empty string when present`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (i.schema !== undefined) {
|
|
351
|
+
validatePayloadFieldMap(i.schema, `manifest.inputs[${i.name}].schema`, errors);
|
|
352
|
+
}
|
|
278
353
|
}
|
|
279
354
|
}
|
|
280
355
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|