@colixsystems/widget-sdk 0.162.0 → 0.164.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 +34 -1
- package/dist/button-style.js +60 -0
- package/dist/button-view.js +197 -0
- package/dist/button.js +10 -0
- package/dist/button.native.js +10 -0
- package/dist/card-style.js +80 -0
- package/dist/card-view.js +174 -0
- package/dist/card.js +8 -0
- package/dist/card.native.js +8 -0
- package/dist/contract.cjs +72 -1
- package/dist/contract.js +72 -1
- package/dist/icon.js +7 -3
- package/dist/index.d.ts +112 -0
- package/dist/index.js +30 -0
- package/dist/index.native.js +30 -0
- package/dist/tabs-tokens.js +75 -0
- package/dist/tabs-view.js +169 -0
- package/dist/tabs.js +7 -0
- package/dist/tabs.native.js +7 -0
- package/dist/text-field-tokens.js +54 -0
- package/dist/text-field-view.js +79 -0
- package/dist/text-field.js +7 -0
- package/dist/text-field.native.js +7 -0
- package/dist/theme-components.cjs +5 -0
- package/dist/theme-components.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,38 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
73
|
|
|
74
74
|
## Status
|
|
75
75
|
|
|
76
|
-
`v0.
|
|
76
|
+
`v0.164.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**.
|
|
77
|
+
|
|
78
|
+
### What's new in 0.164.0 (contract 1.127.0)
|
|
79
|
+
|
|
80
|
+
**`Card`, `Button` and `TextField` — the built-in widgets' own surfaces, now yours.** The built-in widgets drew their cards, buttons and text fields from shared code only they could reach, so a custom widget had to re-invent each one and never quite matched. The same implementations are now SDK components, and the built-ins render through them:
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
<Card>
|
|
84
|
+
<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />
|
|
85
|
+
<Button title="Save" onPress={save} />
|
|
86
|
+
</Card>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- **`<Card>`** takes the app's card look (Theme Settings > Cards) with no props. Override one card with `background`, `borderColor`, `radius`, `padding`, `shadow` or `gradient`, or pass `style={props.style}` when your widget declares the `card*` style fields. `as={Pressable}` makes the whole card pressable.
|
|
90
|
+
- **`<Button>`** follows Theme Settings > Buttons (and Destructive / Focus ring). `variant` is `primary` | `secondary` | `outlined` | `danger`; `icon` / `iconPosition` add a lucide glyph; omit `onPress` for a visual inside something pressable; `fields` overrides one button's surface.
|
|
91
|
+
- **`<TextField>`** is a `TextInput` in the app's input look (Theme Settings > Muted, which gains a **`height`** token); `multiline` grows to a ceiling, then scrolls.
|
|
92
|
+
|
|
93
|
+
The card and button resolvers (`cardOverlay`, `shadowStyle`, `marginStyle`, `buttonSurfaceOverlay`, …) are exported too. Additive: the built-in widgets render exactly as before.
|
|
94
|
+
|
|
95
|
+
### What's new in 0.163.0 (contract 1.126.0)
|
|
96
|
+
|
|
97
|
+
**A themed tab strip: `Tabs`, and the `tabs` theme scope.** `<Tabs items={[{ key, label, icon? }]} value={tab} onChange={setTab} />` draws a strip whose look — a segmented control, pills, or an underline — and whose colours and corners come from **Theme Settings > Tabs** (`themeConfig.components.tabs`). It draws the strip only; render the selected panel yourself. The page Tabs container now renders through the same component on the web Player and in the exported app, so a page's tabs and a widget's tabs match, and the two hosts no longer draw different strips. Unthemed, the strip is the segmented control the exported app always drew.
|
|
98
|
+
|
|
99
|
+
```jsx
|
|
100
|
+
const [tab, setTab] = useState("open");
|
|
101
|
+
<Tabs items={[{ key: "open", label: "Open" }, { key: "done", label: "Done", icon: "check" }]} value={tab} onChange={setTab} />
|
|
102
|
+
{tab === "open" ? <OpenOrders /> : <DoneOrders />}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Pass `variant` ("segmented" | "pill" | "underline"), `activeColor`, `activeTextColor`, `textColor` or `radius` to override one strip, or declare the style fields `tabsStyle`, `tabsActiveColor`, `tabsActiveTextColor`, `tabsTextColor` and `tabsRadius` so an author can.
|
|
106
|
+
|
|
107
|
+
The component-token vocabulary gains an **`enum`** type (`{ type: "enum", values }`), first used by `tabs.style`; `CONTRACT.themeComponentTabStyles` lists the styles.
|
|
77
108
|
|
|
78
109
|
### What's new in 0.162.0 (contract 1.125.0)
|
|
79
110
|
|
|
@@ -1787,6 +1818,8 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
1787
1818
|
- `parseMarkdown`, `markdownToPlainText`, `parseMarkdownImage`, `formatMarkdownImage`, `stripHtmlToMarkdown`, `MARKDOWN_IMAGE_SIZES` — the same grammar as pure helpers, for content you inspect rather than render: the block list, a marker-free projection (list previews, search, a11y labels), the one-line image form read and written, a legacy-HTML row normalised to markdown on read, and the closed size list (`small` | `medium` | `large` | `full`).
|
|
1788
1819
|
- `isSafeMarkdownImageSrc`, `isHttpImageSrc`, `normaliseMarkdownImageSize`, `readMarkdownAlt` — the image half of that grammar, for a widget rendering images itself via `renderImage`. `isSafeMarkdownImageSrc` is the same allowlist the parser applies (an `http(s)` URL or a filestore id; a `javascript:` scheme is refused), so the widget and the parser cannot disagree about which src is renderable.
|
|
1789
1820
|
- `List`, `ListRow`, `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; `<EmptyState icon title message>` is the "nothing here" panel. Never hand-roll a row from `View` + `Pressable`.
|
|
1821
|
+
- `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`.
|
|
1822
|
+
- `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.
|
|
1790
1823
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
1791
1824
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
1792
1825
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The button-surface resolvers behind `<Button>`: one source for the SDK and
|
|
2
|
+
// the built-in widgets (frontend `_shared/buttonStyle.js` re-exports these).
|
|
3
|
+
// Platform-free, so both hosts resolve a button identically.
|
|
4
|
+
|
|
5
|
+
export const BUTTON_PADDING_MAX = 64;
|
|
6
|
+
export const BUTTON_BORDER_WIDTH_MAX = 8;
|
|
7
|
+
|
|
8
|
+
// `stretch` is absent on purpose: a button never grows to its container.
|
|
9
|
+
export const SELF_ALIGNMENTS = Object.freeze({
|
|
10
|
+
start: "flex-start",
|
|
11
|
+
center: "center",
|
|
12
|
+
end: "flex-end",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function read(style, prefix, key) {
|
|
16
|
+
const name = prefix
|
|
17
|
+
? `${prefix}${key[0].toUpperCase()}${key.slice(1)}`
|
|
18
|
+
: key;
|
|
19
|
+
return style ? style[name] : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function px(value, max) {
|
|
23
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
24
|
+
? Math.min(value, max)
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The container overlay for the set button-surface fields: padding, border
|
|
30
|
+
* colour and width. A width of 0 removes a variant's border, so zero survives.
|
|
31
|
+
* `prefix` reads a re-keyed field set (`buttonPaddingX`).
|
|
32
|
+
*/
|
|
33
|
+
export function buttonSurfaceOverlay(style, prefix = "") {
|
|
34
|
+
const paddingX = px(read(style, prefix, "paddingX"), BUTTON_PADDING_MAX);
|
|
35
|
+
const paddingY = px(read(style, prefix, "paddingY"), BUTTON_PADDING_MAX);
|
|
36
|
+
const borderColor = read(style, prefix, "borderColor");
|
|
37
|
+
const borderWidth = px(
|
|
38
|
+
read(style, prefix, "borderWidth"),
|
|
39
|
+
BUTTON_BORDER_WIDTH_MAX,
|
|
40
|
+
);
|
|
41
|
+
const overlay = {
|
|
42
|
+
...(paddingX !== undefined ? { paddingHorizontal: paddingX } : null),
|
|
43
|
+
...(paddingY !== undefined ? { paddingVertical: paddingY } : null),
|
|
44
|
+
...(borderColor ? { borderWidth: 1, borderColor } : null),
|
|
45
|
+
...(borderWidth !== undefined ? { borderWidth } : null),
|
|
46
|
+
};
|
|
47
|
+
return Object.keys(overlay).length > 0 ? overlay : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The label overlay for the button-surface fields that live on the text. */
|
|
51
|
+
export function buttonSurfaceTextOverlay(style, prefix = "") {
|
|
52
|
+
const fontWeight = read(style, prefix, "fontWeight");
|
|
53
|
+
return fontWeight ? { fontWeight: String(fontWeight) } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The author's alignment, or null to keep the widget's own default. */
|
|
57
|
+
export function selfAlignStyle(style) {
|
|
58
|
+
const alignSelf = SELF_ALIGNMENTS[style && style.align];
|
|
59
|
+
return alignSelf ? { alignSelf } : null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The ONE button surface, behind the `<Button>` SDK primitive and the built-in
|
|
2
|
+
// Button widget. The bindings (`button.js` / `button.native.js`) differ only in
|
|
3
|
+
// their primitives and in which `<Gradient>` paints a gradient fill.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { useHostTheme } from "./hooks.js";
|
|
7
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
|
+
import { lucideGlyph } from "./icon.js";
|
|
9
|
+
import { applyThemeComponentStyle } from "./theme-components.js";
|
|
10
|
+
import { cornerRadiusOverlay, shadowStyle, gradientProps } from "./card-style.js";
|
|
11
|
+
import { buttonSurfaceOverlay, buttonSurfaceTextOverlay } from "./button-style.js";
|
|
12
|
+
|
|
13
|
+
export const BUTTON_VARIANTS = Object.freeze(["primary", "secondary", "outlined", "danger"]);
|
|
14
|
+
|
|
15
|
+
// The theme's button, destructive and ring scopes reach a custom widget's
|
|
16
|
+
// button exactly as the host folds them onto the built-in one.
|
|
17
|
+
const THEMED_AS = "appstudio.button";
|
|
18
|
+
|
|
19
|
+
function variantPaint(variant, colors) {
|
|
20
|
+
if (variant === "secondary") {
|
|
21
|
+
return {
|
|
22
|
+
container: { backgroundColor: colors.secondary, borderWidth: 1, borderColor: colors.border },
|
|
23
|
+
ink: colors.onSecondary,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (variant === "danger") {
|
|
27
|
+
return {
|
|
28
|
+
container: { backgroundColor: colors.danger, borderWidth: 1, borderColor: colors.danger },
|
|
29
|
+
ink: colors.onPrimary,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (variant === "outlined") {
|
|
33
|
+
return {
|
|
34
|
+
container: { backgroundColor: "transparent", borderWidth: 1, borderColor: colors.primary },
|
|
35
|
+
ink: colors.primary,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { container: { backgroundColor: colors.primary }, ink: colors.onPrimary };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Precedence, weakest first: variant, danger scope, ring, then the fields.
|
|
42
|
+
function containerLayers(variant, f, colors) {
|
|
43
|
+
const paint = variantPaint(variant, colors);
|
|
44
|
+
let ink = paint.ink;
|
|
45
|
+
const layers = [paint.container];
|
|
46
|
+
if (variant === "danger") {
|
|
47
|
+
if (f.dangerBackground) layers.push({ backgroundColor: f.dangerBackground });
|
|
48
|
+
if (f.dangerBorderColor) layers.push({ borderWidth: 1, borderColor: f.dangerBorderColor });
|
|
49
|
+
if (f.dangerTextColor) ink = f.dangerTextColor;
|
|
50
|
+
}
|
|
51
|
+
if (f.ringColor) {
|
|
52
|
+
layers.push({
|
|
53
|
+
borderWidth: typeof f.ringWidth === "number" ? f.ringWidth : 2,
|
|
54
|
+
borderColor: f.ringColor,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (f.background) layers.push({ backgroundColor: f.background });
|
|
58
|
+
layers.push(buttonSurfaceOverlay(f));
|
|
59
|
+
layers.push(cornerRadiusOverlay(f.radius));
|
|
60
|
+
if (f.textColor) ink = f.textColor;
|
|
61
|
+
if (f.shadow) layers.push(shadowStyle(f.shadow));
|
|
62
|
+
return { layers, ink };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// An unknown name draws nothing: a button keeps its label, not a placeholder.
|
|
66
|
+
function iconNode(name, size, color) {
|
|
67
|
+
if (typeof name !== "string" || name.trim().length === 0) return null;
|
|
68
|
+
const Glyph = lucideGlyph(name);
|
|
69
|
+
return Glyph ? React.createElement(Glyph, { size, color }) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A positioned wrapper keeps a bare web <svg> above the absolute gradient fill.
|
|
73
|
+
function wrapGlyph(View, glyph, key) {
|
|
74
|
+
return React.createElement(View, { key }, glyph);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bind the shared button to one platform's primitives.
|
|
79
|
+
*
|
|
80
|
+
* @param {{View: any, Text: any, Pressable: any, StyleSheet: any, Gradient: any}} rn
|
|
81
|
+
*/
|
|
82
|
+
export function makeButton(rn) {
|
|
83
|
+
const { View, Text, Pressable, StyleSheet, Gradient } = rn;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} props.title — the label.
|
|
87
|
+
* @param {string} [props.variant] — primary | secondary | outlined | danger.
|
|
88
|
+
* @param {object} [props.fields] — per-instance button-surface fields
|
|
89
|
+
* (background, textColor, borderColor, borderWidth, paddingX, paddingY,
|
|
90
|
+
* radius, fontWeight, fontSize, shadow, gradient, danger*, ring*).
|
|
91
|
+
* @param {Function} [props.onPress] — makes it pressable; omit for a visual.
|
|
92
|
+
* @param {object|Array} [props.style] — extra container style, merged last.
|
|
93
|
+
* @param {object|Array} [props.labelStyle] — extra label style.
|
|
94
|
+
* @param {boolean} [props.hostThemed] — `fields` already carry the theme
|
|
95
|
+
* (a built-in the host folded); skip the second fold, which would add the
|
|
96
|
+
* Button widget's own scopes to a form submit or a Link action.
|
|
97
|
+
*/
|
|
98
|
+
function Button({
|
|
99
|
+
title,
|
|
100
|
+
variant = "primary",
|
|
101
|
+
icon,
|
|
102
|
+
iconPosition = "leading",
|
|
103
|
+
fields,
|
|
104
|
+
onPress,
|
|
105
|
+
disabled = false,
|
|
106
|
+
style,
|
|
107
|
+
labelStyle,
|
|
108
|
+
hostThemed = false,
|
|
109
|
+
accessibilityLabel,
|
|
110
|
+
testID,
|
|
111
|
+
}) {
|
|
112
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
113
|
+
const { colors, spacing, radii, typography } = theme;
|
|
114
|
+
const f = hostThemed
|
|
115
|
+
? fields || {}
|
|
116
|
+
: applyThemeComponentStyle(THEMED_AS, theme, { style: fields }).style || {};
|
|
117
|
+
const resolvedVariant = BUTTON_VARIANTS.includes(variant) ? variant : "primary";
|
|
118
|
+
const { layers, ink } = containerLayers(resolvedVariant, f, colors);
|
|
119
|
+
|
|
120
|
+
const fontSize =
|
|
121
|
+
typeof f.fontSize === "number" && Number.isFinite(f.fontSize)
|
|
122
|
+
? f.fontSize
|
|
123
|
+
: typography.sizes.sm;
|
|
124
|
+
const glyph = iconNode(icon, Math.round(fontSize), ink);
|
|
125
|
+
const gradient = gradientProps(f.gradient);
|
|
126
|
+
|
|
127
|
+
const container = [
|
|
128
|
+
{
|
|
129
|
+
paddingHorizontal: spacing.md,
|
|
130
|
+
paddingVertical: spacing.sm,
|
|
131
|
+
borderRadius: radii.sm,
|
|
132
|
+
minHeight: 44,
|
|
133
|
+
minWidth: 64,
|
|
134
|
+
alignItems: "center",
|
|
135
|
+
justifyContent: "center",
|
|
136
|
+
alignSelf: "flex-start",
|
|
137
|
+
},
|
|
138
|
+
...layers,
|
|
139
|
+
glyph ? { flexDirection: "row", gap: spacing.xs } : null,
|
|
140
|
+
// The gradient IS the fill, so it drops every flat background.
|
|
141
|
+
gradient ? { backgroundColor: "transparent" } : null,
|
|
142
|
+
disabled ? { opacity: 0.5 } : null,
|
|
143
|
+
style,
|
|
144
|
+
];
|
|
145
|
+
const label = React.createElement(
|
|
146
|
+
Text,
|
|
147
|
+
{
|
|
148
|
+
style: [
|
|
149
|
+
{ fontSize: typography.sizes.sm, fontWeight: "600", fontFamily: typography.fontFamily },
|
|
150
|
+
{ color: ink },
|
|
151
|
+
typeof f.fontSize === "number" ? { fontSize: f.fontSize } : null,
|
|
152
|
+
labelStyle,
|
|
153
|
+
buttonSurfaceTextOverlay(f),
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
title,
|
|
157
|
+
);
|
|
158
|
+
const children = [
|
|
159
|
+
gradient
|
|
160
|
+
? React.createElement(Gradient, {
|
|
161
|
+
key: "fill",
|
|
162
|
+
colors: gradient.colors,
|
|
163
|
+
angle: gradient.angle,
|
|
164
|
+
// Rounded rather than clipped: `overflow: hidden` drops an iOS shadow.
|
|
165
|
+
style: [
|
|
166
|
+
StyleSheet.absoluteFill,
|
|
167
|
+
{ borderRadius: radii.sm, pointerEvents: "none" },
|
|
168
|
+
cornerRadiusOverlay(f.radius),
|
|
169
|
+
],
|
|
170
|
+
})
|
|
171
|
+
: null,
|
|
172
|
+
glyph && iconPosition !== "trailing" ? wrapGlyph(View, glyph, "lead") : null,
|
|
173
|
+
React.cloneElement(label, { key: "label" }),
|
|
174
|
+
glyph && iconPosition === "trailing" ? wrapGlyph(View, glyph, "trail") : null,
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
if (typeof onPress !== "function") {
|
|
178
|
+
return React.createElement(View, { style: container, testID, accessibilityLabel }, children);
|
|
179
|
+
}
|
|
180
|
+
return React.createElement(
|
|
181
|
+
Pressable,
|
|
182
|
+
{
|
|
183
|
+
onPress,
|
|
184
|
+
disabled,
|
|
185
|
+
accessibilityRole: "button",
|
|
186
|
+
accessibilityLabel: accessibilityLabel || (typeof title === "string" ? title : undefined),
|
|
187
|
+
"aria-disabled": disabled,
|
|
188
|
+
testID,
|
|
189
|
+
style: ({ pressed }) => [...container, pressed && !disabled ? { opacity: 0.85 } : null],
|
|
190
|
+
},
|
|
191
|
+
children,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
Button.displayName = "Button";
|
|
196
|
+
return Button;
|
|
197
|
+
}
|
package/dist/button.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `<Button>` (web). Binds ./button-view.js to react-native-web and the CSS
|
|
2
|
+
// gradient; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { Gradient } from "./gradient.js";
|
|
6
|
+
import { makeButton, BUTTON_VARIANTS } from "./button-view.js";
|
|
7
|
+
|
|
8
|
+
export { BUTTON_VARIANTS };
|
|
9
|
+
|
|
10
|
+
export const Button = makeButton({ ...ReactNative, Gradient });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `<Button>` (native). Binds ./button-view.js to React Native and the
|
|
2
|
+
// expo-linear-gradient `<Gradient>`; only the imports differ from ./button.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable, StyleSheet } from "react-native";
|
|
5
|
+
import { Gradient } from "./gradient.native.js";
|
|
6
|
+
import { makeButton, BUTTON_VARIANTS } from "./button-view.js";
|
|
7
|
+
|
|
8
|
+
export { BUTTON_VARIANTS };
|
|
9
|
+
|
|
10
|
+
export const Button = makeButton({ View, Text, Pressable, StyleSheet, Gradient });
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The card-surface resolvers behind `<Card>`: one source for the SDK, the
|
|
2
|
+
// built-in widgets (frontend `_shared/cardStyle.js` re-exports these) and the
|
|
3
|
+
// exported app. Platform-free, so both hosts resolve a card identically.
|
|
4
|
+
//
|
|
5
|
+
// Every resolver emits ONLY the fields that are set, so a widget's own base
|
|
6
|
+
// style keeps whatever the author left alone.
|
|
7
|
+
|
|
8
|
+
import { CONTRACT, normaliseComponentGradient } from "./contract.js";
|
|
9
|
+
import { normaliseCornerRadius, cornerRadiusStyle } from "./corner-radius.js";
|
|
10
|
+
import { normaliseSpacing, spacingStyle, isZeroSpacing } from "./spacing.js";
|
|
11
|
+
|
|
12
|
+
export const SHADOW_PRESETS = CONTRACT.themeTokens.elevation;
|
|
13
|
+
|
|
14
|
+
/** An elevation level as an RN style object; unknown / unset is none. */
|
|
15
|
+
export function shadowStyle(shadow) {
|
|
16
|
+
return SHADOW_PRESETS[shadow] || SHADOW_PRESETS.none;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const SPACING_MAX_PX = 64;
|
|
20
|
+
export const CARD_RADIUS_MAX_PX = 48;
|
|
21
|
+
export const FRAME_RADIUS_MAX_PX = 64;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The widget's outer margin. A full-bleed widget keeps only its vertical
|
|
25
|
+
* margin, so a side margin cannot pull the band back off the screen edge.
|
|
26
|
+
*/
|
|
27
|
+
export function marginStyle(style) {
|
|
28
|
+
const m = normaliseSpacing(style && style.margin, 0, SPACING_MAX_PX);
|
|
29
|
+
if (isZeroSpacing(m)) return null;
|
|
30
|
+
if (style.fullBleed) return { marginTop: m.top, marginBottom: m.bottom };
|
|
31
|
+
return spacingStyle(m, "margin");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A per-side padding/margin value; unset stays unset, an explicit 0 applies. */
|
|
35
|
+
export function spacingOverlay(value, property, max = SPACING_MAX_PX) {
|
|
36
|
+
if (value === undefined || value === null || value === "") return null;
|
|
37
|
+
return spacingStyle(normaliseSpacing(value, 0, max), property);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A per-corner radius value; unset stays unset. */
|
|
41
|
+
export function cornerRadiusOverlay(value, max = CARD_RADIUS_MAX_PX) {
|
|
42
|
+
if (value === undefined || value === null || value === "") return null;
|
|
43
|
+
return cornerRadiusStyle(normaliseCornerRadius(value, 0, max));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The card surface — every `card*` field plus the shadow, and no margin. */
|
|
47
|
+
export function cardSurfaceStyle(style) {
|
|
48
|
+
const s = style || {};
|
|
49
|
+
return {
|
|
50
|
+
...(s.cardBackground ? { backgroundColor: s.cardBackground } : null),
|
|
51
|
+
...(s.cardBorderColor
|
|
52
|
+
? { borderColor: s.cardBorderColor, borderWidth: 1 }
|
|
53
|
+
: null),
|
|
54
|
+
...cornerRadiusOverlay(s.cardRadius, CARD_RADIUS_MAX_PX),
|
|
55
|
+
...spacingOverlay(s.cardPadding, "padding"),
|
|
56
|
+
...shadowStyle(s.shadow),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The card surface plus the widget's outer margin. */
|
|
61
|
+
export function cardOverlay(style) {
|
|
62
|
+
return { ...cardSurfaceStyle(style), ...marginStyle(style || {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A gradient value as `<Gradient>` props, or null when unpaintable. A
|
|
67
|
+
* per-instance value runs through the same normaliser the theme uses, so the
|
|
68
|
+
* two routes agree on which values paint.
|
|
69
|
+
*/
|
|
70
|
+
export function gradientProps(value) {
|
|
71
|
+
const gradient = normaliseComponentGradient(value);
|
|
72
|
+
return gradient
|
|
73
|
+
? { colors: [gradient.from, gradient.to], angle: gradient.angle }
|
|
74
|
+
: null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The card-surface gradient (`cardGradient`). */
|
|
78
|
+
export function cardGradientProps(style) {
|
|
79
|
+
return gradientProps(style && style.cardGradient);
|
|
80
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The ONE card surface, behind the `<Card>` SDK primitive and every built-in
|
|
2
|
+
// widget that paints a card. The bindings (`card.js` / `card.native.js`) differ
|
|
3
|
+
// only in their primitives and in which `<Gradient>` paints a gradient fill.
|
|
4
|
+
//
|
|
5
|
+
// A gradient is a COMPONENT on both hosts, never a style value, so the element
|
|
6
|
+
// swap lives here once rather than in each widget.
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { useHostTheme } from "./hooks.js";
|
|
10
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
11
|
+
import { CONTRACT } from "./contract.js";
|
|
12
|
+
import { cardOverlay, cardSurfaceStyle, cardGradientProps } from "./card-style.js";
|
|
13
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
14
|
+
|
|
15
|
+
// A wrapped scroller stays transparent and fits inside the gradient.
|
|
16
|
+
const SCROLLER_IN_GRADIENT = { backgroundColor: "transparent", flexShrink: 1 };
|
|
17
|
+
|
|
18
|
+
// A pressable inner container must still cover the whole card.
|
|
19
|
+
const FILL_IN_GRADIENT = {
|
|
20
|
+
backgroundColor: "transparent",
|
|
21
|
+
flexGrow: 1,
|
|
22
|
+
alignSelf: "stretch",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const CLIP_TO_RADIUS = { overflow: "hidden" };
|
|
26
|
+
|
|
27
|
+
const CARD_FIELDS = CONTRACT.themeComponents.card.universalFields;
|
|
28
|
+
|
|
29
|
+
// The convenience props, each named for the `card*` field it sets.
|
|
30
|
+
const PROP_FIELDS = Object.freeze({
|
|
31
|
+
background: CARD_FIELDS.background,
|
|
32
|
+
borderColor: CARD_FIELDS.borderColor,
|
|
33
|
+
radius: CARD_FIELDS.radius,
|
|
34
|
+
padding: CARD_FIELDS.padding,
|
|
35
|
+
gradient: CARD_FIELDS.gradient,
|
|
36
|
+
shadow: "shadow",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// `Pressable` hands its style `{ pressed }`, so a callable must stay callable.
|
|
40
|
+
function composeStyle(base, extra) {
|
|
41
|
+
if (extra === undefined || extra === null) return base;
|
|
42
|
+
if (typeof extra === "function") return (state) => [...base, extra(state)];
|
|
43
|
+
return [...base, extra];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The theme's `card` scope, as the `card*` fields a host would have folded in.
|
|
47
|
+
function themedFields(theme) {
|
|
48
|
+
const scope = normaliseThemeComponents(theme.components).card || {};
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [token, field] of Object.entries(CARD_FIELDS)) {
|
|
51
|
+
if (scope[token] !== undefined) out[field] = scope[token];
|
|
52
|
+
}
|
|
53
|
+
if (scope.shadow !== undefined) out.shadow = scope.shadow;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function themedBase(theme) {
|
|
58
|
+
const { colors, radii, spacing } = theme;
|
|
59
|
+
return {
|
|
60
|
+
backgroundColor: colors.surface,
|
|
61
|
+
borderColor: colors.border,
|
|
62
|
+
borderWidth: 1,
|
|
63
|
+
borderRadius: radii.md,
|
|
64
|
+
padding: spacing.md,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bind the shared card to one platform's primitives.
|
|
70
|
+
*
|
|
71
|
+
* @param {{View: any, Gradient: any}} rn
|
|
72
|
+
*/
|
|
73
|
+
export function makeCard(rn) {
|
|
74
|
+
const { View, Gradient } = rn;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {object} [props.style] — `card*` fields, `shadow`, `margin`
|
|
78
|
+
* (a widget's per-instance style, theme tokens already folded in).
|
|
79
|
+
* @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.
|
|
81
|
+
* @param {React.ElementType} [props.as] — container; anything but `View`
|
|
82
|
+
* renders INSIDE the gradient, never as it. Other props land on it.
|
|
83
|
+
* @param {boolean} [props.fillInGradient] — the inner container stretches to
|
|
84
|
+
* the gradient instead of shrink-wrapping (a pressable, not a scroller).
|
|
85
|
+
* @param {object|Array|Function} [props.containerStyle] — style for the
|
|
86
|
+
* element receiving the other props, so a Pressable's feedback lands.
|
|
87
|
+
* @param {boolean} [props.withoutMargin] — leave the margin off this surface
|
|
88
|
+
* for a widget that rides it on an outer root.
|
|
89
|
+
*/
|
|
90
|
+
function Card(props) {
|
|
91
|
+
const {
|
|
92
|
+
style,
|
|
93
|
+
baseStyle,
|
|
94
|
+
as: Container = View,
|
|
95
|
+
fillInGradient = false,
|
|
96
|
+
containerStyle,
|
|
97
|
+
withoutMargin = false,
|
|
98
|
+
children,
|
|
99
|
+
...others
|
|
100
|
+
} = props;
|
|
101
|
+
const rest = {};
|
|
102
|
+
for (const [key, value] of Object.entries(others)) {
|
|
103
|
+
if (!(key in PROP_FIELDS)) rest[key] = value;
|
|
104
|
+
}
|
|
105
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
106
|
+
|
|
107
|
+
const explicit = {};
|
|
108
|
+
for (const [prop, field] of Object.entries(PROP_FIELDS)) {
|
|
109
|
+
if (props[prop] !== undefined && props[prop] !== null) explicit[field] = props[prop];
|
|
110
|
+
}
|
|
111
|
+
// A built-in brings its own base and receives the theme through `style`;
|
|
112
|
+
// anything else starts from the theme, so it matches without plumbing.
|
|
113
|
+
const standalone = baseStyle === undefined;
|
|
114
|
+
const fields = {
|
|
115
|
+
...(standalone ? themedFields(theme) : null),
|
|
116
|
+
...style,
|
|
117
|
+
...explicit,
|
|
118
|
+
};
|
|
119
|
+
const base = standalone ? themedBase(theme) : baseStyle;
|
|
120
|
+
|
|
121
|
+
const overlay = withoutMargin ? cardSurfaceStyle(fields) : cardOverlay(fields);
|
|
122
|
+
const gradient = cardGradientProps(fields);
|
|
123
|
+
|
|
124
|
+
if (!gradient) {
|
|
125
|
+
return React.createElement(
|
|
126
|
+
Container,
|
|
127
|
+
{ style: composeStyle([base, overlay], containerStyle), ...rest },
|
|
128
|
+
children,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The gradient IS the background; a flat fill on top would hide it.
|
|
133
|
+
const paint = { ...overlay };
|
|
134
|
+
delete paint.backgroundColor;
|
|
135
|
+
|
|
136
|
+
if (Container === View) {
|
|
137
|
+
return React.createElement(
|
|
138
|
+
Gradient,
|
|
139
|
+
{
|
|
140
|
+
colors: gradient.colors,
|
|
141
|
+
angle: gradient.angle,
|
|
142
|
+
style: composeStyle([base, paint], containerStyle),
|
|
143
|
+
...rest,
|
|
144
|
+
},
|
|
145
|
+
children,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A scroller cannot BE a Gradient, so the wrapper takes the layout and
|
|
150
|
+
// paint; re-applying the base inside would double every padding.
|
|
151
|
+
return React.createElement(
|
|
152
|
+
Gradient,
|
|
153
|
+
{
|
|
154
|
+
colors: gradient.colors,
|
|
155
|
+
angle: gradient.angle,
|
|
156
|
+
style: [base, paint, CLIP_TO_RADIUS],
|
|
157
|
+
},
|
|
158
|
+
React.createElement(
|
|
159
|
+
Container,
|
|
160
|
+
{
|
|
161
|
+
style: composeStyle(
|
|
162
|
+
[fillInGradient ? FILL_IN_GRADIENT : SCROLLER_IN_GRADIENT],
|
|
163
|
+
containerStyle,
|
|
164
|
+
),
|
|
165
|
+
...rest,
|
|
166
|
+
},
|
|
167
|
+
children,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
Card.displayName = "Card";
|
|
173
|
+
return Card;
|
|
174
|
+
}
|
package/dist/card.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// `<Card>` (web). Binds ./card-view.js to react-native-web and the CSS
|
|
2
|
+
// gradient; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { Gradient } from "./gradient.js";
|
|
6
|
+
import { makeCard } from "./card-view.js";
|
|
7
|
+
|
|
8
|
+
export const Card = makeCard({ View: ReactNative.View, Gradient });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// `<Card>` (native). Binds ./card-view.js to React Native and the
|
|
2
|
+
// expo-linear-gradient `<Gradient>`; only the imports differ from ./card.js.
|
|
3
|
+
|
|
4
|
+
import { View } from "react-native";
|
|
5
|
+
import { Gradient } from "./gradient.native.js";
|
|
6
|
+
import { makeCard } from "./card-view.js";
|
|
7
|
+
|
|
8
|
+
export const Card = makeCard({ View, Gradient });
|