@colixsystems/widget-sdk 0.200.0 → 0.202.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 +70 -1
- package/dist/accordion-tokens.js +54 -0
- package/dist/accordion-view.js +210 -0
- package/dist/accordion.js +7 -0
- package/dist/accordion.native.js +7 -0
- package/dist/contract.cjs +39 -3
- package/dist/contract.js +39 -3
- package/dist/hooks.js +7 -4
- package/dist/index.d.ts +70 -5
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +49 -0
- package/dist/linter.js +49 -0
- package/dist/manifest.cjs +6 -0
- package/dist/manifest.js +6 -0
- package/package.json +1 -1
- package/src/contract.js +39 -3
package/README.md
CHANGED
|
@@ -83,7 +83,75 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
83
83
|
|
|
84
84
|
## Status
|
|
85
85
|
|
|
86
|
-
`v0.
|
|
86
|
+
`v0.202.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**.
|
|
87
|
+
|
|
88
|
+
### What's new in 0.202.0 (contract 1.161.0)
|
|
89
|
+
|
|
90
|
+
**Themed collapsible sections: `Accordion` and `AccordionItem`.** Widgets each built their own expand/collapse section, with their own header, chevron and spacing, and none told a screen reader whether the section was open. The SDK now draws one, from the app's existing look: the surface from **Theme Settings > Cards** (background, border, corner radius) and the headers and dividers from **Theme Settings > Lists** (row padding, row height, divider). No new theme scope.
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
<Accordion defaultValue="shipping">
|
|
94
|
+
<AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">
|
|
95
|
+
<Text>…</Text>
|
|
96
|
+
</AccordionItem>
|
|
97
|
+
<AccordionItem value="returns" title="Returns" trailing={<Badge label="New" tone="info" />}>
|
|
98
|
+
<Text>…</Text>
|
|
99
|
+
</AccordionItem>
|
|
100
|
+
</Accordion>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
By default one section is open at a time and pressing the open one closes it; `multiple` lets several stay open. Pass `value` + `onChange` to control it (a key, or an array of keys when `multiple`), or `defaultValue` to leave it uncontrolled. Each header is a 44px button with a chevron that works from the keyboard and reports `aria-expanded`. `trailing` sits outside the header's toggle, so a button there runs its own action. A section's body mounts only while it is open, so fetch its data inside it. One view renders on both the web Player and the exported app. Additive.
|
|
104
|
+
|
|
105
|
+
### What's new in 0.201.0 (contract 1.160.0)
|
|
106
|
+
|
|
107
|
+
**A page badge can stay live while its page is closed — `manifest.badgeCounter` + `useBadgeCount(props)`.** `usePageBadge` only runs while your widget is on screen, so a chat's unread count used to reach the menu only after someone opened the chat's page. Declare `badgeCounter: true` and export a counting hook from every platform entry, and the app runs it in the background for each placement on a page its menu links to (installed marketplace widgets, your workspace's own included):
|
|
108
|
+
|
|
109
|
+
```jsx
|
|
110
|
+
import { useMemo } from "react";
|
|
111
|
+
import {
|
|
112
|
+
useDatastoreQuery,
|
|
113
|
+
useDatastoreSubscription,
|
|
114
|
+
usePageBadge,
|
|
115
|
+
useStableQuery,
|
|
116
|
+
useUser,
|
|
117
|
+
} from "@colixsystems/widget-sdk";
|
|
118
|
+
|
|
119
|
+
function useOpenTasks({ tasksTableId }) {
|
|
120
|
+
const me = useUser().id;
|
|
121
|
+
const table = me ? tasksTableId : null;
|
|
122
|
+
const { data, loading, refetch } = useDatastoreQuery(
|
|
123
|
+
table,
|
|
124
|
+
useStableQuery(() => ({ filter: { Assignee: "eq:me", Done: "eq:false" }, limit: 500 })),
|
|
125
|
+
);
|
|
126
|
+
// Stay live while the page is closed: re-read whenever a task changes.
|
|
127
|
+
const handlers = useMemo(
|
|
128
|
+
() => ({ onCreated: refetch, onUpdated: refetch, onDeleted: refetch }),
|
|
129
|
+
[refetch],
|
|
130
|
+
);
|
|
131
|
+
useDatastoreSubscription(table, handlers);
|
|
132
|
+
return loading ? null : data.length;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The host runs this while the page is closed.
|
|
136
|
+
export function useBadgeCount(props) {
|
|
137
|
+
return useOpenTasks(props);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default function TaskList(props) {
|
|
141
|
+
usePageBadge(useOpenTasks(props));
|
|
142
|
+
// ...
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- **The hook** is called with the placement's resolved props, inside the widget's own context. Return a whole number, or `null` while the count is not known yet. It must only read: no rendering, no data writes, no navigation, no events or toasts, nothing marked read, and no `usePageBadge`, `useScreenTitle`, `useSectionEmpty` or `useWidgetRoute` setter.
|
|
147
|
+
- **The host enforces read-only.** A counter's datastore and directory offer only their read methods and realtime subscriptions, and notification mutes only `get`/`list`. Writes, typing signals, payments, `notifications.send`, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable, exactly as on a host that does not provide them.
|
|
148
|
+
- **Stay live.** The counter runs for as long as the app is open, so subscribe to changes (as above) or refetch on a slow interval; a counter that reads once goes stale.
|
|
149
|
+
- **Share one implementation.** Count with the same function the widget badges with, so the background count and the open widget never disagree.
|
|
150
|
+
- **`usePageBadge(null)` is a new signal.** An explicit `null` means "not known yet": nothing is written and the current badge stays. Pass `null` until your first load settles, so opening the page never flashes the badge to 0. `undefined`, `0` and any other value that is not a positive number still clear this widget's share, exactly as before, so `usePageBadge(data?.count)` behaves as it always has.
|
|
151
|
+
- **New linter rules.** `badge-counter-export-missing` (error) refuses a widget that declares `badgeCounter` while an entry has no `useBadgeCount` export; `badge-counter-undeclared` (warning) flags the export without the declaration. The export name is published as `CONTRACT.badgeCounterExport`.
|
|
152
|
+
- **Types:** `WidgetManifest.badgeCounter` and `BadgeCounterHook`.
|
|
153
|
+
|
|
154
|
+
Additive: a widget without `badgeCounter` is unaffected.
|
|
87
155
|
|
|
88
156
|
### What's new in 0.200.0 (contract 1.159.0)
|
|
89
157
|
|
|
@@ -2342,6 +2410,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
2342
2410
|
- `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel. Never hand-roll a row or a header row from `View` + `Pressable`.
|
|
2343
2411
|
- `Badge`, `Avatar`, `Progress`, `Stat`, `Divider` — the themed status pill, person picture, completion bar or ring, headline figure and rule the built-in widgets of the same names render through. Reach for them inside a record row instead of hand-rolling a pill or a circle from `View`.
|
|
2344
2412
|
- `Card`, `Button`, `TextField` — the themed card surface, button and text field the built-in widgets render through. With no props each takes the app's look from Theme Settings (Cards, Buttons, Muted); never hand-roll one from `View` / `Pressable` / a bare `TextInput`.
|
|
2413
|
+
- `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
|
|
2345
2414
|
- `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
|
|
2346
2415
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
2347
2416
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The values `<Accordion>` paints with, and its open-state rules. The surface
|
|
2
|
+
// comes from the theme's `card` scope and the rows from its `list` scope, so a
|
|
3
|
+
// look restyles every collapsible section without a scope of its own.
|
|
4
|
+
// Platform-free, so both hosts draw from the same numbers and tests can pin
|
|
5
|
+
// them without a renderer.
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
|
+
import { mixHex, isHexColor } from "./contract.js";
|
|
9
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
10
|
+
import { resolveListTokens, LIST_ROW_MIN_HEIGHT } from "./list-tokens.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve one accordion: the `card` scope's surface, the `list` scope's row
|
|
14
|
+
* padding, height and divider, then the theme tokens.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
17
|
+
*/
|
|
18
|
+
export function resolveAccordionTokens(theme) {
|
|
19
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
20
|
+
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
21
|
+
const radii = base.radii || DEFAULT_THEME_TOKENS.radii;
|
|
22
|
+
const card = normaliseThemeComponents(base.components).card || {};
|
|
23
|
+
const list = resolveListTokens(base);
|
|
24
|
+
|
|
25
|
+
const background = card.background ?? colors.surface;
|
|
26
|
+
return {
|
|
27
|
+
background,
|
|
28
|
+
borderColor: card.borderColor ?? colors.border,
|
|
29
|
+
radius: card.radius ?? radii.md,
|
|
30
|
+
headerPadding: list.rowPadding,
|
|
31
|
+
// A dense look may shrink a row, never the header's tap target.
|
|
32
|
+
headerMinHeight: Math.max(list.rowMinHeight, LIST_ROW_MIN_HEIGHT),
|
|
33
|
+
dividerColor: list.dividerColor,
|
|
34
|
+
dividerWidth: list.dividerWidth,
|
|
35
|
+
// The mix needs hex; a translucent surface tints the theme's own instead.
|
|
36
|
+
pressedBackground: mixHex(colors.onSurface, isHexColor(background) ? background : colors.surface, 0.94),
|
|
37
|
+
chevronColor: colors.onSurfaceMuted,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The open sections as an array, from a single key or a list of keys. */
|
|
42
|
+
export function openKeysOf(value) {
|
|
43
|
+
if (Array.isArray(value)) return value.filter((key) => key !== null && key !== undefined);
|
|
44
|
+
return value === null || value === undefined ? [] : [value];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The open sections after one header is pressed. Single mode keeps at most one
|
|
49
|
+
* open, and pressing the open one closes it.
|
|
50
|
+
*/
|
|
51
|
+
export function toggleOpenKey(open, key, multiple) {
|
|
52
|
+
if (open.includes(key)) return open.filter((k) => k !== key);
|
|
53
|
+
return multiple ? [...open, key] : [key];
|
|
54
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The ONE implementation behind the `<Accordion>` and `<AccordionItem>` SDK
|
|
2
|
+
// primitives. The platform bindings (`accordion.js` / `accordion.native.js`)
|
|
3
|
+
// differ only in where the primitives come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// Widgets each built their own collapsible section, with their own header,
|
|
6
|
+
// chevron and spacing, and none announced whether it was open. This one reads
|
|
7
|
+
// the theme's `card` and `list` scopes, so every section in an app matches.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useHostTheme } from "./hooks.js";
|
|
11
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
|
+
import { Icon } from "./icon.js";
|
|
13
|
+
import { textRoleStyle } from "./text-roles.js";
|
|
14
|
+
import { resolveAccordionTokens, openKeysOf, toggleOpenKey } from "./accordion-tokens.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bind the shared accordion to one platform's primitives.
|
|
18
|
+
*
|
|
19
|
+
* @param {{View: any, Text: any, Pressable: any}} rn
|
|
20
|
+
*/
|
|
21
|
+
export function makeAccordion(rn) {
|
|
22
|
+
const { View, Text, Pressable } = rn;
|
|
23
|
+
const AccordionContext = React.createContext(null);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {boolean} [props.multiple] — several sections may be open at once.
|
|
27
|
+
* @param {string|string[]|null} [props.value] — the open section(s); makes
|
|
28
|
+
* the accordion controlled. A key in single mode, an array in multiple.
|
|
29
|
+
* @param {string|string[]|null} [props.defaultValue] — the initially open
|
|
30
|
+
* section(s) when uncontrolled.
|
|
31
|
+
* @param {(next: string|string[]|null) => void} [props.onChange] — the open
|
|
32
|
+
* section(s) after a header press, in the same shape as `value`.
|
|
33
|
+
*/
|
|
34
|
+
function Accordion({
|
|
35
|
+
multiple = false,
|
|
36
|
+
value,
|
|
37
|
+
defaultValue,
|
|
38
|
+
onChange,
|
|
39
|
+
children,
|
|
40
|
+
accessibilityLabel,
|
|
41
|
+
style,
|
|
42
|
+
testID,
|
|
43
|
+
}) {
|
|
44
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
45
|
+
const tokens = resolveAccordionTokens(theme);
|
|
46
|
+
const controlled = value !== undefined;
|
|
47
|
+
const [ownOpen, setOwnOpen] = React.useState(() => openKeysOf(defaultValue));
|
|
48
|
+
const open = controlled ? openKeysOf(value) : ownOpen;
|
|
49
|
+
|
|
50
|
+
const toggle = (key) => {
|
|
51
|
+
if (key === null || key === undefined) return;
|
|
52
|
+
const next = toggleOpenKey(open, key, multiple);
|
|
53
|
+
if (!controlled) setOwnOpen(next);
|
|
54
|
+
if (typeof onChange === "function") onChange(multiple ? next : (next[0] ?? null));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return React.createElement(
|
|
58
|
+
AccordionContext.Provider,
|
|
59
|
+
{ value: { open, toggle, tokens, theme } },
|
|
60
|
+
React.createElement(
|
|
61
|
+
View,
|
|
62
|
+
{
|
|
63
|
+
style: [
|
|
64
|
+
{
|
|
65
|
+
backgroundColor: tokens.background,
|
|
66
|
+
borderColor: tokens.borderColor,
|
|
67
|
+
borderWidth: 1,
|
|
68
|
+
borderRadius: tokens.radius,
|
|
69
|
+
overflow: "hidden",
|
|
70
|
+
},
|
|
71
|
+
style,
|
|
72
|
+
],
|
|
73
|
+
// A label names the group only when it has a role to hang on.
|
|
74
|
+
role: accessibilityLabel ? "group" : undefined,
|
|
75
|
+
accessibilityLabel,
|
|
76
|
+
testID,
|
|
77
|
+
},
|
|
78
|
+
// The last section's divider slides under the clipped edge, so it
|
|
79
|
+
// ends on the surface wherever it sits in the tree.
|
|
80
|
+
React.createElement(
|
|
81
|
+
View,
|
|
82
|
+
{ style: { marginBottom: -tokens.dividerWidth } },
|
|
83
|
+
children,
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
Accordion.displayName = "Accordion";
|
|
89
|
+
|
|
90
|
+
function AccordionItem(props) {
|
|
91
|
+
const context = React.useContext(AccordionContext);
|
|
92
|
+
// Stable per section, so a wrapped or conditional one keeps its own key.
|
|
93
|
+
const ownKey = React.useId();
|
|
94
|
+
// A lone section is an accordion of one, so it still opens and closes.
|
|
95
|
+
if (!context) return React.createElement(Accordion, null, React.createElement(AccordionItem, props));
|
|
96
|
+
|
|
97
|
+
const {
|
|
98
|
+
value = ownKey,
|
|
99
|
+
title,
|
|
100
|
+
subtitle,
|
|
101
|
+
trailing,
|
|
102
|
+
disabled = false,
|
|
103
|
+
divider = true,
|
|
104
|
+
children,
|
|
105
|
+
accessibilityLabel,
|
|
106
|
+
style,
|
|
107
|
+
testID,
|
|
108
|
+
} = props;
|
|
109
|
+
const { open: openKeys, toggle, tokens, theme } = context;
|
|
110
|
+
const open = openKeys.includes(value);
|
|
111
|
+
const { colors, typography } = theme;
|
|
112
|
+
|
|
113
|
+
const header = React.createElement(
|
|
114
|
+
View,
|
|
115
|
+
{
|
|
116
|
+
style: {
|
|
117
|
+
flexDirection: "row",
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
paddingRight: trailing ? tokens.headerPadding : 0,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
React.createElement(
|
|
123
|
+
Pressable,
|
|
124
|
+
{
|
|
125
|
+
onPress: () => toggle(value),
|
|
126
|
+
disabled,
|
|
127
|
+
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
128
|
+
accessibilityRole: "button",
|
|
129
|
+
accessibilityLabel: accessibilityLabel ?? headerLabel(title, subtitle),
|
|
130
|
+
"aria-expanded": open,
|
|
131
|
+
"aria-disabled": disabled,
|
|
132
|
+
testID: testID ? `${testID}-header` : undefined,
|
|
133
|
+
style: ({ pressed }) => [
|
|
134
|
+
{
|
|
135
|
+
flex: 1,
|
|
136
|
+
minWidth: 0,
|
|
137
|
+
flexDirection: "row",
|
|
138
|
+
alignItems: "center",
|
|
139
|
+
gap: theme.spacing.sm,
|
|
140
|
+
minHeight: tokens.headerMinHeight,
|
|
141
|
+
padding: tokens.headerPadding,
|
|
142
|
+
},
|
|
143
|
+
pressed ? { backgroundColor: tokens.pressedBackground } : null,
|
|
144
|
+
disabled ? { opacity: 0.5 } : null,
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
React.createElement(Icon, {
|
|
148
|
+
name: open ? "chevron-down" : "chevron-right",
|
|
149
|
+
size: 18,
|
|
150
|
+
color: tokens.chevronColor,
|
|
151
|
+
}),
|
|
152
|
+
React.createElement(
|
|
153
|
+
View,
|
|
154
|
+
{ style: { flex: 1, minWidth: 0, gap: 2 } },
|
|
155
|
+
title !== undefined && title !== null
|
|
156
|
+
? React.createElement(
|
|
157
|
+
Text,
|
|
158
|
+
{ style: textRoleStyle("subtitle", typography, colors), numberOfLines: 1 },
|
|
159
|
+
title,
|
|
160
|
+
)
|
|
161
|
+
: null,
|
|
162
|
+
subtitle !== undefined && subtitle !== null
|
|
163
|
+
? React.createElement(
|
|
164
|
+
Text,
|
|
165
|
+
{ style: textRoleStyle("caption", typography, colors), numberOfLines: 2 },
|
|
166
|
+
subtitle,
|
|
167
|
+
)
|
|
168
|
+
: null,
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
// Outside the toggle, so a button here never opens the section.
|
|
172
|
+
trailing || null,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return React.createElement(
|
|
176
|
+
View,
|
|
177
|
+
{
|
|
178
|
+
style: [
|
|
179
|
+
divider && tokens.dividerWidth > 0
|
|
180
|
+
? { borderBottomWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
|
|
181
|
+
: null,
|
|
182
|
+
style,
|
|
183
|
+
],
|
|
184
|
+
testID,
|
|
185
|
+
},
|
|
186
|
+
header,
|
|
187
|
+
// Mounted only while open, so a closed section costs nothing.
|
|
188
|
+
open
|
|
189
|
+
? React.createElement(
|
|
190
|
+
View,
|
|
191
|
+
{
|
|
192
|
+
style: { padding: tokens.headerPadding, paddingTop: 0 },
|
|
193
|
+
testID: testID ? `${testID}-panel` : undefined,
|
|
194
|
+
},
|
|
195
|
+
// A section nested in this body is not one of this accordion's.
|
|
196
|
+
React.createElement(AccordionContext.Provider, { value: null }, children),
|
|
197
|
+
)
|
|
198
|
+
: null,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
AccordionItem.displayName = "AccordionItem";
|
|
202
|
+
|
|
203
|
+
// The label replaces the visible text, so it must carry the subtitle too.
|
|
204
|
+
function headerLabel(title, subtitle) {
|
|
205
|
+
const parts = [title, subtitle].filter((part) => typeof part === "string" && part !== "");
|
|
206
|
+
return parts.length ? parts.join(", ") : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { Accordion, AccordionItem };
|
|
210
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<Accordion>` / `<AccordionItem>` (web). Binds ./accordion-view.js to
|
|
2
|
+
// react-native-web; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable } from "react-native-web";
|
|
5
|
+
import { makeAccordion } from "./accordion-view.js";
|
|
6
|
+
|
|
7
|
+
export const { Accordion, AccordionItem } = makeAccordion({ View, Text, Pressable });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<Accordion>` / `<AccordionItem>` (native). Binds ./accordion-view.js to
|
|
2
|
+
// React Native's own primitives; only the import differs from ./accordion.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable } from "react-native";
|
|
5
|
+
import { makeAccordion } from "./accordion-view.js";
|
|
6
|
+
|
|
7
|
+
export const { Accordion, AccordionItem } = makeAccordion({ View, Text, Pressable });
|
package/dist/contract.cjs
CHANGED
|
@@ -1065,7 +1065,10 @@ const HOOKS = [
|
|
|
1065
1065
|
description:
|
|
1066
1066
|
"Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
|
|
1067
1067
|
"The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
|
|
1068
|
-
"reports again; 0, or
|
|
1068
|
+
"reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
|
|
1069
|
+
"An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
|
|
1070
|
+
"settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
|
|
1071
|
+
"see that field for who may declare it. Returns { supported }: false " +
|
|
1069
1072
|
"on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
|
|
1070
1073
|
"count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
|
|
1071
1074
|
returnShape: {
|
|
@@ -1447,7 +1450,7 @@ const HOOKS = [
|
|
|
1447
1450
|
message: "string | null",
|
|
1448
1451
|
signerName: "string | null",
|
|
1449
1452
|
signedAt: "string | null",
|
|
1450
|
-
verdict: "{ valid, checks, content_status, ... } | null",
|
|
1453
|
+
verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
|
|
1451
1454
|
loading: "boolean",
|
|
1452
1455
|
error: "PermissionError | null // { code, message, retryable }",
|
|
1453
1456
|
initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
|
|
@@ -2801,6 +2804,20 @@ const PRIMITIVES = [
|
|
|
2801
2804
|
rnComponent: null,
|
|
2802
2805
|
docsUrl: null,
|
|
2803
2806
|
},
|
|
2807
|
+
{
|
|
2808
|
+
name: "Accordion",
|
|
2809
|
+
description:
|
|
2810
|
+
'Themed collapsible sections. `<Accordion defaultValue="shipping"><AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem><AccordionItem value="returns" title="Returns">…</AccordionItem></Accordion>`. THE way to build a section that expands and collapses (an FAQ, a settings group, a record\'s details, a group whose members show on demand): the surface takes the app\'s card look (Theme Settings > Cards: background, border, corner radius) and the headers and dividers its list look (Theme Settings > Lists: row padding, row height, divider), so every collapsible section in the app matches. Each header has a chevron, a 44px touch target, works from the keyboard and tells assistive technology whether its section is open, so never hand-roll a collapsible from `Pressable` + a chevron + a `useState`. By default one section is open at a time and pressing the open one closes it; `multiple` lets several stay open. Props: `multiple`, `value` (the open key, or an array of keys when `multiple`; makes it controlled), `defaultValue` (the same shape, uncontrolled), `onChange(next)` (the open key(s) after a press, in the same shape), `accessibilityLabel`, `style`, `testID`. Its children are `<AccordionItem>`s.',
|
|
2811
|
+
rnComponent: null,
|
|
2812
|
+
docsUrl: null,
|
|
2813
|
+
},
|
|
2814
|
+
{
|
|
2815
|
+
name: "AccordionItem",
|
|
2816
|
+
description:
|
|
2817
|
+
'One section of an `<Accordion>`. `<AccordionItem value={group.id} title={group.name} subtitle={`${group.count} members`} trailing={<Button title="Delete" variant="danger" onPress={remove} />}>…</AccordionItem>`. Props: `value` (the key `value` / `defaultValue` / `onChange` name it by — give one whenever you name sections; an item may sit inside your own component or a fragment), `title`, `subtitle` (the subtitle / caption text tiers), `trailing` (a node beside the header and OUTSIDE its toggle, so a button there runs its own action without opening the section), `disabled`, `divider` (default true; the surface edge hides the rule under the last section), `children` (the body: it mounts only while the section is open, so fetch its data inside it; an `<AccordionItem>` placed in a body is its own section, never part of the outer accordion), `accessibilityLabel` (defaults to the string `title` and `subtitle`), `style`, `testID` (also tags the header `<testID>-header` and the open body `<testID>-panel`). On its own, outside an `<Accordion>`, it is an accordion of one.',
|
|
2818
|
+
rnComponent: null,
|
|
2819
|
+
docsUrl: null,
|
|
2820
|
+
},
|
|
2804
2821
|
{
|
|
2805
2822
|
name: "Tabs",
|
|
2806
2823
|
description:
|
|
@@ -3047,6 +3064,13 @@ const MANIFEST_SCHEMA = {
|
|
|
3047
3064
|
"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.",
|
|
3048
3065
|
default: false,
|
|
3049
3066
|
},
|
|
3067
|
+
badgeCounter: {
|
|
3068
|
+
type: "boolean",
|
|
3069
|
+
required: false,
|
|
3070
|
+
description:
|
|
3071
|
+
"Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
|
|
3072
|
+
default: false,
|
|
3073
|
+
},
|
|
3050
3074
|
events: {
|
|
3051
3075
|
type: "object[]",
|
|
3052
3076
|
required: true,
|
|
@@ -5305,7 +5329,17 @@ const CONTRACT = deepFreeze({
|
|
|
5305
5329
|
// flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
|
|
5306
5330
|
// New `useBundledAsset(fileName)` resolves one through the new
|
|
5307
5331
|
// `widget.assets` map; linter rule `bundled-asset-missing` (error).
|
|
5308
|
-
|
|
5332
|
+
// 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
|
|
5333
|
+
// the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
|
|
5334
|
+
// and the host runs it while the widget's page is closed, so the page
|
|
5335
|
+
// badge stays live; the host gives a counter a read-only context.
|
|
5336
|
+
// `usePageBadge(null)` now means "not known yet" and keeps the current
|
|
5337
|
+
// badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
|
|
5338
|
+
// (error) and `badge-counter-undeclared` (warning).
|
|
5339
|
+
// 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
|
|
5340
|
+
// collapsible sections drawn from the `card` and `list` scopes: single
|
|
5341
|
+
// or multiple open, controlled or uncontrolled, `aria-expanded` headers.
|
|
5342
|
+
version: "1.161.0",
|
|
5309
5343
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5310
5344
|
hooks: HOOKS,
|
|
5311
5345
|
primitives: PRIMITIVES,
|
|
@@ -5331,6 +5365,8 @@ const CONTRACT = deepFreeze({
|
|
|
5331
5365
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
5332
5366
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
5333
5367
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
5368
|
+
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
|
5369
|
+
badgeCounterExport: "useBadgeCount",
|
|
5334
5370
|
bannedApis: BANNED_APIS,
|
|
5335
5371
|
vettedImports: VETTED_IMPORTS,
|
|
5336
5372
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
package/dist/contract.js
CHANGED
|
@@ -1065,7 +1065,10 @@ const HOOKS = [
|
|
|
1065
1065
|
description:
|
|
1066
1066
|
"Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
|
|
1067
1067
|
"The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
|
|
1068
|
-
"reports again; 0, or
|
|
1068
|
+
"reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
|
|
1069
|
+
"An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
|
|
1070
|
+
"settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
|
|
1071
|
+
"see that field for who may declare it. Returns { supported }: false " +
|
|
1069
1072
|
"on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
|
|
1070
1073
|
"count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
|
|
1071
1074
|
returnShape: {
|
|
@@ -1447,7 +1450,7 @@ const HOOKS = [
|
|
|
1447
1450
|
message: "string | null",
|
|
1448
1451
|
signerName: "string | null",
|
|
1449
1452
|
signedAt: "string | null",
|
|
1450
|
-
verdict: "{ valid, checks, content_status, ... } | null",
|
|
1453
|
+
verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
|
|
1451
1454
|
loading: "boolean",
|
|
1452
1455
|
error: "PermissionError | null // { code, message, retryable }",
|
|
1453
1456
|
initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
|
|
@@ -2801,6 +2804,20 @@ const PRIMITIVES = [
|
|
|
2801
2804
|
rnComponent: null,
|
|
2802
2805
|
docsUrl: null,
|
|
2803
2806
|
},
|
|
2807
|
+
{
|
|
2808
|
+
name: "Accordion",
|
|
2809
|
+
description:
|
|
2810
|
+
'Themed collapsible sections. `<Accordion defaultValue="shipping"><AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem><AccordionItem value="returns" title="Returns">…</AccordionItem></Accordion>`. THE way to build a section that expands and collapses (an FAQ, a settings group, a record\'s details, a group whose members show on demand): the surface takes the app\'s card look (Theme Settings > Cards: background, border, corner radius) and the headers and dividers its list look (Theme Settings > Lists: row padding, row height, divider), so every collapsible section in the app matches. Each header has a chevron, a 44px touch target, works from the keyboard and tells assistive technology whether its section is open, so never hand-roll a collapsible from `Pressable` + a chevron + a `useState`. By default one section is open at a time and pressing the open one closes it; `multiple` lets several stay open. Props: `multiple`, `value` (the open key, or an array of keys when `multiple`; makes it controlled), `defaultValue` (the same shape, uncontrolled), `onChange(next)` (the open key(s) after a press, in the same shape), `accessibilityLabel`, `style`, `testID`. Its children are `<AccordionItem>`s.',
|
|
2811
|
+
rnComponent: null,
|
|
2812
|
+
docsUrl: null,
|
|
2813
|
+
},
|
|
2814
|
+
{
|
|
2815
|
+
name: "AccordionItem",
|
|
2816
|
+
description:
|
|
2817
|
+
'One section of an `<Accordion>`. `<AccordionItem value={group.id} title={group.name} subtitle={`${group.count} members`} trailing={<Button title="Delete" variant="danger" onPress={remove} />}>…</AccordionItem>`. Props: `value` (the key `value` / `defaultValue` / `onChange` name it by — give one whenever you name sections; an item may sit inside your own component or a fragment), `title`, `subtitle` (the subtitle / caption text tiers), `trailing` (a node beside the header and OUTSIDE its toggle, so a button there runs its own action without opening the section), `disabled`, `divider` (default true; the surface edge hides the rule under the last section), `children` (the body: it mounts only while the section is open, so fetch its data inside it; an `<AccordionItem>` placed in a body is its own section, never part of the outer accordion), `accessibilityLabel` (defaults to the string `title` and `subtitle`), `style`, `testID` (also tags the header `<testID>-header` and the open body `<testID>-panel`). On its own, outside an `<Accordion>`, it is an accordion of one.',
|
|
2818
|
+
rnComponent: null,
|
|
2819
|
+
docsUrl: null,
|
|
2820
|
+
},
|
|
2804
2821
|
{
|
|
2805
2822
|
name: "Tabs",
|
|
2806
2823
|
description:
|
|
@@ -3043,6 +3060,13 @@ const MANIFEST_SCHEMA = {
|
|
|
3043
3060
|
"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.",
|
|
3044
3061
|
default: false,
|
|
3045
3062
|
},
|
|
3063
|
+
badgeCounter: {
|
|
3064
|
+
type: "boolean",
|
|
3065
|
+
required: false,
|
|
3066
|
+
description:
|
|
3067
|
+
"Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
|
|
3068
|
+
default: false,
|
|
3069
|
+
},
|
|
3046
3070
|
events: {
|
|
3047
3071
|
type: "object[]",
|
|
3048
3072
|
required: true,
|
|
@@ -5301,7 +5325,17 @@ const CONTRACT = deepFreeze({
|
|
|
5301
5325
|
// flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
|
|
5302
5326
|
// New `useBundledAsset(fileName)` resolves one through the new
|
|
5303
5327
|
// `widget.assets` map; linter rule `bundled-asset-missing` (error).
|
|
5304
|
-
|
|
5328
|
+
// 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
|
|
5329
|
+
// the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
|
|
5330
|
+
// and the host runs it while the widget's page is closed, so the page
|
|
5331
|
+
// badge stays live; the host gives a counter a read-only context.
|
|
5332
|
+
// `usePageBadge(null)` now means "not known yet" and keeps the current
|
|
5333
|
+
// badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
|
|
5334
|
+
// (error) and `badge-counter-undeclared` (warning).
|
|
5335
|
+
// 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
|
|
5336
|
+
// collapsible sections drawn from the `card` and `list` scopes: single
|
|
5337
|
+
// or multiple open, controlled or uncontrolled, `aria-expanded` headers.
|
|
5338
|
+
version: "1.161.0",
|
|
5305
5339
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5306
5340
|
hooks: HOOKS,
|
|
5307
5341
|
primitives: PRIMITIVES,
|
|
@@ -5327,6 +5361,8 @@ const CONTRACT = deepFreeze({
|
|
|
5327
5361
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
5328
5362
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
5329
5363
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
5364
|
+
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
|
5365
|
+
badgeCounterExport: "useBadgeCount",
|
|
5330
5366
|
bannedApis: BANNED_APIS,
|
|
5331
5367
|
vettedImports: VETTED_IMPORTS,
|
|
5332
5368
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
package/dist/hooks.js
CHANGED
|
@@ -621,8 +621,10 @@ export function useFilePreview(file) {
|
|
|
621
621
|
return { uri };
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
-
// A count the host can draw: a whole number, 0 for nothing to show
|
|
624
|
+
// A count the host can draw: a whole number, 0 for nothing to show, null
|
|
625
|
+
// only when the widget explicitly says it does not know yet.
|
|
625
626
|
function _badgeCount(count) {
|
|
627
|
+
if (count === null) return null;
|
|
626
628
|
return typeof count === "number" && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
627
629
|
}
|
|
628
630
|
|
|
@@ -631,8 +633,9 @@ function _badgeCount(count) {
|
|
|
631
633
|
* on, such as a chat's unread messages. Returns `{ supported }`. The host sums
|
|
632
634
|
* every widget on the page and keeps the last count after the widget leaves
|
|
633
635
|
* the screen, until it reports again; 0 (or anything not a positive number)
|
|
634
|
-
* clears this widget's share.
|
|
635
|
-
*
|
|
636
|
+
* clears this widget's share. sc-10829: an explicit null means "not known
|
|
637
|
+
* yet" and keeps the current badge; undefined still clears. A host without
|
|
638
|
+
* page badges (the Builder canvas) ignores it.
|
|
636
639
|
*/
|
|
637
640
|
export function usePageBadge(count) {
|
|
638
641
|
const ctx = useWidgetContextOrThrow("usePageBadge");
|
|
@@ -640,7 +643,7 @@ export function usePageBadge(count) {
|
|
|
640
643
|
const setBadge = typeof nav.setBadge === "function" ? nav.setBadge : null;
|
|
641
644
|
const value = _badgeCount(count);
|
|
642
645
|
useEffect(() => {
|
|
643
|
-
if (setBadge) setBadge(value);
|
|
646
|
+
if (setBadge && value !== null) setBadge(value);
|
|
644
647
|
}, [setBadge, value]);
|
|
645
648
|
return { supported: Boolean(setBadge) };
|
|
646
649
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -397,8 +397,23 @@ export interface WidgetManifest {
|
|
|
397
397
|
* /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
|
|
398
398
|
*/
|
|
399
399
|
translations?: Record<string, { en: string } & Record<string, string>>;
|
|
400
|
+
/**
|
|
401
|
+
* Optional (sc-10829, default false). True when every platform entry also
|
|
402
|
+
* exports `useBadgeCount(props)` (a `BadgeCounterHook`), which the host runs
|
|
403
|
+
* while the widget's page is closed so its menu badge stays live.
|
|
404
|
+
*/
|
|
405
|
+
badgeCounter?: boolean;
|
|
400
406
|
}
|
|
401
407
|
|
|
408
|
+
/**
|
|
409
|
+
* sc-10829 — the `useBadgeCount` export of a `badgeCounter: true` widget. Called
|
|
410
|
+
* with the instance's resolved props inside the widget's own context; returns
|
|
411
|
+
* the page badge count, or `null` while it is not known yet. It only reads (the
|
|
412
|
+
* host gives it read-only clients and no device, payment or AI access): no
|
|
413
|
+
* rendering, writes, navigation, events, toasts or page-chrome hooks.
|
|
414
|
+
*/
|
|
415
|
+
export type BadgeCounterHook = (props: Record<string, unknown>) => number | null;
|
|
416
|
+
|
|
402
417
|
/** A React Native shadow style object — one level of `ThemeTokens.elevation`. */
|
|
403
418
|
export interface ElevationLevel {
|
|
404
419
|
shadowColor?: string;
|
|
@@ -1821,17 +1836,19 @@ export function useNavigation(): {
|
|
|
1821
1836
|
*/
|
|
1822
1837
|
export function useRouteParams(): Record<string, unknown>;
|
|
1823
1838
|
|
|
1824
|
-
/**
|
|
1825
|
-
* sc-10268 — puts `count` on the menu entry or tab of the page this widget is
|
|
1826
|
-
* on. The host sums the page's widgets and keeps the last count after the
|
|
1827
|
-
* widget leaves the screen; 0 clears this widget's share.
|
|
1828
|
-
*/
|
|
1829
1839
|
/**
|
|
1830
1840
|
* sc-10268 — `{ uri }` an `<Image>` can show for a picked file before it is
|
|
1831
1841
|
* uploaded. Null for no file, or a host that cannot preview one.
|
|
1832
1842
|
*/
|
|
1833
1843
|
export function useFilePreview(file: unknown): { uri: string | null };
|
|
1834
1844
|
|
|
1845
|
+
/**
|
|
1846
|
+
* sc-10268 — puts `count` on the menu entry or tab of the page this widget is
|
|
1847
|
+
* on. The host sums the page's widgets and keeps the last count after the
|
|
1848
|
+
* widget leaves the screen; 0 or `undefined` clears this widget's share.
|
|
1849
|
+
* sc-10829: an explicit `null` means the count is not known yet and keeps the
|
|
1850
|
+
* current badge.
|
|
1851
|
+
*/
|
|
1835
1852
|
export function usePageBadge(count: number | null | undefined): {
|
|
1836
1853
|
supported: boolean;
|
|
1837
1854
|
};
|
|
@@ -3424,6 +3441,52 @@ export const Tabs: (props: {
|
|
|
3424
3441
|
testID?: string;
|
|
3425
3442
|
}) => any;
|
|
3426
3443
|
|
|
3444
|
+
/**
|
|
3445
|
+
* Themed collapsible sections. The surface comes from the theme's `card`
|
|
3446
|
+
* scope and the headers and dividers from its `list` scope, so every
|
|
3447
|
+
* collapsible section in the app matches. Each header announces whether its
|
|
3448
|
+
* section is open, and a section's body mounts only while it is open.
|
|
3449
|
+
*
|
|
3450
|
+
* @example
|
|
3451
|
+
* <Accordion defaultValue="shipping">
|
|
3452
|
+
* <AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem>
|
|
3453
|
+
* <AccordionItem value="returns" title="Returns">…</AccordionItem>
|
|
3454
|
+
* </Accordion>
|
|
3455
|
+
*/
|
|
3456
|
+
export const Accordion: (props: {
|
|
3457
|
+
/** Several sections may be open at once; by default opening one closes the other. */
|
|
3458
|
+
multiple?: boolean;
|
|
3459
|
+
/** The open section key (an array of keys when `multiple`); makes it controlled. */
|
|
3460
|
+
value?: string | string[] | null;
|
|
3461
|
+
/** The initially open section(s) when uncontrolled. */
|
|
3462
|
+
defaultValue?: string | string[] | null;
|
|
3463
|
+
/** The open section(s) after a header press, in the same shape as `value`. */
|
|
3464
|
+
onChange?: (next: string | string[] | null) => void;
|
|
3465
|
+
children?: ReactNode;
|
|
3466
|
+
/** Names the group of sections for assistive technology. */
|
|
3467
|
+
accessibilityLabel?: string;
|
|
3468
|
+
style?: any;
|
|
3469
|
+
testID?: string;
|
|
3470
|
+
}) => any;
|
|
3471
|
+
|
|
3472
|
+
/** One section of an `<Accordion>`; on its own it is an accordion of one. */
|
|
3473
|
+
export const AccordionItem: (props: {
|
|
3474
|
+
/** The key `value` / `defaultValue` / `onChange` name it by; give one whenever you name sections. */
|
|
3475
|
+
value?: string;
|
|
3476
|
+
title?: ReactNode;
|
|
3477
|
+
subtitle?: ReactNode;
|
|
3478
|
+
/** Beside the header and outside its toggle, so a button here does its own thing. */
|
|
3479
|
+
trailing?: ReactNode;
|
|
3480
|
+
disabled?: boolean;
|
|
3481
|
+
/** Rule under the section (default true); the surface edge hides the last one's. */
|
|
3482
|
+
divider?: boolean;
|
|
3483
|
+
children?: ReactNode;
|
|
3484
|
+
accessibilityLabel?: string;
|
|
3485
|
+
style?: any;
|
|
3486
|
+
/** Also tags the header (`<testID>-header`) and open body (`<testID>-panel`). */
|
|
3487
|
+
testID?: string;
|
|
3488
|
+
}) => any;
|
|
3489
|
+
|
|
3427
3490
|
export type TextRole = "display" | "title" | "subtitle" | "body" | "caption" | "overline";
|
|
3428
3491
|
export const TEXT_ROLES: readonly TextRole[];
|
|
3429
3492
|
export const TEXT_TRANSFORMS: readonly string[];
|
|
@@ -3851,6 +3914,8 @@ export interface AiWidgetContract {
|
|
|
3851
3914
|
>
|
|
3852
3915
|
>;
|
|
3853
3916
|
readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
|
|
3917
|
+
/** sc-10829 — the export name of a `badgeCounter: true` entry's hook. */
|
|
3918
|
+
readonly badgeCounterExport: "useBadgeCount";
|
|
3854
3919
|
readonly bannedApis: ReadonlyArray<ContractBannedApi>;
|
|
3855
3920
|
readonly allowedBareImports: ReadonlyArray<string>;
|
|
3856
3921
|
/** Bare specifiers the web host provides by exact name; a widget bundle never inlines them. */
|
package/dist/index.js
CHANGED
|
@@ -257,6 +257,8 @@ export {
|
|
|
257
257
|
} from "./button-style.js";
|
|
258
258
|
// The themed text-entry field; follows the theme's `muted` scope.
|
|
259
259
|
export { TextField } from "./text-field.js";
|
|
260
|
+
// Themed collapsible sections, drawn from the `card` and `list` scopes.
|
|
261
|
+
export { Accordion, AccordionItem } from "./accordion.js";
|
|
260
262
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
261
263
|
export { Tabs } from "./tabs.js";
|
|
262
264
|
// The semantic text tiers, shared with the built-in widgets.
|
package/dist/index.native.js
CHANGED
|
@@ -250,6 +250,8 @@ export {
|
|
|
250
250
|
} from "./button-style.js";
|
|
251
251
|
// The themed text-entry field; follows the theme's `muted` scope.
|
|
252
252
|
export { TextField } from "./text-field.native.js";
|
|
253
|
+
// Themed collapsible sections, drawn from the `card` and `list` scopes.
|
|
254
|
+
export { Accordion, AccordionItem } from "./accordion.native.js";
|
|
253
255
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
254
256
|
export { Tabs } from "./tabs.native.js";
|
|
255
257
|
// The semantic text tiers, shared with the built-in widgets.
|
package/dist/linter.cjs
CHANGED
|
@@ -1992,6 +1992,54 @@ function lintEditability(manifest, files) {
|
|
|
1992
1992
|
return { ok: true, findings };
|
|
1993
1993
|
}
|
|
1994
1994
|
|
|
1995
|
+
// sc-10829 — `manifest.badgeCounter` promises the host a hook it can run
|
|
1996
|
+
// while the page is closed; each shipped entry must export it by name.
|
|
1997
|
+
function _exportedNameLine(code, name) {
|
|
1998
|
+
const declared = new RegExp(
|
|
1999
|
+
`\\bexport\\s+(?:(?:async\\s+)?function\\b\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b`,
|
|
2000
|
+
);
|
|
2001
|
+
const hit = declared.exec(code);
|
|
2002
|
+
if (hit) return code.slice(0, hit.index).split("\n").length;
|
|
2003
|
+
for (const clause of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
|
|
2004
|
+
const names = clause[1].split(",").map((part) => part.trim().split(/\s+as\s+/).pop().trim());
|
|
2005
|
+
if (names.includes(name)) return code.slice(0, clause.index).split("\n").length;
|
|
2006
|
+
}
|
|
2007
|
+
return 0;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function _badgeCounterRules(source, manifest) {
|
|
2011
|
+
if (!manifest || typeof manifest !== "object") return [];
|
|
2012
|
+
const name = CONTRACT.badgeCounterExport;
|
|
2013
|
+
const line = _exportedNameLine(_stripNonCode(source), name);
|
|
2014
|
+
if (manifest.badgeCounter === true && line === 0) {
|
|
2015
|
+
return [
|
|
2016
|
+
{
|
|
2017
|
+
rule: "badge-counter-export-missing",
|
|
2018
|
+
severity: "error",
|
|
2019
|
+
label:
|
|
2020
|
+
`manifest.badgeCounter is true but this entry does not export ${name}(props). ` +
|
|
2021
|
+
"Export the hook from every platform entry, or drop badgeCounter.",
|
|
2022
|
+
line: 0,
|
|
2023
|
+
snippet: "",
|
|
2024
|
+
},
|
|
2025
|
+
];
|
|
2026
|
+
}
|
|
2027
|
+
if (manifest.badgeCounter !== true && line > 0) {
|
|
2028
|
+
return [
|
|
2029
|
+
{
|
|
2030
|
+
rule: "badge-counter-undeclared",
|
|
2031
|
+
severity: "warning",
|
|
2032
|
+
label:
|
|
2033
|
+
`This entry exports ${name} but manifest.badgeCounter is not true, so the host never runs it. ` +
|
|
2034
|
+
"Declare badgeCounter: true, or remove the export.",
|
|
2035
|
+
line,
|
|
2036
|
+
snippet: source.split(/\r?\n/)[line - 1].trim().slice(0, 200),
|
|
2037
|
+
},
|
|
2038
|
+
];
|
|
2039
|
+
}
|
|
2040
|
+
return [];
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1995
2043
|
function lintSource(source, options) {
|
|
1996
2044
|
if (typeof source !== "string") {
|
|
1997
2045
|
return {
|
|
@@ -2070,6 +2118,7 @@ function lintSource(source, options) {
|
|
|
2070
2118
|
})),
|
|
2071
2119
|
);
|
|
2072
2120
|
findings.push(..._manifestActionRules(options && options.manifest));
|
|
2121
|
+
findings.push(..._badgeCounterRules(source, options && options.manifest));
|
|
2073
2122
|
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
2074
2123
|
return { ok: !hasErrors, findings };
|
|
2075
2124
|
}
|
package/dist/linter.js
CHANGED
|
@@ -2055,6 +2055,54 @@ export function lintEditability(manifest, files) {
|
|
|
2055
2055
|
return { ok: true, findings };
|
|
2056
2056
|
}
|
|
2057
2057
|
|
|
2058
|
+
// sc-10829 — `manifest.badgeCounter` promises the host a hook it can run
|
|
2059
|
+
// while the page is closed; each shipped entry must export it by name.
|
|
2060
|
+
function _exportedNameLine(code, name) {
|
|
2061
|
+
const declared = new RegExp(
|
|
2062
|
+
`\\bexport\\s+(?:(?:async\\s+)?function\\b\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b`,
|
|
2063
|
+
);
|
|
2064
|
+
const hit = declared.exec(code);
|
|
2065
|
+
if (hit) return code.slice(0, hit.index).split("\n").length;
|
|
2066
|
+
for (const clause of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
|
|
2067
|
+
const names = clause[1].split(",").map((part) => part.trim().split(/\s+as\s+/).pop().trim());
|
|
2068
|
+
if (names.includes(name)) return code.slice(0, clause.index).split("\n").length;
|
|
2069
|
+
}
|
|
2070
|
+
return 0;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function _badgeCounterRules(source, manifest) {
|
|
2074
|
+
if (!manifest || typeof manifest !== "object") return [];
|
|
2075
|
+
const name = CONTRACT.badgeCounterExport;
|
|
2076
|
+
const line = _exportedNameLine(_stripNonCode(source), name);
|
|
2077
|
+
if (manifest.badgeCounter === true && line === 0) {
|
|
2078
|
+
return [
|
|
2079
|
+
{
|
|
2080
|
+
rule: "badge-counter-export-missing",
|
|
2081
|
+
severity: "error",
|
|
2082
|
+
label:
|
|
2083
|
+
`manifest.badgeCounter is true but this entry does not export ${name}(props). ` +
|
|
2084
|
+
"Export the hook from every platform entry, or drop badgeCounter.",
|
|
2085
|
+
line: 0,
|
|
2086
|
+
snippet: "",
|
|
2087
|
+
},
|
|
2088
|
+
];
|
|
2089
|
+
}
|
|
2090
|
+
if (manifest.badgeCounter !== true && line > 0) {
|
|
2091
|
+
return [
|
|
2092
|
+
{
|
|
2093
|
+
rule: "badge-counter-undeclared",
|
|
2094
|
+
severity: "warning",
|
|
2095
|
+
label:
|
|
2096
|
+
`This entry exports ${name} but manifest.badgeCounter is not true, so the host never runs it. ` +
|
|
2097
|
+
"Declare badgeCounter: true, or remove the export.",
|
|
2098
|
+
line,
|
|
2099
|
+
snippet: source.split(/\r?\n/)[line - 1].trim().slice(0, 200),
|
|
2100
|
+
},
|
|
2101
|
+
];
|
|
2102
|
+
}
|
|
2103
|
+
return [];
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2058
2106
|
export function lintSource(source, options) {
|
|
2059
2107
|
if (typeof source !== "string") {
|
|
2060
2108
|
return {
|
|
@@ -2142,6 +2190,7 @@ export function lintSource(source, options) {
|
|
|
2142
2190
|
);
|
|
2143
2191
|
// REQ-WIDGET-ACTION — structural validation of manifest-declared actions.
|
|
2144
2192
|
findings.push(..._manifestActionRules(options && options.manifest));
|
|
2193
|
+
findings.push(..._badgeCounterRules(source, options && options.manifest));
|
|
2145
2194
|
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
2146
2195
|
return { ok: !hasErrors, findings };
|
|
2147
2196
|
}
|
package/dist/manifest.cjs
CHANGED
|
@@ -325,6 +325,12 @@ function validateManifest(m) {
|
|
|
325
325
|
validateManifestTranslations(manifest.translations, manifest.id, errors);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
pushIf(
|
|
329
|
+
errors,
|
|
330
|
+
manifest.badgeCounter === undefined || typeof manifest.badgeCounter === "boolean",
|
|
331
|
+
"manifest.badgeCounter must be a boolean when present",
|
|
332
|
+
);
|
|
333
|
+
|
|
328
334
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
329
335
|
}
|
|
330
336
|
|
package/dist/manifest.js
CHANGED
|
@@ -371,6 +371,12 @@ function validateManifest(m) {
|
|
|
371
371
|
validateManifestTranslations(manifest.translations, manifest.id, errors);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
pushIf(
|
|
375
|
+
errors,
|
|
376
|
+
manifest.badgeCounter === undefined || typeof manifest.badgeCounter === "boolean",
|
|
377
|
+
"manifest.badgeCounter must be a boolean when present",
|
|
378
|
+
);
|
|
379
|
+
|
|
374
380
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
375
381
|
}
|
|
376
382
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.202.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",
|
package/src/contract.js
CHANGED
|
@@ -1065,7 +1065,10 @@ const HOOKS = [
|
|
|
1065
1065
|
description:
|
|
1066
1066
|
"Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
|
|
1067
1067
|
"The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
|
|
1068
|
-
"reports again; 0, or
|
|
1068
|
+
"reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
|
|
1069
|
+
"An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
|
|
1070
|
+
"settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
|
|
1071
|
+
"see that field for who may declare it. Returns { supported }: false " +
|
|
1069
1072
|
"on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
|
|
1070
1073
|
"count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
|
|
1071
1074
|
returnShape: {
|
|
@@ -1447,7 +1450,7 @@ const HOOKS = [
|
|
|
1447
1450
|
message: "string | null",
|
|
1448
1451
|
signerName: "string | null",
|
|
1449
1452
|
signedAt: "string | null",
|
|
1450
|
-
verdict: "{ valid, checks, content_status, ... } | null",
|
|
1453
|
+
verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
|
|
1451
1454
|
loading: "boolean",
|
|
1452
1455
|
error: "PermissionError | null // { code, message, retryable }",
|
|
1453
1456
|
initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
|
|
@@ -2801,6 +2804,20 @@ const PRIMITIVES = [
|
|
|
2801
2804
|
rnComponent: null,
|
|
2802
2805
|
docsUrl: null,
|
|
2803
2806
|
},
|
|
2807
|
+
{
|
|
2808
|
+
name: "Accordion",
|
|
2809
|
+
description:
|
|
2810
|
+
'Themed collapsible sections. `<Accordion defaultValue="shipping"><AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem><AccordionItem value="returns" title="Returns">…</AccordionItem></Accordion>`. THE way to build a section that expands and collapses (an FAQ, a settings group, a record\'s details, a group whose members show on demand): the surface takes the app\'s card look (Theme Settings > Cards: background, border, corner radius) and the headers and dividers its list look (Theme Settings > Lists: row padding, row height, divider), so every collapsible section in the app matches. Each header has a chevron, a 44px touch target, works from the keyboard and tells assistive technology whether its section is open, so never hand-roll a collapsible from `Pressable` + a chevron + a `useState`. By default one section is open at a time and pressing the open one closes it; `multiple` lets several stay open. Props: `multiple`, `value` (the open key, or an array of keys when `multiple`; makes it controlled), `defaultValue` (the same shape, uncontrolled), `onChange(next)` (the open key(s) after a press, in the same shape), `accessibilityLabel`, `style`, `testID`. Its children are `<AccordionItem>`s.',
|
|
2811
|
+
rnComponent: null,
|
|
2812
|
+
docsUrl: null,
|
|
2813
|
+
},
|
|
2814
|
+
{
|
|
2815
|
+
name: "AccordionItem",
|
|
2816
|
+
description:
|
|
2817
|
+
'One section of an `<Accordion>`. `<AccordionItem value={group.id} title={group.name} subtitle={`${group.count} members`} trailing={<Button title="Delete" variant="danger" onPress={remove} />}>…</AccordionItem>`. Props: `value` (the key `value` / `defaultValue` / `onChange` name it by — give one whenever you name sections; an item may sit inside your own component or a fragment), `title`, `subtitle` (the subtitle / caption text tiers), `trailing` (a node beside the header and OUTSIDE its toggle, so a button there runs its own action without opening the section), `disabled`, `divider` (default true; the surface edge hides the rule under the last section), `children` (the body: it mounts only while the section is open, so fetch its data inside it; an `<AccordionItem>` placed in a body is its own section, never part of the outer accordion), `accessibilityLabel` (defaults to the string `title` and `subtitle`), `style`, `testID` (also tags the header `<testID>-header` and the open body `<testID>-panel`). On its own, outside an `<Accordion>`, it is an accordion of one.',
|
|
2818
|
+
rnComponent: null,
|
|
2819
|
+
docsUrl: null,
|
|
2820
|
+
},
|
|
2804
2821
|
{
|
|
2805
2822
|
name: "Tabs",
|
|
2806
2823
|
description:
|
|
@@ -3043,6 +3060,13 @@ const MANIFEST_SCHEMA = {
|
|
|
3043
3060
|
"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.",
|
|
3044
3061
|
default: false,
|
|
3045
3062
|
},
|
|
3063
|
+
badgeCounter: {
|
|
3064
|
+
type: "boolean",
|
|
3065
|
+
required: false,
|
|
3066
|
+
description:
|
|
3067
|
+
"Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
|
|
3068
|
+
default: false,
|
|
3069
|
+
},
|
|
3046
3070
|
events: {
|
|
3047
3071
|
type: "object[]",
|
|
3048
3072
|
required: true,
|
|
@@ -5301,7 +5325,17 @@ const CONTRACT = deepFreeze({
|
|
|
5301
5325
|
// flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
|
|
5302
5326
|
// New `useBundledAsset(fileName)` resolves one through the new
|
|
5303
5327
|
// `widget.assets` map; linter rule `bundled-asset-missing` (error).
|
|
5304
|
-
|
|
5328
|
+
// 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
|
|
5329
|
+
// the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
|
|
5330
|
+
// and the host runs it while the widget's page is closed, so the page
|
|
5331
|
+
// badge stays live; the host gives a counter a read-only context.
|
|
5332
|
+
// `usePageBadge(null)` now means "not known yet" and keeps the current
|
|
5333
|
+
// badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
|
|
5334
|
+
// (error) and `badge-counter-undeclared` (warning).
|
|
5335
|
+
// 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
|
|
5336
|
+
// collapsible sections drawn from the `card` and `list` scopes: single
|
|
5337
|
+
// or multiple open, controlled or uncontrolled, `aria-expanded` headers.
|
|
5338
|
+
version: "1.161.0",
|
|
5305
5339
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5306
5340
|
hooks: HOOKS,
|
|
5307
5341
|
primitives: PRIMITIVES,
|
|
@@ -5327,6 +5361,8 @@ const CONTRACT = deepFreeze({
|
|
|
5327
5361
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
5328
5362
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
5329
5363
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
5364
|
+
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
|
5365
|
+
badgeCounterExport: "useBadgeCount",
|
|
5330
5366
|
bannedApis: BANNED_APIS,
|
|
5331
5367
|
vettedImports: VETTED_IMPORTS,
|
|
5332
5368
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|