@colixsystems/widget-sdk 0.78.0 → 0.80.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 CHANGED
@@ -26,6 +26,9 @@ The data layer lives in **four separate domain-client packages**, each instantia
26
26
  | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
27
27
  | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
28
28
  | **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`. |
29
+ | **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. |
30
+ | **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. |
31
+ | **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. |
29
32
  | **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. |
30
33
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
31
34
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
@@ -301,6 +304,10 @@ useEffect(() => {
301
304
 
302
305
  ### What's new in 0.41.0
303
306
 
307
+ **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.
308
+
309
+ **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.
310
+
304
311
  **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.
305
312
 
306
313
  ### What's new in 0.40.2
@@ -630,7 +637,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
630
637
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
631
638
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
632
639
  - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget.
633
- - **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
640
+ - **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them.
634
641
  - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
635
642
  - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`.
636
643
  - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
@@ -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
@@ -358,6 +358,68 @@ const HOOKS = [
358
358
  requiredContextSlice: ["refresh.subscribe"],
359
359
  scopes: null,
360
360
  },
361
+ {
362
+ name: "useContainerWidth",
363
+ signature: "useContainerWidth()",
364
+ description:
365
+ "Measure the width the widget's OWN box has, so it can lay itself out " +
366
+ "for the space it is in rather than for the screen. Returns " +
367
+ "`[width, onLayout]`; spread the handler onto the widget's outermost " +
368
+ "primitive. A widget in a 1-of-3 grid cell on a desktop page has " +
369
+ "phone-width room while a widget filling a phone page does not, and the " +
370
+ "window width answers neither question — this is why a table-shaped " +
371
+ "widget must not switch on the device. `onLayout` is the react-native " +
372
+ "callback, so ONE implementation serves the web Player (through " +
373
+ "react-native-web) and the native export. Before the first layout the " +
374
+ "width is 0: treat that as \"not yet measured\" and render the wide " +
375
+ "form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
376
+ "the same threshold. Takes no host context, so it is safe everywhere " +
377
+ "including the Studio canvas.",
378
+ returnShape: {
379
+ "(returns)": "[number, function]",
380
+ },
381
+ requiredContextSlice: [],
382
+ scopes: null,
383
+ },
384
+ {
385
+ name: "isNarrowWidth",
386
+ signature: "isNarrowWidth(width)",
387
+ description:
388
+ "True when a MEASURED width is below `NARROW_WIDTH_PX` (480), the one " +
389
+ "threshold at which a widget adopts its narrow form — a table becomes " +
390
+ "stacked cards, a toolbar wraps. One number so a page of widgets " +
391
+ "reflows together instead of raggedly. An unmeasured width (0) is " +
392
+ "deliberately NOT narrow, so a widget that never lays out keeps its " +
393
+ "historical wide rendering.",
394
+ returnShape: {
395
+ "(returns)": "boolean",
396
+ },
397
+ requiredContextSlice: [],
398
+ scopes: null,
399
+ },
400
+ {
401
+ name: "useSectionEmpty",
402
+ signature: "useSectionEmpty(isEmpty)",
403
+ description:
404
+ "Declare that this widget currently has NO content to show, so the host " +
405
+ "removes its layout slot instead of reserving space for it. Returning " +
406
+ "null is NOT enough on its own: the host wraps every widget node in an " +
407
+ "entrance wrapper, so a widget that renders nothing still leaves an " +
408
+ "empty box its parent stack puts `gap` around — a dead band of " +
409
+ "whitespace where the content would have been. Only the widget has the " +
410
+ "rows, so only the widget can say. Use it for a CONDITIONALLY ABSENT " +
411
+ "section (a per-record child collection with no rows for the record " +
412
+ "being viewed), never to suppress a genuine empty state a user is " +
413
+ "expected to fill. The widget stays mounted while collapsed, so passing " +
414
+ "false later brings the section back. Authoring surfaces (Studio canvas, " +
415
+ "Agent Mode edit preview) never collapse, so an absent section stays " +
416
+ "editable. Safe on a host that does not implement it — a no-op there.",
417
+ returnShape: {
418
+ "(returns)": "void",
419
+ },
420
+ requiredContextSlice: ["section.reportEmpty"],
421
+ scopes: null,
422
+ },
361
423
  {
362
424
  name: "useNavigation",
363
425
  signature: "useNavigation()",
@@ -1438,6 +1500,21 @@ const WIDGET_CONTEXT_SHAPE = {
1438
1500
  required: false,
1439
1501
  fields: { subscribe: "function" },
1440
1502
  },
1503
+ // sc-4416 — widget -> host emptiness signal. The widget owns the knowledge
1504
+ // (it has the rows); the host owns the spacing (the slot and its gap).
1505
+ // Optional so an AUTHORING host can omit it on purpose: with no slice the
1506
+ // hook is a no-op and an empty widget stays visible and selectable on the
1507
+ // Studio canvas and in the Agent Mode edit preview.
1508
+ section: {
1509
+ description:
1510
+ "Optional layout-slot slot. { reportEmpty(isEmpty) }. Called by the " +
1511
+ "widget when it has no content to show; the host then drops the node's " +
1512
+ "layout slot so its parent stack puts no gap around it. Backs " +
1513
+ "useSectionEmpty(). Omitted on authoring surfaces, where an empty " +
1514
+ "widget must stay visible.",
1515
+ required: false,
1516
+ fields: { reportEmpty: "function" },
1517
+ },
1441
1518
  events: {
1442
1519
  description: "{ emit(name, payload) }.",
1443
1520
  required: true,
@@ -2368,7 +2445,15 @@ const CONTRACT = deepFreeze({
2368
2445
  // public endpoint instead, which skips the cache, the metering and the
2369
2446
  // workspace's provider. Publishing the host list here keeps the linter,
2370
2447
  // the Developer guide and the agent prompt reading one source.
2371
- version: "1.52.0",
2448
+ // 1.54.0: additive (sc-4399, epic 4395) — `useContainerWidth()` +
2449
+ // `isNarrowWidth(width)` / `NARROW_WIDTH_PX`. Built-in widgets laid
2450
+ // themselves out at a fixed size — UserManagement's rows alone carried
2451
+ // ~800px of minWidth — so a page that reflowed around them still
2452
+ // overflowed, because the widget inside demanded desktop width. Gallery
2453
+ // already measured its own box with onLayout; this promotes that one
2454
+ // pattern to the SDK so custom and marketplace widgets get it too,
2455
+ // instead of each rolling its own.
2456
+ version: "1.54.0",
2372
2457
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2373
2458
  hooks: HOOKS,
2374
2459
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -358,6 +358,68 @@ const HOOKS = [
358
358
  requiredContextSlice: ["refresh.subscribe"],
359
359
  scopes: null,
360
360
  },
361
+ {
362
+ name: "useContainerWidth",
363
+ signature: "useContainerWidth()",
364
+ description:
365
+ "Measure the width the widget's OWN box has, so it can lay itself out " +
366
+ "for the space it is in rather than for the screen. Returns " +
367
+ "`[width, onLayout]`; spread the handler onto the widget's outermost " +
368
+ "primitive. A widget in a 1-of-3 grid cell on a desktop page has " +
369
+ "phone-width room while a widget filling a phone page does not, and the " +
370
+ "window width answers neither question — this is why a table-shaped " +
371
+ "widget must not switch on the device. `onLayout` is the react-native " +
372
+ "callback, so ONE implementation serves the web Player (through " +
373
+ "react-native-web) and the native export. Before the first layout the " +
374
+ "width is 0: treat that as \"not yet measured\" and render the wide " +
375
+ "form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
376
+ "the same threshold. Takes no host context, so it is safe everywhere " +
377
+ "including the Studio canvas.",
378
+ returnShape: {
379
+ "(returns)": "[number, function]",
380
+ },
381
+ requiredContextSlice: [],
382
+ scopes: null,
383
+ },
384
+ {
385
+ name: "isNarrowWidth",
386
+ signature: "isNarrowWidth(width)",
387
+ description:
388
+ "True when a MEASURED width is below `NARROW_WIDTH_PX` (480), the one " +
389
+ "threshold at which a widget adopts its narrow form — a table becomes " +
390
+ "stacked cards, a toolbar wraps. One number so a page of widgets " +
391
+ "reflows together instead of raggedly. An unmeasured width (0) is " +
392
+ "deliberately NOT narrow, so a widget that never lays out keeps its " +
393
+ "historical wide rendering.",
394
+ returnShape: {
395
+ "(returns)": "boolean",
396
+ },
397
+ requiredContextSlice: [],
398
+ scopes: null,
399
+ },
400
+ {
401
+ name: "useSectionEmpty",
402
+ signature: "useSectionEmpty(isEmpty)",
403
+ description:
404
+ "Declare that this widget currently has NO content to show, so the host " +
405
+ "removes its layout slot instead of reserving space for it. Returning " +
406
+ "null is NOT enough on its own: the host wraps every widget node in an " +
407
+ "entrance wrapper, so a widget that renders nothing still leaves an " +
408
+ "empty box its parent stack puts `gap` around — a dead band of " +
409
+ "whitespace where the content would have been. Only the widget has the " +
410
+ "rows, so only the widget can say. Use it for a CONDITIONALLY ABSENT " +
411
+ "section (a per-record child collection with no rows for the record " +
412
+ "being viewed), never to suppress a genuine empty state a user is " +
413
+ "expected to fill. The widget stays mounted while collapsed, so passing " +
414
+ "false later brings the section back. Authoring surfaces (Studio canvas, " +
415
+ "Agent Mode edit preview) never collapse, so an absent section stays " +
416
+ "editable. Safe on a host that does not implement it — a no-op there.",
417
+ returnShape: {
418
+ "(returns)": "void",
419
+ },
420
+ requiredContextSlice: ["section.reportEmpty"],
421
+ scopes: null,
422
+ },
361
423
  {
362
424
  name: "useNavigation",
363
425
  signature: "useNavigation()",
@@ -1438,6 +1500,21 @@ const WIDGET_CONTEXT_SHAPE = {
1438
1500
  required: false,
1439
1501
  fields: { subscribe: "function" },
1440
1502
  },
1503
+ // sc-4416 — widget -> host emptiness signal. The widget owns the knowledge
1504
+ // (it has the rows); the host owns the spacing (the slot and its gap).
1505
+ // Optional so an AUTHORING host can omit it on purpose: with no slice the
1506
+ // hook is a no-op and an empty widget stays visible and selectable on the
1507
+ // Studio canvas and in the Agent Mode edit preview.
1508
+ section: {
1509
+ description:
1510
+ "Optional layout-slot slot. { reportEmpty(isEmpty) }. Called by the " +
1511
+ "widget when it has no content to show; the host then drops the node's " +
1512
+ "layout slot so its parent stack puts no gap around it. Backs " +
1513
+ "useSectionEmpty(). Omitted on authoring surfaces, where an empty " +
1514
+ "widget must stay visible.",
1515
+ required: false,
1516
+ fields: { reportEmpty: "function" },
1517
+ },
1441
1518
  events: {
1442
1519
  description: "{ emit(name, payload) }.",
1443
1520
  required: true,
@@ -2368,7 +2445,15 @@ const CONTRACT = deepFreeze({
2368
2445
  // public endpoint instead, which skips the cache, the metering and the
2369
2446
  // workspace's provider. Publishing the host list here keeps the linter,
2370
2447
  // the Developer guide and the agent prompt reading one source.
2371
- version: "1.52.0",
2448
+ // 1.54.0: additive (sc-4399, epic 4395) — `useContainerWidth()` +
2449
+ // `isNarrowWidth(width)` / `NARROW_WIDTH_PX`. Built-in widgets laid
2450
+ // themselves out at a fixed size — UserManagement's rows alone carried
2451
+ // ~800px of minWidth — so a page that reflowed around them still
2452
+ // overflowed, because the widget inside demanded desktop width. Gallery
2453
+ // already measured its own box with onLayout; this promotes that one
2454
+ // pattern to the SDK so custom and marketplace widgets get it too,
2455
+ // instead of each rolling its own.
2456
+ version: "1.54.0",
2372
2457
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2373
2458
  hooks: HOOKS,
2374
2459
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -242,6 +242,85 @@ export function useRefresh(handler) {
242
242
  }, []);
243
243
  }
244
244
 
245
+ /**
246
+ * sc-4416 — declare that this widget currently has NO content to show, so the
247
+ * host removes its layout slot instead of reserving space for it.
248
+ *
249
+ * const { data: rows } = useDatastoreQuery(props.tableId, query);
250
+ * useSectionEmpty(rows.length === 0);
251
+ * if (rows.length === 0) return null;
252
+ *
253
+ * Returning `null` is not enough on its own. The host wraps every widget node
254
+ * in an element (the mount-time entrance wrapper), so a widget that renders
255
+ * nothing still leaves an empty box that its parent stack puts `gap` around —
256
+ * a dead band of whitespace exactly where the content would have been. This
257
+ * hook is how the widget tells the host to collapse that slot; nothing else
258
+ * can know, because only the widget has the rows.
259
+ *
260
+ * Reach for it whenever a section is CONDITIONALLY ABSENT — a per-record child
261
+ * collection with no rows for the record being viewed (a policy with no quiz
262
+ * questions), a panel that only applies to some states. Do NOT use it to hide
263
+ * a genuine empty state: a list the user is expected to fill should still say
264
+ * "Nothing here yet" so the page does not look broken.
265
+ *
266
+ * The widget stays MOUNTED while collapsed, so when rows do arrive it simply
267
+ * calls `useSectionEmpty(false)` on the next render and the section reappears.
268
+ * Call it unconditionally (it is a hook) and let the argument carry the state.
269
+ *
270
+ * Authoring surfaces never collapse — the Studio canvas and the Agent Mode
271
+ * edit preview keep an empty widget visible and selectable, or an absent
272
+ * section could not be edited. Safe to call on a host that does not implement
273
+ * it: the hook collapses to a no-op.
274
+ */
275
+ export function useSectionEmpty(isEmpty) {
276
+ const ctx = useWidgetContextOrThrow("useSectionEmpty");
277
+ const report = ctx.section && ctx.section.reportEmpty;
278
+ const reportRef = useRef(report);
279
+ reportRef.current = report;
280
+ const empty = isEmpty === true;
281
+ useEffect(() => {
282
+ const fn = reportRef.current;
283
+ if (typeof fn !== "function") return undefined;
284
+ fn(empty);
285
+ // No cleanup on change — releasing the collapse before re-declaring it
286
+ // would flash the reserved slot back in between the two renders. The host
287
+ // drops its per-node state when the node unmounts.
288
+ return undefined;
289
+ }, [empty]);
290
+ }
291
+
292
+ /**
293
+ * sc-4399 — measure the width the widget's own box actually has, so it can lay
294
+ * itself out for the space it is in rather than for the screen. A widget in a
295
+ * 1-of-3 grid cell on a desktop page has phone-width room; a widget filling a
296
+ * phone page does not. The window width answers neither question.
297
+ *
298
+ * Spread the returned handler onto the widget's outermost primitive:
299
+ *
300
+ * const [width, onLayout] = useContainerWidth();
301
+ * return <View onLayout={onLayout}>{isNarrowWidth(width) ? <Cards/> : <Table/>}</View>;
302
+ *
303
+ * `onLayout` is the react-native primitive callback, so ONE implementation
304
+ * serves the web Player (through react-native-web) and the native export.
305
+ * Before the first layout the width is 0 — treat that as "not yet measured"
306
+ * and render the wide form, which is what `isNarrowWidth`
307
+ * (./container-width.js) does.
308
+ *
309
+ * Takes no context, so it is safe on every host including the Studio canvas.
310
+ */
311
+ export function useContainerWidth() {
312
+ const [width, setWidth] = useState(0);
313
+ const onLayout = useCallback((event) => {
314
+ const next =
315
+ event && event.nativeEvent && event.nativeEvent.layout
316
+ ? event.nativeEvent.layout.width
317
+ : undefined;
318
+ if (typeof next !== "number" || !(next > 0)) return;
319
+ setWidth((prev) => (prev === next ? prev : next));
320
+ }, []);
321
+ return [width, onLayout];
322
+ }
323
+
245
324
  /**
246
325
  * Returns the host-provided navigation surface:
247
326
  * `{ goTo, goBack, push, replace, back, currentRoute }`.
package/dist/index.d.ts CHANGED
@@ -1096,6 +1096,48 @@ export function useRefresh(
1096
1096
  handler: () => void | Promise<unknown>,
1097
1097
  ): void;
1098
1098
 
1099
+ /**
1100
+ * sc-4416 — declare that this widget currently has NO content to show, so the
1101
+ * host removes its layout slot rather than reserving space for it.
1102
+ *
1103
+ * Returning `null` alone is not enough: the host wraps every widget node in an
1104
+ * entrance wrapper, so a widget rendering nothing still leaves an empty box
1105
+ * that its parent stack puts `gap` around. Use it for a CONDITIONALLY ABSENT
1106
+ * section (a per-record child collection with no rows for this record), never
1107
+ * to suppress a genuine empty state. The widget stays mounted while collapsed,
1108
+ * so passing `false` later brings the section back. Authoring surfaces never
1109
+ * collapse. Safe on a host that does not implement it — a no-op there.
1110
+ */
1111
+ export function useSectionEmpty(isEmpty: boolean): void;
1112
+
1113
+ /** The layout event a react-native primitive passes to `onLayout`. */
1114
+ export interface WidgetLayoutEvent {
1115
+ nativeEvent: { layout: { width: number; height: number; x: number; y: number } };
1116
+ }
1117
+
1118
+ /**
1119
+ * sc-4399 — measure the width the widget's OWN box has, so it can lay itself
1120
+ * out for the space it is in rather than for the screen. Spread the returned
1121
+ * handler onto the widget's outermost primitive; ONE implementation serves the
1122
+ * web Player (via react-native-web) and the native export. The width is 0
1123
+ * before the first layout — treat that as "not yet measured" and render the
1124
+ * wide form, which is what `isNarrowWidth` does.
1125
+ */
1126
+ export function useContainerWidth(): [
1127
+ number,
1128
+ (event: WidgetLayoutEvent) => void,
1129
+ ];
1130
+
1131
+ /** The width below which a widget should adopt its narrow form (480). */
1132
+ export const NARROW_WIDTH_PX: number;
1133
+
1134
+ /**
1135
+ * True when a MEASURED width is below `NARROW_WIDTH_PX`. An unmeasured width
1136
+ * (0) is deliberately NOT narrow, so a widget that never lays out keeps its
1137
+ * historical wide rendering.
1138
+ */
1139
+ export function isNarrowWidth(width: number): boolean;
1140
+
1099
1141
  /** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */
1100
1142
  export interface GeolocationOptions {
1101
1143
  enableHighAccuracy?: boolean;
package/dist/index.js CHANGED
@@ -50,10 +50,13 @@ export {
50
50
  usePageContext,
51
51
  useChildRenderer,
52
52
  useRefresh,
53
+ useSectionEmpty,
54
+ useContainerWidth,
53
55
  useGeolocation,
54
56
  GeolocationError,
55
57
  WidgetTree,
56
58
  } from "./hooks.js";
59
+ export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
57
60
  // REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
58
61
  // (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
59
62
  // the web variant and index.native.js picks the native variant.
@@ -50,10 +50,13 @@ export {
50
50
  usePageContext,
51
51
  useChildRenderer,
52
52
  useRefresh,
53
+ useSectionEmpty,
54
+ useContainerWidth,
53
55
  useGeolocation,
54
56
  GeolocationError,
55
57
  WidgetTree,
56
58
  } from "./hooks.js";
59
+ export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
57
60
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
58
61
  export { useClipboard, ClipboardError } from "./clipboard.native.js";
59
62
  export { useToast } from "./toast.native.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.78.0",
3
+ "version": "0.80.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",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"