@colixsystems/widget-sdk 0.176.0 → 0.180.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 +37 -4
- package/dist/button-style.js +94 -0
- package/dist/button-view.js +15 -4
- package/dist/card-style.js +74 -1
- package/dist/card-view.js +31 -10
- package/dist/contract.cjs +77 -5
- package/dist/contract.js +77 -5
- package/dist/datastore-figure.js +27 -0
- package/dist/figure.js +59 -0
- package/dist/hooks.js +32 -5
- package/dist/host.d.ts +15 -0
- package/dist/host.js +1 -1
- package/dist/index.d.ts +43 -1
- package/dist/index.js +1 -0
- package/dist/index.native.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,8 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
43
43
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
44
44
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
45
45
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
46
|
-
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
46
|
+
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
47
|
+
| **DATASTORE** | `useDatastoreFigure(table, { aggregate, column, filter })` (optional: options) | `{ value, text, truncated, loading, fetching, error, refetch }` | `records(table).list` (a count reads the envelope total; a sum or average up to 1000 rows) — `datastore.read:*`. One count / sum / average for a `<Stat>`, computed exactly as the built-in Stat widget does. |
|
|
47
48
|
| **DATASTORE** | `useDatastoreRecord(table, id, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching`; `{ includeCapabilities: true }` adds `data._capabilities` | `records(table).get` — `datastore.read:<table>` |
|
|
48
49
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
49
50
|
| **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
|
|
@@ -56,7 +57,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
56
57
|
| **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
|
|
57
58
|
| **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
|
|
58
59
|
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
|
|
59
|
-
| **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
|
|
60
|
+
| **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
|
|
60
61
|
| **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
61
62
|
| **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
|
|
62
63
|
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
|
|
@@ -73,7 +74,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
74
|
|
|
74
75
|
## Status
|
|
75
76
|
|
|
76
|
-
`v0.
|
|
77
|
+
`v0.180.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
78
|
+
|
|
79
|
+
### What's new in 0.180.0 (contract 1.141.0)
|
|
80
|
+
|
|
81
|
+
**`<Button>` stays readable on a coloured section.** A section that paints its own fill (a card or band on the theme's colours, or a gradient wash) already gave its text a colour that reads there. Its buttons kept the page's paint, so an `outlined` button drew its label and border in the primary colour on a band washed in that same colour, and a `secondary` fill disappeared into it. A button there is now measured against the section by the same rule as its text: a colour that already reads is kept, and one the section makes unreadable takes the section's text colour, but only when that reads better. "The section makes it unreadable" is measured against the surface the look was designed for: a colour moves only when the section costs it at least a third of the contrast it had there, so a white or lightly tinted panel changes nothing.
|
|
82
|
+
|
|
83
|
+
- `outlined`: a label under 4.5:1 and a border under 3:1 against the section are re-inked.
|
|
84
|
+
- `secondary`: a fill under 3:1 against the section becomes the section's outlined button.
|
|
85
|
+
- `primary` and `danger`: a fill under 3:1 turns inverse, with the section's text colour as its fill and its own colour as its label, so it still leads the quieter buttons. Where that label would not reach 4.5:1, or you set the label colour, the fill is kept and gains an edge in the section's text colour instead.
|
|
86
|
+
|
|
87
|
+
A colour you pass in `fields`, `style` (`backgroundColor`, `borderColor`) or `labelStyle` (`color`), or that the app's button look sets, is never changed. A `<Card>` inside such a section becomes the surface its content reads: it re-derives `surface`, `onSurface`, `border` and the rest from its own fill, as a layout container does, and its buttons are measured against it. Inside a section `useTheme().colors.backdrop` is the fill your widget sits on and `colors.backdropBase` the surface the look was designed for; on the page itself both are absent.
|
|
88
|
+
|
|
89
|
+
### What's new in 0.179.0 (contract 1.140.0)
|
|
90
|
+
|
|
91
|
+
**`<Card>` no longer discards the layout in its `style` (sc-9200).** `style` was read for the `card*` design fields alone, so `<Card style={{ width: "100%" }}>` sized nothing and every layout key vanished without a warning — a widget could not lay its cards out by its measured width, because the `widthTier()` branch set exactly that. A card that brings no `baseStyle` now also takes the box's **layout** from `style`: width, height, the flex properties, `alignSelf`, `gap` and position (`CARD_LAYOUT_STYLE_FIELDS`). The app's card look is kept:
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
<Card style={{ width: "100%", maxWidth: 480, alignSelf: "center" }}>…</Card>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Layout only, deliberately: a widget is told to hand `<Card>` its whole declared style bag, so forwarding everything that is not a `card*` field would put design fields (`background`, `fontSize`, `titleColor`) on the box — and react-native-web renders an unknown key as real CSS where React Native drops it, which is one widget rendering two different cards. The paint stays the card's own vocabulary (`cardBackground`, `cardRadius`, `padding`, `shadow`); anything else about the container goes on `containerStyle`.
|
|
98
|
+
|
|
99
|
+
A card given a `baseStyle` is unchanged: that caller owns the container's layout, so its `style` is still read for the `card*` fields only — the contract the built-in widgets pass their per-instance style on.
|
|
100
|
+
|
|
101
|
+
### What's new in 0.178.0 (contract 1.139.0)
|
|
102
|
+
|
|
103
|
+
**New optional manifest field `drawsFigures` (sc-9150).** A boolean (default `false`) that declares your widget draws a strip of live figures, such as "12 pending" or "€4,200 this month", about the records it shows, or is itself a strip of figures. Set it `true` when your widget draws them (for example with `useDatastoreFigure` and `<Stat>`). The AppStudio app-builder then counts the widget as the page's figures when it reuses it on a page, and does not plan a second strip of figures beside it. Leave it `false` or omit it when the widget draws no figures. Additive: existing manifests omit it and read `false`, so no widget needs changing.
|
|
104
|
+
|
|
105
|
+
### What's new in 0.177.0 (contract 1.138.0)
|
|
106
|
+
|
|
107
|
+
**`useDatastoreFigure(tableId, { aggregate, column, filter })` — one live figure over a table, for a `<Stat>`.** A custom widget showing "12 pending" above its list had to reduce `useDatastoreQuery` rows by hand, and nothing told it to skip blank cells, apply a filter, or admit the 1000-row query ceiling. The built-in Stat widget's figure logic is now this hook, and the Stat widget computes through it. `aggregate` is `"count"` (the default), `"sum"` or `"average"` of the number `column`, and `filter` takes the same shapes as `useDatastoreQuery`. It returns `{ value, text, truncated, loading, fetching, error, refetch }`: pass `text` to `<Stat value>`. It is null while loading or when there is no figure, so the tile prints its empty value, never a fake 0. A count is exact: it reads the server's total and fetches a single row. A sum or average reads up to 1000 rows and, when the table holds more, reads "≈ n" with `truncated: true`. Filter on a bound column only once `useBoundColumns` has resolved (pass `null` as the table until then), or the first read is unfiltered.
|
|
108
|
+
|
|
109
|
+
**`useDatastoreQuery` returns `total`**: how many rows the query matches in all (the envelope's `meta.total`), not only the page in `data`.
|
|
77
110
|
|
|
78
111
|
### What's new in 0.176.0 (contract 1.137.0)
|
|
79
112
|
|
|
@@ -160,7 +193,7 @@ Installed widgets pick up the row fix with no rebuild on the web: the Player han
|
|
|
160
193
|
- **`<Badge>`** is a record's state (Active, Paid, Overdue) beside the thing it describes. `tone` is `neutral` | `info` | `success` | `warning` | `danger`, tinted from the app's theme; the label always shows.
|
|
161
194
|
- **`<Avatar>`** is a person: a picture, else the name's initials, else a person glyph. `size` is `sm` | `md` | `lg` | `xl` or a number of px.
|
|
162
195
|
- **`<Progress>`** is real progress toward a goal, as a `bar` or a `ring`; the percent is clamped to 0-100 and printed.
|
|
163
|
-
- **`<Stat>`** displays a headline figure
|
|
196
|
+
- **`<Stat>`** displays a headline figure (compute a live one with `useDatastoreFigure`), with an optional caption, icon and delta. An empty value prints `emptyValue` (default "—"), never a fake 0.
|
|
164
197
|
- **`<Divider>`** splits two groups inside one surface, optionally captioned. A `<List>` and a `<Card>` already draw their own edges.
|
|
165
198
|
|
|
166
199
|
`initialsFor(name)` is exported too. Additive: the built-in widgets render as before.
|
package/dist/button-style.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// the built-in widgets (frontend `_shared/buttonStyle.js` re-exports these).
|
|
3
3
|
// Platform-free, so both hosts resolve a button identically.
|
|
4
4
|
|
|
5
|
+
import { INK_CONTRAST, contrastRatio, inkForBackdrop, isHexColor } from "./contract.js";
|
|
6
|
+
|
|
5
7
|
export const BUTTON_PADDING_MAX = 64;
|
|
6
8
|
export const BUTTON_BORDER_WIDTH_MAX = 8;
|
|
7
9
|
|
|
@@ -58,3 +60,95 @@ export function selfAlignStyle(style) {
|
|
|
58
60
|
const alignSelf = SELF_ALIGNMENTS[style && style.align];
|
|
59
61
|
return alignSelf ? { alignSelf } : null;
|
|
60
62
|
}
|
|
63
|
+
|
|
64
|
+
// How much of its page contrast a section must cost a colour before it moves:
|
|
65
|
+
// a tint or neutral panel never does, a coloured band always does.
|
|
66
|
+
const SECTION_LOSS = 1.5;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A variant's own paint, measured against the painted section it sits on
|
|
70
|
+
* (`colors.backdrop`) by the rule that section's text follows. A colour moves
|
|
71
|
+
* only where it is unreadable there AND the section is what broke it, measured
|
|
72
|
+
* against the surface the look was designed for (`colors.backdropBase`). Only
|
|
73
|
+
* what the look painted moves; `owned` names what the maker set.
|
|
74
|
+
*
|
|
75
|
+
* A transparent button re-inks its label and border separately. A fill the
|
|
76
|
+
* section swallows is replaced: a quiet `secondary` becomes the section's
|
|
77
|
+
* outlined button, and an emphatic fill turns inverse (the section's ink as
|
|
78
|
+
* fill, its own colour as label), so it still outranks the quiet ones. Where
|
|
79
|
+
* that label would not read, or the maker chose it, the fill gains an edge.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} variant
|
|
82
|
+
* @param {{container: object, ink: string}} paint
|
|
83
|
+
* @param {object} colors - the surface-aware theme colours.
|
|
84
|
+
* @param {{fill?: boolean, label?: boolean, border?: boolean}} [owned]
|
|
85
|
+
* @returns {{container: object, ink: string}} `paint` itself when nothing moves.
|
|
86
|
+
*/
|
|
87
|
+
export function paintOnBackdrop(variant, paint, colors, owned = {}) {
|
|
88
|
+
const { backdrop, backdropBase, onSurface: ink } = colors || {};
|
|
89
|
+
if (owned.fill || ![backdrop, backdropBase, ink].every(isHexColor)) return paint;
|
|
90
|
+
const reink = (colour, minimum) =>
|
|
91
|
+
isHexColor(colour) && brokenBySection(colour, backdrop, backdropBase, minimum)
|
|
92
|
+
? inkForBackdrop(colour, ink, backdrop, minimum)
|
|
93
|
+
: colour;
|
|
94
|
+
const { container } = paint;
|
|
95
|
+
const fill = container.backgroundColor;
|
|
96
|
+
if (fill === "transparent") return reinkOutline(paint, reink, owned);
|
|
97
|
+
if (reink(fill, INK_CONTRAST.boundary) === fill) return paint;
|
|
98
|
+
if (!owned.label && variant === "secondary") {
|
|
99
|
+
return {
|
|
100
|
+
container: { backgroundColor: "transparent", borderWidth: 1, borderColor: ink },
|
|
101
|
+
ink,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (!owned.label && contrastRatio(fill, ink) >= INK_CONTRAST.text) {
|
|
105
|
+
const edge = container.borderColor ? { borderColor: ink } : null;
|
|
106
|
+
return { container: { ...container, backgroundColor: ink, ...edge }, ink: fill };
|
|
107
|
+
}
|
|
108
|
+
if (owned.border) return paint;
|
|
109
|
+
return { container: { ...container, borderWidth: 1, borderColor: ink }, ink: paint.ink };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function brokenBySection(colour, backdrop, base, minimum) {
|
|
113
|
+
const here = contrastRatio(colour, backdrop);
|
|
114
|
+
return here < minimum && here * SECTION_LOSS <= contrastRatio(colour, base);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function reinkOutline(paint, reink, owned) {
|
|
118
|
+
const { borderColor } = paint.container;
|
|
119
|
+
const label = owned.label ? paint.ink : reink(paint.ink, INK_CONTRAST.text);
|
|
120
|
+
const border = owned.border ? borderColor : reink(borderColor, INK_CONTRAST.boundary);
|
|
121
|
+
if (label === paint.ink && border === borderColor) return paint;
|
|
122
|
+
return { container: { ...paint.container, borderColor: border }, ink: label };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SIDE_BORDER_COLORS = [
|
|
126
|
+
"borderColor",
|
|
127
|
+
"borderTopColor",
|
|
128
|
+
"borderRightColor",
|
|
129
|
+
"borderBottomColor",
|
|
130
|
+
"borderLeftColor",
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Which of a button's fill, label and border its caller set, so
|
|
135
|
+
* `paintOnBackdrop` never replaces them: the surface `fields`, and colours in
|
|
136
|
+
* the flattened container `style` and `labelStyle`.
|
|
137
|
+
*/
|
|
138
|
+
export function ownedButtonPaint(variant, fields, style, labelStyle) {
|
|
139
|
+
const f = fields || {};
|
|
140
|
+
const s = style || {};
|
|
141
|
+
const danger = variant === "danger";
|
|
142
|
+
return {
|
|
143
|
+
fill: Boolean(
|
|
144
|
+
f.background || f.gradient || s.backgroundColor || (danger && f.dangerBackground),
|
|
145
|
+
),
|
|
146
|
+
label: Boolean(f.textColor || (labelStyle && labelStyle.color) || (danger && f.dangerTextColor)),
|
|
147
|
+
border: Boolean(
|
|
148
|
+
f.borderColor ||
|
|
149
|
+
f.ringColor ||
|
|
150
|
+
SIDE_BORDER_COLORS.some((key) => s[key]) ||
|
|
151
|
+
(danger && f.dangerBorderColor),
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
}
|
package/dist/button-view.js
CHANGED
|
@@ -8,7 +8,12 @@ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
|
8
8
|
import { lucideGlyph } from "./icon.js";
|
|
9
9
|
import { applyThemeComponentStyle } from "./theme-components.js";
|
|
10
10
|
import { cornerRadiusOverlay, shadowStyle, gradientProps } from "./card-style.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buttonSurfaceOverlay,
|
|
13
|
+
buttonSurfaceTextOverlay,
|
|
14
|
+
ownedButtonPaint,
|
|
15
|
+
paintOnBackdrop,
|
|
16
|
+
} from "./button-style.js";
|
|
12
17
|
|
|
13
18
|
export const BUTTON_VARIANTS = Object.freeze(["primary", "secondary", "outlined", "danger"]);
|
|
14
19
|
|
|
@@ -39,8 +44,8 @@ function variantPaint(variant, colors) {
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
// Precedence, weakest first: variant, danger scope, ring, then the fields.
|
|
42
|
-
function containerLayers(variant, f, colors) {
|
|
43
|
-
const paint = variantPaint(variant, colors);
|
|
47
|
+
function containerLayers(variant, f, colors, owned) {
|
|
48
|
+
const paint = paintOnBackdrop(variant, variantPaint(variant, colors), colors, owned);
|
|
44
49
|
let ink = paint.ink;
|
|
45
50
|
const layers = [paint.container];
|
|
46
51
|
if (variant === "danger") {
|
|
@@ -115,7 +120,13 @@ export function makeButton(rn) {
|
|
|
115
120
|
? fields || {}
|
|
116
121
|
: applyThemeComponentStyle(THEMED_AS, theme, { style: fields }).style || {};
|
|
117
122
|
const resolvedVariant = BUTTON_VARIANTS.includes(variant) ? variant : "primary";
|
|
118
|
-
const
|
|
123
|
+
const owned = ownedButtonPaint(
|
|
124
|
+
resolvedVariant,
|
|
125
|
+
f,
|
|
126
|
+
StyleSheet.flatten(style),
|
|
127
|
+
StyleSheet.flatten(labelStyle),
|
|
128
|
+
);
|
|
129
|
+
const { layers, ink } = containerLayers(resolvedVariant, f, colors, owned);
|
|
119
130
|
|
|
120
131
|
const fontSize =
|
|
121
132
|
typeof f.fontSize === "number" && Number.isFinite(f.fontSize)
|
package/dist/card-style.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Every resolver emits ONLY the fields that are set, so a widget's own base
|
|
6
6
|
// style keeps whatever the author left alone.
|
|
7
7
|
|
|
8
|
-
import { CONTRACT, normaliseComponentGradient } from "./contract.js";
|
|
8
|
+
import { CONTRACT, isHexColor, mixHex, normaliseComponentGradient } from "./contract.js";
|
|
9
9
|
import { normaliseCornerRadius, cornerRadiusStyle } from "./corner-radius.js";
|
|
10
10
|
import { normaliseSpacing, spacingStyle, isZeroSpacing } from "./spacing.js";
|
|
11
11
|
|
|
@@ -62,6 +62,50 @@ export function cardOverlay(style) {
|
|
|
62
62
|
return { ...cardSurfaceStyle(style), ...marginStyle(style || {}) };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The React Native layout properties a card may be given through `style` —
|
|
67
|
+
* how big the box is and where it sits, nothing about how it is painted.
|
|
68
|
+
*
|
|
69
|
+
* An ALLOWLIST, because a widget is told to hand `<Card>` its whole declared
|
|
70
|
+
* style bag: forwarding whatever is not a `card*` field would put design
|
|
71
|
+
* fields (`background`, `fontSize`, `titleColor`) on the box, and
|
|
72
|
+
* react-native-web renders an unknown key as real CSS where native drops it —
|
|
73
|
+
* two hosts, one widget, different cards (CLAUDE.md §8). Paint stays the
|
|
74
|
+
* card's own vocabulary (`cardBackground`, `cardRadius`, `padding`, `shadow`);
|
|
75
|
+
* anything else on the container goes through `containerStyle`.
|
|
76
|
+
*/
|
|
77
|
+
export const CARD_LAYOUT_STYLE_FIELDS = Object.freeze([
|
|
78
|
+
"width", "minWidth", "maxWidth",
|
|
79
|
+
"height", "minHeight", "maxHeight",
|
|
80
|
+
"aspectRatio",
|
|
81
|
+
"flex", "flexBasis", "flexGrow", "flexShrink", "alignSelf",
|
|
82
|
+
"flexDirection", "flexWrap", "justifyContent", "alignItems", "alignContent",
|
|
83
|
+
"gap", "rowGap", "columnGap",
|
|
84
|
+
"position", "top", "right", "bottom", "left", "start", "end", "zIndex",
|
|
85
|
+
"display", "overflow",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The layout a card's `style` declares. Returning it is what keeps
|
|
90
|
+
* `<Card style={{ width: "100%" }}>` from being silently dropped; a card
|
|
91
|
+
* declaring none yields null, so it emits no extra style object.
|
|
92
|
+
*
|
|
93
|
+
* An `undefined` value is skipped rather than copied: react-native-web's
|
|
94
|
+
* styleq ignores it while React Native's flattenStyle assigns it, so copying
|
|
95
|
+
* one would clear the theme's value on native alone.
|
|
96
|
+
*/
|
|
97
|
+
export function cardLayoutStyle(style) {
|
|
98
|
+
if (!style || typeof style !== "object" || Array.isArray(style)) return null;
|
|
99
|
+
const out = {};
|
|
100
|
+
let any = false;
|
|
101
|
+
for (const key of CARD_LAYOUT_STYLE_FIELDS) {
|
|
102
|
+
if (style[key] === undefined) continue;
|
|
103
|
+
out[key] = style[key];
|
|
104
|
+
any = true;
|
|
105
|
+
}
|
|
106
|
+
return any ? out : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
65
109
|
/**
|
|
66
110
|
* A gradient value as `<Gradient>` props, or null when unpaintable. A
|
|
67
111
|
* per-instance value runs through the same normaliser the theme uses, so the
|
|
@@ -74,6 +118,35 @@ export function gradientProps(value) {
|
|
|
74
118
|
: null;
|
|
75
119
|
}
|
|
76
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The colour a card's buttons are measured against: its opaque flat fill, or
|
|
123
|
+
* a gradient's midpoint, as a layout container's ink basis is read. `null`
|
|
124
|
+
* (a translucent or unknown fill) keeps the backdrop the card inherited.
|
|
125
|
+
*
|
|
126
|
+
* @param {object|Array} styles - the card's container style, as composed.
|
|
127
|
+
* @param {{colors: string[]}|null} gradient - its `gradientProps`.
|
|
128
|
+
*/
|
|
129
|
+
export function cardBackdrop(styles, gradient) {
|
|
130
|
+
if (gradient) {
|
|
131
|
+
const [from, to] = gradient.colors;
|
|
132
|
+
return isOpaqueHex(from) && isOpaqueHex(to) ? mixHex(from, to, 0.5) : null;
|
|
133
|
+
}
|
|
134
|
+
let fill = null;
|
|
135
|
+
for (const style of [styles].flat(Infinity)) {
|
|
136
|
+
if (style && typeof style === "object" && style.backgroundColor !== undefined) {
|
|
137
|
+
fill = style.backgroundColor;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return isOpaqueHex(fill) ? fill : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The colour maths ignores alpha, so a see-through fill cannot be judged.
|
|
144
|
+
function isOpaqueHex(value) {
|
|
145
|
+
if (!isHexColor(value)) return false;
|
|
146
|
+
const hex = value.slice(1);
|
|
147
|
+
return hex.length !== 8 || hex.slice(6).toLowerCase() === "ff";
|
|
148
|
+
}
|
|
149
|
+
|
|
77
150
|
/** The card-surface gradient (`cardGradient`). */
|
|
78
151
|
export function cardGradientProps(style) {
|
|
79
152
|
return gradientProps(style && style.cardGradient);
|
package/dist/card-view.js
CHANGED
|
@@ -6,10 +6,16 @@
|
|
|
6
6
|
// swap lives here once rather than in each widget.
|
|
7
7
|
|
|
8
8
|
import React from "react";
|
|
9
|
-
import { useHostTheme } from "./hooks.js";
|
|
9
|
+
import { useHostTheme, BackdropScope } from "./hooks.js";
|
|
10
10
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
11
11
|
import { CONTRACT } from "./contract.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
cardOverlay,
|
|
14
|
+
cardSurfaceStyle,
|
|
15
|
+
cardGradientProps,
|
|
16
|
+
cardLayoutStyle,
|
|
17
|
+
cardBackdrop,
|
|
18
|
+
} from "./card-style.js";
|
|
13
19
|
import { normaliseThemeComponents } from "./theme-components.js";
|
|
14
20
|
|
|
15
21
|
// A wrapped scroller stays transparent and fits inside the gradient.
|
|
@@ -75,9 +81,16 @@ export function makeCard(rn) {
|
|
|
75
81
|
|
|
76
82
|
/**
|
|
77
83
|
* @param {object} [props.style] — `card*` fields, `shadow`, `margin`
|
|
78
|
-
* (a widget's per-instance style, theme tokens already folded in).
|
|
84
|
+
* (a widget's per-instance style, theme tokens already folded in). On a
|
|
85
|
+
* card with no `baseStyle` it also carries the box's LAYOUT
|
|
86
|
+
* (`CARD_LAYOUT_STYLE_FIELDS`), so `style={{ width: "100%" }}` sizes the
|
|
87
|
+
* card rather than vanishing. Under `as` + a gradient the layout rides the
|
|
88
|
+
* gradient — the card's real box — so the content keys there
|
|
89
|
+
* (`flexDirection`, `gap`, …) arrange the wrapper, not the inner children.
|
|
79
90
|
* @param {object|Array} [props.baseStyle] — the caller's own base style. A
|
|
80
|
-
* card given none starts from the theme's surface and `card` scope.
|
|
91
|
+
* card given none starts from the theme's surface and `card` scope. A
|
|
92
|
+
* caller that supplies one owns the container's layout, so its `style` is
|
|
93
|
+
* read for `card*` fields only — that is the built-in widgets' contract.
|
|
81
94
|
* @param {React.ElementType} [props.as] — container; anything but `View`
|
|
82
95
|
* renders INSIDE the gradient, never as it. Other props land on it.
|
|
83
96
|
* @param {boolean} [props.fillInGradient] — the inner container stretches to
|
|
@@ -117,15 +130,22 @@ export function makeCard(rn) {
|
|
|
117
130
|
...explicit,
|
|
118
131
|
};
|
|
119
132
|
const base = standalone ? themedBase(theme) : baseStyle;
|
|
133
|
+
// Last, so an author's own declaration beats the theme's surface.
|
|
134
|
+
const layout = standalone ? cardLayoutStyle(style) : null;
|
|
120
135
|
|
|
121
136
|
const overlay = withoutMargin ? cardSurfaceStyle(fields) : cardOverlay(fields);
|
|
122
137
|
const gradient = cardGradientProps(fields);
|
|
138
|
+
const basis = cardBackdrop(
|
|
139
|
+
[base, overlay, typeof containerStyle === "function" ? null : containerStyle],
|
|
140
|
+
gradient,
|
|
141
|
+
);
|
|
142
|
+
const scoped = React.createElement(BackdropScope, { basis }, children);
|
|
123
143
|
|
|
124
144
|
if (!gradient) {
|
|
125
145
|
return React.createElement(
|
|
126
146
|
Container,
|
|
127
|
-
{ style: composeStyle([base, overlay], containerStyle), ...rest },
|
|
128
|
-
|
|
147
|
+
{ style: composeStyle([base, overlay, layout], containerStyle), ...rest },
|
|
148
|
+
scoped,
|
|
129
149
|
);
|
|
130
150
|
}
|
|
131
151
|
|
|
@@ -139,10 +159,10 @@ export function makeCard(rn) {
|
|
|
139
159
|
{
|
|
140
160
|
colors: gradient.colors,
|
|
141
161
|
angle: gradient.angle,
|
|
142
|
-
style: composeStyle([base, paint], containerStyle),
|
|
162
|
+
style: composeStyle([base, paint, layout], containerStyle),
|
|
143
163
|
...rest,
|
|
144
164
|
},
|
|
145
|
-
|
|
165
|
+
scoped,
|
|
146
166
|
);
|
|
147
167
|
}
|
|
148
168
|
|
|
@@ -153,7 +173,8 @@ export function makeCard(rn) {
|
|
|
153
173
|
{
|
|
154
174
|
colors: gradient.colors,
|
|
155
175
|
angle: gradient.angle,
|
|
156
|
-
|
|
176
|
+
// Before the clip: it keeps the gradient inside the corners, not a default.
|
|
177
|
+
style: [base, paint, layout, CLIP_TO_RADIUS],
|
|
157
178
|
},
|
|
158
179
|
React.createElement(
|
|
159
180
|
Container,
|
|
@@ -164,7 +185,7 @@ export function makeCard(rn) {
|
|
|
164
185
|
),
|
|
165
186
|
...rest,
|
|
166
187
|
},
|
|
167
|
-
|
|
188
|
+
scoped,
|
|
168
189
|
),
|
|
169
190
|
);
|
|
170
191
|
}
|
package/dist/contract.cjs
CHANGED
|
@@ -1395,9 +1395,43 @@ const HOOKS = [
|
|
|
1395
1395
|
"`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
|
|
1396
1396
|
"the signed-in user may do to THAT row, the answer update/delete " +
|
|
1397
1397
|
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1398
|
-
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`."
|
|
1398
|
+
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
|
|
1399
|
+
"`total` is how many rows the query matches in all (the envelope's " +
|
|
1400
|
+
"meta.total), not just the page in `data`; null until known.",
|
|
1399
1401
|
returnShape: {
|
|
1400
1402
|
data: "Record[]",
|
|
1403
|
+
total: "number | null",
|
|
1404
|
+
loading: "boolean",
|
|
1405
|
+
fetching: "boolean",
|
|
1406
|
+
error: "DatastoreError | null",
|
|
1407
|
+
refetch: "() => Promise<void>",
|
|
1408
|
+
},
|
|
1409
|
+
requiredContextSlice: ["datastore.records"],
|
|
1410
|
+
scopes: ["datastore.read:*"],
|
|
1411
|
+
},
|
|
1412
|
+
{
|
|
1413
|
+
name: "useDatastoreFigure",
|
|
1414
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1415
|
+
optionalArgs: ["aggregate", "column", "filter"],
|
|
1416
|
+
description:
|
|
1417
|
+
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1418
|
+
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
1419
|
+
"or \"average\" (of the NUMBER `column`). `filter` takes the same shapes " +
|
|
1420
|
+
"as useDatastoreQuery's; name a bound column through useBoundColumns and " +
|
|
1421
|
+
"pass null as the table until its `loading` is false, or the first read " +
|
|
1422
|
+
"runs unfiltered: useDatastoreFigure(columnsLoading ? null : tableId, " +
|
|
1423
|
+
"{ filter: [{ column: bound.statusField, operator: \"eq\", value: \"Pending\" }] }). " +
|
|
1424
|
+
"Render `<Stat value={figure.text} label=\"Pending\" />`: `text` is null " +
|
|
1425
|
+
"while loading or when there is no figure, so the tile prints its empty " +
|
|
1426
|
+
"value, never a fake 0. A count is exact, from the server's total. A sum " +
|
|
1427
|
+
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1428
|
+
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1429
|
+
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1430
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure.",
|
|
1431
|
+
returnShape: {
|
|
1432
|
+
value: "number | null",
|
|
1433
|
+
text: "string | null // the formatted figure for <Stat value>",
|
|
1434
|
+
truncated: "boolean",
|
|
1401
1435
|
loading: "boolean",
|
|
1402
1436
|
fetching: "boolean",
|
|
1403
1437
|
error: "DatastoreError | null",
|
|
@@ -2418,7 +2452,7 @@ const PRIMITIVES = [
|
|
|
2418
2452
|
{
|
|
2419
2453
|
name: "Stat",
|
|
2420
2454
|
description:
|
|
2421
|
-
'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure,
|
|
2455
|
+
'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure: compute a count, sum or average over a table with `useDatastoreFigure` and pass its `text`. An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
|
|
2422
2456
|
rnComponent: null,
|
|
2423
2457
|
docsUrl: null,
|
|
2424
2458
|
},
|
|
@@ -2439,14 +2473,14 @@ const PRIMITIVES = [
|
|
|
2439
2473
|
{
|
|
2440
2474
|
name: "Card",
|
|
2441
2475
|
description:
|
|
2442
|
-
'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
|
|
2476
|
+
'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A card that brings no `baseStyle` also takes the box\'s LAYOUT from `style` — width, height, the flex properties, `alignSelf`, `gap`, position — so `style={{ width: \'100%\', maxWidth: 480, alignSelf: \'center\' }}` sizes the card and still keeps the app\'s card look; put anything else about the container on `containerStyle`. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
|
|
2443
2477
|
rnComponent: null,
|
|
2444
2478
|
docsUrl: null,
|
|
2445
2479
|
},
|
|
2446
2480
|
{
|
|
2447
2481
|
name: "Button",
|
|
2448
2482
|
description:
|
|
2449
|
-
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
|
|
2483
|
+
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. On a section that paints its own fill (`theme.colors.backdrop` there), a variant colour that the section makes unreadable takes that section\'s text colour, as its text does: an outlined label and border, a secondary whose fill disappears becomes outlined, and a primary or danger fill turns inverse (that text colour as its fill, its own colour as its label). A white or lightly tinted panel changes nothing, and a button inside a `<Card>` is measured against that card. A colour you pass in `fields`, `style` or `labelStyle`, or that the app\'s button look sets, is never changed. Never hand-roll a button from `Pressable` + background + radius.',
|
|
2450
2484
|
rnComponent: null,
|
|
2451
2485
|
docsUrl: null,
|
|
2452
2486
|
},
|
|
@@ -2655,6 +2689,13 @@ const MANIFEST_SCHEMA = {
|
|
|
2655
2689
|
"Optional (default false). TRUE when the widget renders its OWN section header — a heading (and optional subtitle), plus any primary action for its section — making it a self-contained section. The AppStudio app-builder reads this when it places an already-installed widget: it lays the widget out as the WHOLE section and does NOT add a standalone heading or a duplicate action button above it, so the section is never double-titled. Set it to match what the component actually renders; leave it false (or omit) for a content-only widget whose heading the page supplies.",
|
|
2656
2690
|
default: false,
|
|
2657
2691
|
},
|
|
2692
|
+
drawsFigures: {
|
|
2693
|
+
type: "boolean",
|
|
2694
|
+
required: false,
|
|
2695
|
+
description:
|
|
2696
|
+
"Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
|
|
2697
|
+
default: false,
|
|
2698
|
+
},
|
|
2658
2699
|
events: {
|
|
2659
2700
|
type: "object[]",
|
|
2660
2701
|
required: true,
|
|
@@ -4716,7 +4757,17 @@ const CONTRACT = deepFreeze({
|
|
|
4716
4757
|
// without saving it, through the new `device.pdfViewer` host slice. And
|
|
4717
4758
|
// `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
|
|
4718
4759
|
// "print version" as a plain-text blob that never worked on native.
|
|
4719
|
-
|
|
4760
|
+
// 1.137.0: additive (sc-9142) — `manifest.description` is optional.
|
|
4761
|
+
// 1.138.0: additive (sc-9150) — `useDatastoreFigure`, and
|
|
4762
|
+
// `useDatastoreQuery` returns the envelope's `total`.
|
|
4763
|
+
// 1.139.0: additive (sc-9150) — new OPTIONAL manifest field `drawsFigures`
|
|
4764
|
+
// (boolean, default false): the widget draws live figures about its
|
|
4765
|
+
// records, so the app-builder counts it as the page's figures when it
|
|
4766
|
+
// reuses it. Existing manifests omit it and read false.
|
|
4767
|
+
// 1.141.0: additive (sc-9346) — inside a section that paints its own fill,
|
|
4768
|
+
// `theme.colors.backdrop` is that fill, and `<Button>` re-inks a variant
|
|
4769
|
+
// colour that cannot be read against it by the rule its text follows.
|
|
4770
|
+
version: "1.141.0",
|
|
4720
4771
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4721
4772
|
hooks: HOOKS,
|
|
4722
4773
|
primitives: PRIMITIVES,
|
|
@@ -4989,6 +5040,25 @@ function deriveSurfaceTokens(backgroundColor) {
|
|
|
4989
5040
|
};
|
|
4990
5041
|
}
|
|
4991
5042
|
|
|
5043
|
+
// sc-8370: WCAG 1.4.3 for text, 1.4.11 for a control's boundary.
|
|
5044
|
+
const INK_CONTRAST = Object.freeze({ text: 4.5, boundary: 3 });
|
|
5045
|
+
|
|
5046
|
+
/**
|
|
5047
|
+
* The measured re-ink rule every painted section applies — to its text tiers
|
|
5048
|
+
* and to its buttons alike. `current` is kept while it reaches `minimum`
|
|
5049
|
+
* against `backdrop`; below that, `candidate` replaces it only when it is
|
|
5050
|
+
* genuinely more readable there. A backdrop with no good answer keeps what it
|
|
5051
|
+
* had rather than committing to a colour that is wrong for half of it.
|
|
5052
|
+
*/
|
|
5053
|
+
function inkForBackdrop(current, candidate, backdrop, minimum) {
|
|
5054
|
+
if (!isHexColor(current) || !isHexColor(candidate) || !isHexColor(backdrop)) {
|
|
5055
|
+
return current;
|
|
5056
|
+
}
|
|
5057
|
+
const held = contrastRatio(current, backdrop);
|
|
5058
|
+
if (held >= minimum) return current;
|
|
5059
|
+
return contrastRatio(candidate, backdrop) > held ? candidate : current;
|
|
5060
|
+
}
|
|
5061
|
+
|
|
4992
5062
|
/**
|
|
4993
5063
|
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
4994
5064
|
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
@@ -5071,6 +5141,8 @@ module.exports = {
|
|
|
5071
5141
|
readableTextColor,
|
|
5072
5142
|
deriveAccentTints,
|
|
5073
5143
|
deriveSurfaceTokens,
|
|
5144
|
+
inkForBackdrop,
|
|
5145
|
+
INK_CONTRAST,
|
|
5074
5146
|
gradientAngleToVector,
|
|
5075
5147
|
normaliseComponentGradient,
|
|
5076
5148
|
isThemeColorTokenRef,
|
package/dist/contract.js
CHANGED
|
@@ -1395,9 +1395,43 @@ const HOOKS = [
|
|
|
1395
1395
|
"`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
|
|
1396
1396
|
"the signed-in user may do to THAT row, the answer update/delete " +
|
|
1397
1397
|
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1398
|
-
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`."
|
|
1398
|
+
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
|
|
1399
|
+
"`total` is how many rows the query matches in all (the envelope's " +
|
|
1400
|
+
"meta.total), not just the page in `data`; null until known.",
|
|
1399
1401
|
returnShape: {
|
|
1400
1402
|
data: "Record[]",
|
|
1403
|
+
total: "number | null",
|
|
1404
|
+
loading: "boolean",
|
|
1405
|
+
fetching: "boolean",
|
|
1406
|
+
error: "DatastoreError | null",
|
|
1407
|
+
refetch: "() => Promise<void>",
|
|
1408
|
+
},
|
|
1409
|
+
requiredContextSlice: ["datastore.records"],
|
|
1410
|
+
scopes: ["datastore.read:*"],
|
|
1411
|
+
},
|
|
1412
|
+
{
|
|
1413
|
+
name: "useDatastoreFigure",
|
|
1414
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1415
|
+
optionalArgs: ["aggregate", "column", "filter"],
|
|
1416
|
+
description:
|
|
1417
|
+
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1418
|
+
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
1419
|
+
"or \"average\" (of the NUMBER `column`). `filter` takes the same shapes " +
|
|
1420
|
+
"as useDatastoreQuery's; name a bound column through useBoundColumns and " +
|
|
1421
|
+
"pass null as the table until its `loading` is false, or the first read " +
|
|
1422
|
+
"runs unfiltered: useDatastoreFigure(columnsLoading ? null : tableId, " +
|
|
1423
|
+
"{ filter: [{ column: bound.statusField, operator: \"eq\", value: \"Pending\" }] }). " +
|
|
1424
|
+
"Render `<Stat value={figure.text} label=\"Pending\" />`: `text` is null " +
|
|
1425
|
+
"while loading or when there is no figure, so the tile prints its empty " +
|
|
1426
|
+
"value, never a fake 0. A count is exact, from the server's total. A sum " +
|
|
1427
|
+
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1428
|
+
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1429
|
+
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1430
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure.",
|
|
1431
|
+
returnShape: {
|
|
1432
|
+
value: "number | null",
|
|
1433
|
+
text: "string | null // the formatted figure for <Stat value>",
|
|
1434
|
+
truncated: "boolean",
|
|
1401
1435
|
loading: "boolean",
|
|
1402
1436
|
fetching: "boolean",
|
|
1403
1437
|
error: "DatastoreError | null",
|
|
@@ -2418,7 +2452,7 @@ const PRIMITIVES = [
|
|
|
2418
2452
|
{
|
|
2419
2453
|
name: "Stat",
|
|
2420
2454
|
description:
|
|
2421
|
-
'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure,
|
|
2455
|
+
'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure: compute a count, sum or average over a table with `useDatastoreFigure` and pass its `text`. An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
|
|
2422
2456
|
rnComponent: null,
|
|
2423
2457
|
docsUrl: null,
|
|
2424
2458
|
},
|
|
@@ -2439,14 +2473,14 @@ const PRIMITIVES = [
|
|
|
2439
2473
|
{
|
|
2440
2474
|
name: "Card",
|
|
2441
2475
|
description:
|
|
2442
|
-
'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
|
|
2476
|
+
'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A card that brings no `baseStyle` also takes the box\'s LAYOUT from `style` — width, height, the flex properties, `alignSelf`, `gap`, position — so `style={{ width: \'100%\', maxWidth: 480, alignSelf: \'center\' }}` sizes the card and still keeps the app\'s card look; put anything else about the container on `containerStyle`. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
|
|
2443
2477
|
rnComponent: null,
|
|
2444
2478
|
docsUrl: null,
|
|
2445
2479
|
},
|
|
2446
2480
|
{
|
|
2447
2481
|
name: "Button",
|
|
2448
2482
|
description:
|
|
2449
|
-
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
|
|
2483
|
+
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. On a section that paints its own fill (`theme.colors.backdrop` there), a variant colour that the section makes unreadable takes that section\'s text colour, as its text does: an outlined label and border, a secondary whose fill disappears becomes outlined, and a primary or danger fill turns inverse (that text colour as its fill, its own colour as its label). A white or lightly tinted panel changes nothing, and a button inside a `<Card>` is measured against that card. A colour you pass in `fields`, `style` or `labelStyle`, or that the app\'s button look sets, is never changed. Never hand-roll a button from `Pressable` + background + radius.',
|
|
2450
2484
|
rnComponent: null,
|
|
2451
2485
|
docsUrl: null,
|
|
2452
2486
|
},
|
|
@@ -2651,6 +2685,13 @@ const MANIFEST_SCHEMA = {
|
|
|
2651
2685
|
"Optional (default false). TRUE when the widget renders its OWN section header — a heading (and optional subtitle), plus any primary action for its section — making it a self-contained section. The AppStudio app-builder reads this when it places an already-installed widget: it lays the widget out as the WHOLE section and does NOT add a standalone heading or a duplicate action button above it, so the section is never double-titled. Set it to match what the component actually renders; leave it false (or omit) for a content-only widget whose heading the page supplies.",
|
|
2652
2686
|
default: false,
|
|
2653
2687
|
},
|
|
2688
|
+
drawsFigures: {
|
|
2689
|
+
type: "boolean",
|
|
2690
|
+
required: false,
|
|
2691
|
+
description:
|
|
2692
|
+
"Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
|
|
2693
|
+
default: false,
|
|
2694
|
+
},
|
|
2654
2695
|
events: {
|
|
2655
2696
|
type: "object[]",
|
|
2656
2697
|
required: true,
|
|
@@ -4712,7 +4753,17 @@ const CONTRACT = deepFreeze({
|
|
|
4712
4753
|
// without saving it, through the new `device.pdfViewer` host slice. And
|
|
4713
4754
|
// `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
|
|
4714
4755
|
// "print version" as a plain-text blob that never worked on native.
|
|
4715
|
-
|
|
4756
|
+
// 1.137.0: additive (sc-9142) — `manifest.description` is optional.
|
|
4757
|
+
// 1.138.0: additive (sc-9150) — `useDatastoreFigure`, and
|
|
4758
|
+
// `useDatastoreQuery` returns the envelope's `total`.
|
|
4759
|
+
// 1.139.0: additive (sc-9150) — new OPTIONAL manifest field `drawsFigures`
|
|
4760
|
+
// (boolean, default false): the widget draws live figures about its
|
|
4761
|
+
// records, so the app-builder counts it as the page's figures when it
|
|
4762
|
+
// reuses it. Existing manifests omit it and read false.
|
|
4763
|
+
// 1.141.0: additive (sc-9346) — inside a section that paints its own fill,
|
|
4764
|
+
// `theme.colors.backdrop` is that fill, and `<Button>` re-inks a variant
|
|
4765
|
+
// colour that cannot be read against it by the rule its text follows.
|
|
4766
|
+
version: "1.141.0",
|
|
4716
4767
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4717
4768
|
hooks: HOOKS,
|
|
4718
4769
|
primitives: PRIMITIVES,
|
|
@@ -4985,6 +5036,25 @@ function deriveSurfaceTokens(backgroundColor) {
|
|
|
4985
5036
|
};
|
|
4986
5037
|
}
|
|
4987
5038
|
|
|
5039
|
+
// sc-8370: WCAG 1.4.3 for text, 1.4.11 for a control's boundary.
|
|
5040
|
+
const INK_CONTRAST = Object.freeze({ text: 4.5, boundary: 3 });
|
|
5041
|
+
|
|
5042
|
+
/**
|
|
5043
|
+
* The measured re-ink rule every painted section applies — to its text tiers
|
|
5044
|
+
* and to its buttons alike. `current` is kept while it reaches `minimum`
|
|
5045
|
+
* against `backdrop`; below that, `candidate` replaces it only when it is
|
|
5046
|
+
* genuinely more readable there. A backdrop with no good answer keeps what it
|
|
5047
|
+
* had rather than committing to a colour that is wrong for half of it.
|
|
5048
|
+
*/
|
|
5049
|
+
function inkForBackdrop(current, candidate, backdrop, minimum) {
|
|
5050
|
+
if (!isHexColor(current) || !isHexColor(candidate) || !isHexColor(backdrop)) {
|
|
5051
|
+
return current;
|
|
5052
|
+
}
|
|
5053
|
+
const held = contrastRatio(current, backdrop);
|
|
5054
|
+
if (held >= minimum) return current;
|
|
5055
|
+
return contrastRatio(candidate, backdrop) > held ? candidate : current;
|
|
5056
|
+
}
|
|
5057
|
+
|
|
4988
5058
|
/**
|
|
4989
5059
|
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
4990
5060
|
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
@@ -5067,6 +5137,8 @@ export {
|
|
|
5067
5137
|
readableTextColor,
|
|
5068
5138
|
deriveAccentTints,
|
|
5069
5139
|
deriveSurfaceTokens,
|
|
5140
|
+
inkForBackdrop,
|
|
5141
|
+
INK_CONTRAST,
|
|
5070
5142
|
gradientAngleToVector,
|
|
5071
5143
|
normaliseComponentGradient,
|
|
5072
5144
|
isThemeColorTokenRef,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The hook half of ./figure.js: one live figure over a table, shared by the
|
|
2
|
+
// stock Stat widget and custom widgets on both hosts.
|
|
3
|
+
import { useDatastoreQuery } from "./hooks.js";
|
|
4
|
+
import { figureQuery, reduceFigure } from "./figure.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* sc-9150 — one live count, sum or average over a table; see the
|
|
8
|
+
* useDatastoreFigure CONTRACT entry. Built on useDatastoreQuery, so a changed
|
|
9
|
+
* `filter` keeps the previous figure on screen while `fetching`.
|
|
10
|
+
*/
|
|
11
|
+
export function useDatastoreFigure(table, options) {
|
|
12
|
+
const { aggregate, column, filter } = options || {};
|
|
13
|
+
const rows = useDatastoreQuery(table || null, figureQuery(filter, aggregate));
|
|
14
|
+
const figure =
|
|
15
|
+
table && !rows.loading && !rows.error
|
|
16
|
+
? reduceFigure(rows.data, { aggregate, column }, rows.total)
|
|
17
|
+
: null;
|
|
18
|
+
return {
|
|
19
|
+
value: figure ? figure.value : null,
|
|
20
|
+
text: figure ? figure.text : null,
|
|
21
|
+
truncated: figure ? figure.truncated : false,
|
|
22
|
+
loading: rows.loading,
|
|
23
|
+
fetching: rows.fetching,
|
|
24
|
+
error: rows.error,
|
|
25
|
+
refetch: rows.refetch,
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/figure.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The pure rules behind `useDatastoreFigure`: which rows a figure reads and how
|
|
2
|
+
// they reduce to the one number a `<Stat>` shows. Plain JS, no React, so the
|
|
3
|
+
// stock Stat and every custom widget count the same way on both hosts.
|
|
4
|
+
|
|
5
|
+
export const FIGURE_AGGREGATES = Object.freeze(["count", "sum", "average"]);
|
|
6
|
+
|
|
7
|
+
// REQ-QUERY-02 clamps every datastore query to 1000 rows, so a sum or average
|
|
8
|
+
// over a bigger table is reduced from a truncated page and must say so.
|
|
9
|
+
export const FIGURE_ROW_CEILING = 1000;
|
|
10
|
+
|
|
11
|
+
/** The aggregate to run: an unknown or missing one counts rows. */
|
|
12
|
+
export function figureAggregate(aggregate) {
|
|
13
|
+
return FIGURE_AGGREGATES.includes(aggregate) ? aggregate : "count";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasFilter(filter) {
|
|
17
|
+
return Array.isArray(filter)
|
|
18
|
+
? filter.length > 0
|
|
19
|
+
: Boolean(filter) && typeof filter === "object" && Object.keys(filter).length > 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The rows query a figure reads. A count needs only the envelope's exact
|
|
24
|
+
* `meta.total`, so it fetches one row; a sum or average reads up to the ceiling.
|
|
25
|
+
*/
|
|
26
|
+
export function figureQuery(filter, aggregate) {
|
|
27
|
+
const limit = figureAggregate(aggregate) === "count" ? 1 : FIGURE_ROW_CEILING;
|
|
28
|
+
return hasFilter(filter) ? { limit, filter } : { limit };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Whole numbers stay whole; an average keeps two decimals at most.
|
|
32
|
+
function formatNumber(n) {
|
|
33
|
+
return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reduce a figure query's result to `{ value, text, truncated }`, or null when
|
|
38
|
+
* there is nothing honest to show. `total` is how many rows the query matched;
|
|
39
|
+
* a sum or average over fewer rows than that is prefixed "≈ ".
|
|
40
|
+
*/
|
|
41
|
+
export function reduceFigure(rows, { aggregate, column } = {}, total = null) {
|
|
42
|
+
if (!Array.isArray(rows)) return null;
|
|
43
|
+
const known = Number.isFinite(total);
|
|
44
|
+
if (figureAggregate(aggregate) === "count") {
|
|
45
|
+
return known ? { value: total, text: String(total), truncated: false } : null;
|
|
46
|
+
}
|
|
47
|
+
if (!column) return null;
|
|
48
|
+
// Number(null) and Number("") are 0: coercing would count a blank or
|
|
49
|
+
// permission-hidden cell as a real zero. A NUMBER cell arrives as a number.
|
|
50
|
+
const numbers = rows
|
|
51
|
+
.map((row) => (row ? row[column] : undefined))
|
|
52
|
+
.filter((cell) => typeof cell === "number" && Number.isFinite(cell));
|
|
53
|
+
if (numbers.length === 0) return null;
|
|
54
|
+
const sum = numbers.reduce((acc, n) => acc + n, 0);
|
|
55
|
+
const value = aggregate === "average" ? sum / numbers.length : sum;
|
|
56
|
+
if (!Number.isFinite(value)) return null;
|
|
57
|
+
const truncated = known ? total > rows.length : rows.length >= FIGURE_ROW_CEILING;
|
|
58
|
+
return { value, text: `${truncated ? "≈ " : ""}${formatNumber(value)}`, truncated };
|
|
59
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
// sc-5717 — "is this key still at its declared default", shared with the
|
|
47
47
|
// host codec so the hook and the encoder can never disagree about it.
|
|
48
48
|
sameWidgetRouteValue,
|
|
49
|
+
deriveSurfaceTokens,
|
|
49
50
|
} from "./contract.js";
|
|
50
51
|
|
|
51
52
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
@@ -67,6 +68,27 @@ export function WidgetContextProvider({ value, children }) {
|
|
|
67
68
|
return React.createElement(HostWidgetContext.Provider, { value }, children);
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* @internal — a surface painting its own fill inside a coloured section
|
|
73
|
+
* becomes the surface its content reads and its buttons are measured against
|
|
74
|
+
* (`<Card>` uses it), by the derivation a layout container applies.
|
|
75
|
+
* Outside such a section there is no backdrop and nothing changes.
|
|
76
|
+
*/
|
|
77
|
+
export function BackdropScope({ basis, children }) {
|
|
78
|
+
const ctx = useContext(HostWidgetContext);
|
|
79
|
+
const theme = ctx && ctx.workspace ? ctx.workspace.theme : null;
|
|
80
|
+
const colors = theme && theme.colors;
|
|
81
|
+
const scoped = Boolean(basis && colors && colors.backdrop && colors.backdrop !== basis);
|
|
82
|
+
const value = useMemo(
|
|
83
|
+
() =>
|
|
84
|
+
scoped
|
|
85
|
+
? { ...ctx, workspace: { ...ctx.workspace, theme: { ...theme, colors: { ...colors, ...deriveSurfaceTokens(basis), backdrop: basis } } } }
|
|
86
|
+
: ctx,
|
|
87
|
+
[scoped, ctx, theme, colors, basis],
|
|
88
|
+
);
|
|
89
|
+
return React.createElement(HostWidgetContext.Provider, { value }, children);
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
// Exported for ./toast.js + future hook modules outside hooks.js. Internal
|
|
71
93
|
// utility — widget code should not import this directly.
|
|
72
94
|
export function useWidgetContextOrThrow(hookName) {
|
|
@@ -2173,7 +2195,7 @@ function toDatastoreError(err) {
|
|
|
2173
2195
|
// on a genuine loop.
|
|
2174
2196
|
const VOLATILE_QUERY_RENDER_LIMIT = 10;
|
|
2175
2197
|
|
|
2176
|
-
const NO_QUERY_RESULT = { key: null, table: null, data: null, error: null, loaded: false };
|
|
2198
|
+
const NO_QUERY_RESULT = { key: null, table: null, data: null, total: null, error: null, loaded: false };
|
|
2177
2199
|
const NO_RECORD_RESULT = { key: null, data: null, error: null, loaded: false };
|
|
2178
2200
|
|
|
2179
2201
|
function serializeQuery(query) {
|
|
@@ -2189,8 +2211,8 @@ const recordResultKey = (table, recordId, includeCapabilities = false) =>
|
|
|
2189
2211
|
JSON.stringify([table, recordId, includeCapabilities]);
|
|
2190
2212
|
|
|
2191
2213
|
/**
|
|
2192
|
-
* Stateful datastore query hook. Returns { data, loading, fetching,
|
|
2193
|
-
* refetch }.
|
|
2214
|
+
* Stateful datastore query hook. Returns { data, total, loading, fetching,
|
|
2215
|
+
* error, refetch }.
|
|
2194
2216
|
*
|
|
2195
2217
|
* The host injects a `@colixsystems/datastore-client` instance at
|
|
2196
2218
|
* `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
|
|
@@ -2207,6 +2229,9 @@ const recordResultKey = (table, recordId, includeCapabilities = false) =>
|
|
|
2207
2229
|
* `_capabilities` ({ can_read, can_write, can_delete, can_grant }), computed
|
|
2208
2230
|
* for the whole page in one server lookup. Gate a row's Edit / Delete on it.
|
|
2209
2231
|
*
|
|
2232
|
+
* sc-9150 — `total` is the envelope's `meta.total`: how many rows the query
|
|
2233
|
+
* matches, beyond the page in `data`. null until known.
|
|
2234
|
+
*
|
|
2210
2235
|
* sc-8595 — stale-while-revalidate. `loading` is true only until the first
|
|
2211
2236
|
* result for the bound TABLE lands. A changed query on that table, `refetch()`
|
|
2212
2237
|
* and a page pull-to-refresh keep the rows already in `data` on screen with
|
|
@@ -2274,9 +2299,10 @@ export function useDatastoreQuery(table, query) {
|
|
|
2274
2299
|
// The datastore-client returns the { data, meta } list envelope
|
|
2275
2300
|
// verbatim — unwrap to the rows array (default []).
|
|
2276
2301
|
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
2302
|
+
const total = res && res.meta && Number.isFinite(res.meta.total) ? res.meta.total : null;
|
|
2277
2303
|
// Discard the result if a newer fetch has started since we kicked off.
|
|
2278
2304
|
if (runRef.current !== myRun) return;
|
|
2279
|
-
setSettled({ key, table: t, data: rows, error: null, loaded: true });
|
|
2305
|
+
setSettled({ key, table: t, data: rows, total, error: null, loaded: true });
|
|
2280
2306
|
setInFlight(false);
|
|
2281
2307
|
} catch (err) {
|
|
2282
2308
|
if (runRef.current !== myRun) return;
|
|
@@ -2285,7 +2311,7 @@ export function useDatastoreQuery(table, query) {
|
|
|
2285
2311
|
setSettled((prev) =>
|
|
2286
2312
|
prev.table === t && prev.loaded
|
|
2287
2313
|
? { ...prev, key, error }
|
|
2288
|
-
: { key, table: t, data: emptyRows, error, loaded: false },
|
|
2314
|
+
: { key, table: t, data: emptyRows, total: null, error, loaded: false },
|
|
2289
2315
|
);
|
|
2290
2316
|
setInFlight(false);
|
|
2291
2317
|
}
|
|
@@ -2340,6 +2366,7 @@ export function useDatastoreQuery(table, query) {
|
|
|
2340
2366
|
const hasRows = Boolean(table) && settled.table === table && settled.loaded;
|
|
2341
2367
|
return {
|
|
2342
2368
|
data: hasRows ? settled.data : emptyRows,
|
|
2369
|
+
total: hasRows ? settled.total : null,
|
|
2343
2370
|
loading: !hasRows && busy,
|
|
2344
2371
|
fetching: busy,
|
|
2345
2372
|
error: table && !busy && settled.table === table ? settled.error : null,
|
package/dist/host.d.ts
CHANGED
|
@@ -39,6 +39,21 @@ export type ThemeWidgetStyles = Record<string, ThemeComponentStyle>;
|
|
|
39
39
|
*/
|
|
40
40
|
export function normaliseThemeComponents(raw: unknown): ThemeComponents;
|
|
41
41
|
|
|
42
|
+
/** Minimum contrast: `text` for labels (WCAG 1.4.3), `boundary` for edges (1.4.11). */
|
|
43
|
+
export const INK_CONTRAST: Readonly<{ text: number; boundary: number }>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The measured re-ink rule a painted section applies to its text and buttons:
|
|
47
|
+
* `current` while it reaches `minimum` against `backdrop`, else `candidate`
|
|
48
|
+
* when that reads better there, else `current`.
|
|
49
|
+
*/
|
|
50
|
+
export function inkForBackdrop(
|
|
51
|
+
current: string,
|
|
52
|
+
candidate: string,
|
|
53
|
+
backdrop: string,
|
|
54
|
+
minimum: number,
|
|
55
|
+
): string;
|
|
56
|
+
|
|
42
57
|
/**
|
|
43
58
|
* REQ-THEME-ELEMENT host helper: validates a raw `themeConfig.widgetStyles` blob.
|
|
44
59
|
* Structural only — the key space is the workspace's widget catalog, not the
|
package/dist/host.js
CHANGED
|
@@ -41,7 +41,7 @@ export {
|
|
|
41
41
|
// surface — the page background and every container that carries its own fill.
|
|
42
42
|
// Host plumbing, deliberately off the author entry point: a widget reads the
|
|
43
43
|
// already-correct `useTheme().colors`, it never derives them itself.
|
|
44
|
-
export { deriveSurfaceTokens } from "./contract.js";
|
|
44
|
+
export { deriveSurfaceTokens, inkForBackdrop, INK_CONTRAST } from "./contract.js";
|
|
45
45
|
|
|
46
46
|
// REQ-WSDK-PLATFORM §6: the host half of `useToast()` — the queue, the
|
|
47
47
|
// auto-dismiss timing, and the themed values a toast is painted with. The
|
package/dist/index.d.ts
CHANGED
|
@@ -393,6 +393,11 @@ export interface ThemeTokens {
|
|
|
393
393
|
onSurface: string;
|
|
394
394
|
danger: string;
|
|
395
395
|
[k: string]: string;
|
|
396
|
+
} & {
|
|
397
|
+
/** Inside a section painting its own fill: that fill. Absent on the page. */
|
|
398
|
+
backdrop?: string;
|
|
399
|
+
/** The surface the look was designed for, which `backdrop` is judged against. */
|
|
400
|
+
backdropBase?: string;
|
|
396
401
|
};
|
|
397
402
|
/** sc-3696: the shared elevation vocabulary. Spread a level into a style. */
|
|
398
403
|
elevation: {
|
|
@@ -843,6 +848,8 @@ export interface RecordCapabilities {
|
|
|
843
848
|
|
|
844
849
|
export interface QueryResult<T> {
|
|
845
850
|
data: T[];
|
|
851
|
+
/** How many rows the query matches in all, not just this page; null until known. */
|
|
852
|
+
total: number | null;
|
|
846
853
|
/**
|
|
847
854
|
* True only until the first result for the bound table lands. A changed
|
|
848
855
|
* query on the same table, `refetch()` and pull-to-refresh keep the rows
|
|
@@ -866,6 +873,34 @@ export function useDatastoreQuery<T = unknown>(
|
|
|
866
873
|
query?: Query,
|
|
867
874
|
): QueryResult<T>;
|
|
868
875
|
|
|
876
|
+
export interface FigureOptions {
|
|
877
|
+
/** Default `"count"`. `"sum"` and `"average"` read the number `column`. */
|
|
878
|
+
aggregate?: "count" | "sum" | "average";
|
|
879
|
+
column?: string;
|
|
880
|
+
/** The rows the figure covers, in any shape `Query.filter` accepts. */
|
|
881
|
+
filter?:
|
|
882
|
+
| Query["filter"]
|
|
883
|
+
| Array<{ column: string; operator: string; value?: unknown; valueMode?: string }>;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export interface FigureResult {
|
|
887
|
+
value: number | null;
|
|
888
|
+
/** The formatted figure for `<Stat value>`; null while loading or when there is none. */
|
|
889
|
+
text: string | null;
|
|
890
|
+
/** A sum or average read fewer rows than the table holds, so `text` reads "≈ n". */
|
|
891
|
+
truncated: boolean;
|
|
892
|
+
loading: boolean;
|
|
893
|
+
fetching: boolean;
|
|
894
|
+
error: Error | null;
|
|
895
|
+
refetch(): Promise<void>;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** One live count, sum or average over a table, as the stock Stat computes it. */
|
|
899
|
+
export function useDatastoreFigure(
|
|
900
|
+
table: string | null | undefined,
|
|
901
|
+
options?: FigureOptions,
|
|
902
|
+
): FigureResult;
|
|
903
|
+
|
|
869
904
|
export function useDatastoreMutation<T = unknown>(
|
|
870
905
|
table: string,
|
|
871
906
|
): MutationApi<T>;
|
|
@@ -2775,7 +2810,14 @@ export const Card: (props: {
|
|
|
2775
2810
|
padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
|
|
2776
2811
|
shadow?: "none" | "sm" | "md" | "lg";
|
|
2777
2812
|
gradient?: { from: string; to: string; angle?: number };
|
|
2778
|
-
/**
|
|
2813
|
+
/**
|
|
2814
|
+
* `card*` style fields, `shadow`, `margin` — a widget's per-instance style.
|
|
2815
|
+
* With no `baseStyle` it also carries the box's layout (width, height, the
|
|
2816
|
+
* flex properties, `alignSelf`, `gap`, position), so `{ width: "100%" }`
|
|
2817
|
+
* sizes the card; anything else about the container goes on
|
|
2818
|
+
* `containerStyle`. Read for the `card*` fields alone when `baseStyle` is
|
|
2819
|
+
* given — the contract the built-in widgets pass their whole style bag on.
|
|
2820
|
+
*/
|
|
2779
2821
|
style?: Record<string, any>;
|
|
2780
2822
|
/** The caller's own base style; a card given none starts from the theme. */
|
|
2781
2823
|
baseStyle?: any;
|
package/dist/index.js
CHANGED
|
@@ -104,6 +104,7 @@ export {
|
|
|
104
104
|
// the web variant and index.native.js picks the native variant.
|
|
105
105
|
export { useClipboard, ClipboardError } from "./clipboard.js";
|
|
106
106
|
export { useToast } from "./toast.js";
|
|
107
|
+
export { useDatastoreFigure } from "./datastore-figure.js";
|
|
107
108
|
// sc-6607 — `<Overlay>` is one shared component (./overlay-view.js) bound to
|
|
108
109
|
// each platform's primitives, so a widget's modal escapes its container box
|
|
109
110
|
// identically on the Player and the Expo export.
|
package/dist/index.native.js
CHANGED
|
@@ -102,6 +102,7 @@ export {
|
|
|
102
102
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
|
|
103
103
|
export { useClipboard, ClipboardError } from "./clipboard.native.js";
|
|
104
104
|
export { useToast } from "./toast.native.js";
|
|
105
|
+
export { useDatastoreFigure } from "./datastore-figure.js";
|
|
105
106
|
// sc-6607 — see the note on the web mirror in ./index.js.
|
|
106
107
|
export { Overlay } from "./overlay.native.js";
|
|
107
108
|
// sc-8104 — see the note on the web mirror in ./index.js.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.180.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|