@colixsystems/widget-sdk 0.163.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 +19 -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 +30 -1
- package/dist/contract.js +30 -1
- package/dist/icon.js +7 -3
- package/dist/index.d.ts +83 -0
- package/dist/index.js +28 -0
- package/dist/index.native.js +28 -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/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,24 @@ 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.
|
|
77
94
|
|
|
78
95
|
### What's new in 0.163.0 (contract 1.126.0)
|
|
79
96
|
|
|
@@ -1801,6 +1818,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
1801
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`).
|
|
1802
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.
|
|
1803
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`.
|
|
1804
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.
|
|
1805
1823
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
1806
1824
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
@@ -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 });
|
package/dist/contract.cjs
CHANGED
|
@@ -416,6 +416,7 @@ const MUTED_INPUT_FIELDS = Object.freeze({
|
|
|
416
416
|
textColor: "inputTextColor",
|
|
417
417
|
borderColor: "inputBorderColor",
|
|
418
418
|
radius: "inputRadius",
|
|
419
|
+
height: "inputHeight",
|
|
419
420
|
});
|
|
420
421
|
|
|
421
422
|
// The Form widgets' choice-field option list -- the closest thing this
|
|
@@ -576,6 +577,8 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
576
577
|
textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
|
|
577
578
|
borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
|
|
578
579
|
radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
|
|
580
|
+
// The field's minimum height; `<TextField>` reads it directly too.
|
|
581
|
+
height: Object.freeze({ type: "size", min: 32, max: 72 }),
|
|
579
582
|
}),
|
|
580
583
|
targets: Object.freeze({
|
|
581
584
|
"appstudio.form-input": MUTED_INPUT_FIELDS,
|
|
@@ -2260,6 +2263,27 @@ const PRIMITIVES = [
|
|
|
2260
2263
|
rnComponent: null,
|
|
2261
2264
|
docsUrl: null,
|
|
2262
2265
|
},
|
|
2266
|
+
{
|
|
2267
|
+
name: "Card",
|
|
2268
|
+
description:
|
|
2269
|
+
'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
|
|
2270
|
+
rnComponent: null,
|
|
2271
|
+
docsUrl: null,
|
|
2272
|
+
},
|
|
2273
|
+
{
|
|
2274
|
+
name: "Button",
|
|
2275
|
+
description:
|
|
2276
|
+
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
|
|
2277
|
+
rnComponent: null,
|
|
2278
|
+
docsUrl: null,
|
|
2279
|
+
},
|
|
2280
|
+
{
|
|
2281
|
+
name: "TextField",
|
|
2282
|
+
description:
|
|
2283
|
+
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Theme Settings > Muted), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. Use it instead of styling a bare `TextInput`.',
|
|
2284
|
+
rnComponent: null,
|
|
2285
|
+
docsUrl: null,
|
|
2286
|
+
},
|
|
2263
2287
|
];
|
|
2264
2288
|
|
|
2265
2289
|
const CATEGORIES = [
|
|
@@ -4454,7 +4478,12 @@ const CONTRACT = deepFreeze({
|
|
|
4454
4478
|
// used by `tabs.style`. The page Tabs container renders through `Tabs` on
|
|
4455
4479
|
// both hosts. Additive: an unthemed strip is the segmented control the
|
|
4456
4480
|
// export always drew.
|
|
4457
|
-
|
|
4481
|
+
// 1.127.0: additive — the `Card`, `Button` and `TextField` primitives,
|
|
4482
|
+
// lifted from the built-in widgets' shared card surface, button surface
|
|
4483
|
+
// and form field so every widget draws them from one implementation, plus
|
|
4484
|
+
// a `height` token on the `muted` scope. Additive: the built-ins render
|
|
4485
|
+
// exactly as before.
|
|
4486
|
+
version: "1.127.0",
|
|
4458
4487
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4459
4488
|
hooks: HOOKS,
|
|
4460
4489
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -416,6 +416,7 @@ const MUTED_INPUT_FIELDS = Object.freeze({
|
|
|
416
416
|
textColor: "inputTextColor",
|
|
417
417
|
borderColor: "inputBorderColor",
|
|
418
418
|
radius: "inputRadius",
|
|
419
|
+
height: "inputHeight",
|
|
419
420
|
});
|
|
420
421
|
|
|
421
422
|
// The Form widgets' choice-field option list -- the closest thing this
|
|
@@ -576,6 +577,8 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
576
577
|
textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
|
|
577
578
|
borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
|
|
578
579
|
radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
|
|
580
|
+
// The field's minimum height; `<TextField>` reads it directly too.
|
|
581
|
+
height: Object.freeze({ type: "size", min: 32, max: 72 }),
|
|
579
582
|
}),
|
|
580
583
|
targets: Object.freeze({
|
|
581
584
|
"appstudio.form-input": MUTED_INPUT_FIELDS,
|
|
@@ -2260,6 +2263,27 @@ const PRIMITIVES = [
|
|
|
2260
2263
|
rnComponent: null,
|
|
2261
2264
|
docsUrl: null,
|
|
2262
2265
|
},
|
|
2266
|
+
{
|
|
2267
|
+
name: "Card",
|
|
2268
|
+
description:
|
|
2269
|
+
'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
|
|
2270
|
+
rnComponent: null,
|
|
2271
|
+
docsUrl: null,
|
|
2272
|
+
},
|
|
2273
|
+
{
|
|
2274
|
+
name: "Button",
|
|
2275
|
+
description:
|
|
2276
|
+
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
|
|
2277
|
+
rnComponent: null,
|
|
2278
|
+
docsUrl: null,
|
|
2279
|
+
},
|
|
2280
|
+
{
|
|
2281
|
+
name: "TextField",
|
|
2282
|
+
description:
|
|
2283
|
+
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Theme Settings > Muted), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. Use it instead of styling a bare `TextInput`.',
|
|
2284
|
+
rnComponent: null,
|
|
2285
|
+
docsUrl: null,
|
|
2286
|
+
},
|
|
2263
2287
|
];
|
|
2264
2288
|
|
|
2265
2289
|
const CATEGORIES = [
|
|
@@ -4450,7 +4474,12 @@ const CONTRACT = deepFreeze({
|
|
|
4450
4474
|
// used by `tabs.style`. The page Tabs container renders through `Tabs` on
|
|
4451
4475
|
// both hosts. Additive: an unthemed strip is the segmented control the
|
|
4452
4476
|
// export always drew.
|
|
4453
|
-
|
|
4477
|
+
// 1.127.0: additive — the `Card`, `Button` and `TextField` primitives,
|
|
4478
|
+
// lifted from the built-in widgets' shared card surface, button surface
|
|
4479
|
+
// and form field so every widget draws them from one implementation, plus
|
|
4480
|
+
// a `height` token on the `muted` scope. Additive: the built-ins render
|
|
4481
|
+
// exactly as before.
|
|
4482
|
+
version: "1.127.0",
|
|
4454
4483
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4455
4484
|
hooks: HOOKS,
|
|
4456
4485
|
primitives: PRIMITIVES,
|
package/dist/icon.js
CHANGED
|
@@ -18,10 +18,14 @@ import React from "react";
|
|
|
18
18
|
import * as LucideIcons from "lucide-react-native";
|
|
19
19
|
import { normalizeLucideIconName } from "./lucideIconName.js";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/** The lucide component a name resolves to, or null for an unknown name. */
|
|
22
|
+
export function lucideGlyph(name) {
|
|
22
23
|
const resolved = normalizeLucideIconName(name);
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
return (resolved && LucideIcons[resolved]) || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Icon({ name, size, color }) {
|
|
28
|
+
const Component = lucideGlyph(name) || LucideIcons.Square;
|
|
25
29
|
const pixelSize = Number.isFinite(size) && size > 0 ? size : 24;
|
|
26
30
|
return React.createElement(Component, {
|
|
27
31
|
size: pixelSize,
|
package/dist/index.d.ts
CHANGED
|
@@ -2615,6 +2615,89 @@ export const EmptyState: (props: {
|
|
|
2615
2615
|
children?: ReactNode;
|
|
2616
2616
|
}) => any;
|
|
2617
2617
|
|
|
2618
|
+
/**
|
|
2619
|
+
* Themed card surface. With no props it takes the app's card look (Theme
|
|
2620
|
+
* Settings > Cards). Props override one card; `style` takes the `card*` style
|
|
2621
|
+
* fields a widget declares; `as` renders it as another container.
|
|
2622
|
+
*
|
|
2623
|
+
* @example
|
|
2624
|
+
* <Card><Text>…</Text></Card>
|
|
2625
|
+
* <Card as={Pressable} onPress={open} radius={12} shadow="md">…</Card>
|
|
2626
|
+
*/
|
|
2627
|
+
export const Card: (props: {
|
|
2628
|
+
background?: string;
|
|
2629
|
+
borderColor?: string;
|
|
2630
|
+
radius?: number | { topLeft?: number; topRight?: number; bottomRight?: number; bottomLeft?: number };
|
|
2631
|
+
padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
|
|
2632
|
+
shadow?: "none" | "sm" | "md" | "lg";
|
|
2633
|
+
gradient?: { from: string; to: string; angle?: number };
|
|
2634
|
+
/** `card*` style fields, `shadow`, `margin` — a widget's per-instance style. */
|
|
2635
|
+
style?: Record<string, any>;
|
|
2636
|
+
/** The caller's own base style; a card given none starts from the theme. */
|
|
2637
|
+
baseStyle?: any;
|
|
2638
|
+
as?: any;
|
|
2639
|
+
fillInGradient?: boolean;
|
|
2640
|
+
containerStyle?: any;
|
|
2641
|
+
withoutMargin?: boolean;
|
|
2642
|
+
children?: ReactNode;
|
|
2643
|
+
[prop: string]: any;
|
|
2644
|
+
}) => any;
|
|
2645
|
+
|
|
2646
|
+
export type ButtonVariant = "primary" | "secondary" | "outlined" | "danger";
|
|
2647
|
+
export const BUTTON_VARIANTS: readonly ButtonVariant[];
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* Themed button (Theme Settings > Buttons, Destructive, Focus ring). Omit
|
|
2651
|
+
* `onPress` for a purely visual button inside something pressable.
|
|
2652
|
+
*
|
|
2653
|
+
* @example
|
|
2654
|
+
* <Button title="Save" onPress={save} />
|
|
2655
|
+
* <Button title="Delete" variant="danger" icon="trash-2" onPress={remove} />
|
|
2656
|
+
*/
|
|
2657
|
+
export const Button: (props: {
|
|
2658
|
+
title: ReactNode;
|
|
2659
|
+
variant?: ButtonVariant;
|
|
2660
|
+
icon?: string;
|
|
2661
|
+
iconPosition?: "leading" | "trailing";
|
|
2662
|
+
onPress?: () => void;
|
|
2663
|
+
disabled?: boolean;
|
|
2664
|
+
/** Per-button surface fields; unset ones follow the theme. */
|
|
2665
|
+
fields?: {
|
|
2666
|
+
background?: string;
|
|
2667
|
+
textColor?: string;
|
|
2668
|
+
borderColor?: string;
|
|
2669
|
+
borderWidth?: number;
|
|
2670
|
+
paddingX?: number;
|
|
2671
|
+
paddingY?: number;
|
|
2672
|
+
radius?: number;
|
|
2673
|
+
fontWeight?: string;
|
|
2674
|
+
fontSize?: number;
|
|
2675
|
+
shadow?: "none" | "sm" | "md" | "lg";
|
|
2676
|
+
gradient?: { from: string; to: string; angle?: number };
|
|
2677
|
+
};
|
|
2678
|
+
style?: any;
|
|
2679
|
+
labelStyle?: any;
|
|
2680
|
+
accessibilityLabel?: string;
|
|
2681
|
+
testID?: string;
|
|
2682
|
+
}) => any;
|
|
2683
|
+
|
|
2684
|
+
/**
|
|
2685
|
+
* Themed text-entry field (Theme Settings > Muted). Every TextInput prop works.
|
|
2686
|
+
*
|
|
2687
|
+
* @example
|
|
2688
|
+
* <TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />
|
|
2689
|
+
*/
|
|
2690
|
+
export const TextField: (props: {
|
|
2691
|
+
background?: string;
|
|
2692
|
+
textColor?: string;
|
|
2693
|
+
borderColor?: string;
|
|
2694
|
+
radius?: number;
|
|
2695
|
+
height?: number;
|
|
2696
|
+
multiline?: boolean;
|
|
2697
|
+
style?: any;
|
|
2698
|
+
[prop: string]: any;
|
|
2699
|
+
}) => any;
|
|
2700
|
+
|
|
2618
2701
|
export type TabStyle = "segmented" | "pill" | "underline";
|
|
2619
2702
|
|
|
2620
2703
|
/**
|
package/dist/index.js
CHANGED
|
@@ -191,6 +191,34 @@ export {
|
|
|
191
191
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
192
192
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
193
193
|
export { List, ListRow, EmptyState } from "./list.js";
|
|
194
|
+
// The one card surface: the theme's `card` scope, a gradient fill, margin.
|
|
195
|
+
export { Card } from "./card.js";
|
|
196
|
+
export {
|
|
197
|
+
SHADOW_PRESETS,
|
|
198
|
+
shadowStyle,
|
|
199
|
+
SPACING_MAX_PX,
|
|
200
|
+
CARD_RADIUS_MAX_PX,
|
|
201
|
+
FRAME_RADIUS_MAX_PX,
|
|
202
|
+
marginStyle,
|
|
203
|
+
spacingOverlay,
|
|
204
|
+
cornerRadiusOverlay,
|
|
205
|
+
cardSurfaceStyle,
|
|
206
|
+
cardOverlay,
|
|
207
|
+
gradientProps,
|
|
208
|
+
cardGradientProps,
|
|
209
|
+
} from "./card-style.js";
|
|
210
|
+
// The themed button; follows the theme's button, destructive and ring scopes.
|
|
211
|
+
export { Button, BUTTON_VARIANTS } from "./button.js";
|
|
212
|
+
export {
|
|
213
|
+
BUTTON_PADDING_MAX,
|
|
214
|
+
BUTTON_BORDER_WIDTH_MAX,
|
|
215
|
+
SELF_ALIGNMENTS,
|
|
216
|
+
buttonSurfaceOverlay,
|
|
217
|
+
buttonSurfaceTextOverlay,
|
|
218
|
+
selfAlignStyle,
|
|
219
|
+
} from "./button-style.js";
|
|
220
|
+
// The themed text-entry field; follows the theme's `muted` scope.
|
|
221
|
+
export { TextField } from "./text-field.js";
|
|
194
222
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
195
223
|
export { Tabs } from "./tabs.js";
|
|
196
224
|
// The semantic text tiers, shared with the built-in widgets.
|
package/dist/index.native.js
CHANGED
|
@@ -184,6 +184,34 @@ export {
|
|
|
184
184
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
185
185
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
186
186
|
export { List, ListRow, EmptyState } from "./list.native.js";
|
|
187
|
+
// The one card surface: the theme's `card` scope, a gradient fill, margin.
|
|
188
|
+
export { Card } from "./card.native.js";
|
|
189
|
+
export {
|
|
190
|
+
SHADOW_PRESETS,
|
|
191
|
+
shadowStyle,
|
|
192
|
+
SPACING_MAX_PX,
|
|
193
|
+
CARD_RADIUS_MAX_PX,
|
|
194
|
+
FRAME_RADIUS_MAX_PX,
|
|
195
|
+
marginStyle,
|
|
196
|
+
spacingOverlay,
|
|
197
|
+
cornerRadiusOverlay,
|
|
198
|
+
cardSurfaceStyle,
|
|
199
|
+
cardOverlay,
|
|
200
|
+
gradientProps,
|
|
201
|
+
cardGradientProps,
|
|
202
|
+
} from "./card-style.js";
|
|
203
|
+
// The themed button; follows the theme's button, destructive and ring scopes.
|
|
204
|
+
export { Button, BUTTON_VARIANTS } from "./button.native.js";
|
|
205
|
+
export {
|
|
206
|
+
BUTTON_PADDING_MAX,
|
|
207
|
+
BUTTON_BORDER_WIDTH_MAX,
|
|
208
|
+
SELF_ALIGNMENTS,
|
|
209
|
+
buttonSurfaceOverlay,
|
|
210
|
+
buttonSurfaceTextOverlay,
|
|
211
|
+
selfAlignStyle,
|
|
212
|
+
} from "./button-style.js";
|
|
213
|
+
// The themed text-entry field; follows the theme's `muted` scope.
|
|
214
|
+
export { TextField } from "./text-field.native.js";
|
|
187
215
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
188
216
|
export { Tabs } from "./tabs.native.js";
|
|
189
217
|
// The semantic text tiers, shared with the built-in widgets.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The values `<TextField>` paints with, resolved from the theme's `muted`
|
|
2
|
+
// component scope — the text-entry surface the built-in forms already theme.
|
|
3
|
+
// Platform-free, so both hosts draw one field from the same numbers.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
|
|
7
|
+
// A field below 44px misses the touch-target floor on both platforms.
|
|
8
|
+
export const TEXT_FIELD_MIN_HEIGHT = 44;
|
|
9
|
+
|
|
10
|
+
// Per-field overrides, named as the `muted` scope's tokens are.
|
|
11
|
+
export const TEXT_FIELD_TOKEN_NAMES = Object.freeze([
|
|
12
|
+
"background",
|
|
13
|
+
"textColor",
|
|
14
|
+
"borderColor",
|
|
15
|
+
"radius",
|
|
16
|
+
"height",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function pick(overrides, scope, name) {
|
|
20
|
+
const own = overrides && overrides[name];
|
|
21
|
+
if (own !== undefined && own !== null && own !== "") return own;
|
|
22
|
+
const themed = scope && scope[name];
|
|
23
|
+
if (themed !== undefined && themed !== null && themed !== "") return themed;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve one text field: explicit override, then the theme's `muted` scope,
|
|
29
|
+
* then the theme tokens. Unthemed, it is the field the built-in forms drew.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
32
|
+
* @param {object} [overrides] — per-field values keyed by TEXT_FIELD_TOKEN_NAMES.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveTextFieldTokens(theme, overrides) {
|
|
35
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
36
|
+
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
37
|
+
const radii = base.radii || DEFAULT_THEME_TOKENS.radii;
|
|
38
|
+
const spacing = base.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
39
|
+
const typography = base.typography || DEFAULT_THEME_TOKENS.typography;
|
|
40
|
+
const scope = base.components && base.components.muted;
|
|
41
|
+
return {
|
|
42
|
+
background: pick(overrides, scope, "background") ?? colors.surfaceMuted,
|
|
43
|
+
textColor: pick(overrides, scope, "textColor") ?? colors.onSurface,
|
|
44
|
+
borderColor: pick(overrides, scope, "borderColor") ?? colors.border,
|
|
45
|
+
radius: pick(overrides, scope, "radius") ?? radii.sm,
|
|
46
|
+
height: pick(overrides, scope, "height") ?? TEXT_FIELD_MIN_HEIGHT,
|
|
47
|
+
paddingHorizontal: spacing.md,
|
|
48
|
+
paddingVertical: spacing.sm,
|
|
49
|
+
fontFamily: typography.fontFamily,
|
|
50
|
+
fontSize: typography.sizes.md,
|
|
51
|
+
placeholderColor: colors.onSurfaceMuted,
|
|
52
|
+
focusColor: colors.primary,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The ONE text-entry field, behind the `<TextField>` SDK primitive and the
|
|
2
|
+
// built-in form fields. The bindings differ only in where `TextInput` comes
|
|
3
|
+
// from, so the hosts cannot drift.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { useHostTheme } from "./hooks.js";
|
|
7
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
|
+
import {
|
|
9
|
+
resolveTextFieldTokens,
|
|
10
|
+
TEXT_FIELD_TOKEN_NAMES,
|
|
11
|
+
} from "./text-field-tokens.js";
|
|
12
|
+
|
|
13
|
+
// A paragraph field grows to a ceiling, then scrolls inside itself.
|
|
14
|
+
const MULTILINE = { minHeight: 88, maxHeight: 220, textAlignVertical: "top" };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bind the shared field to one platform's primitives.
|
|
18
|
+
*
|
|
19
|
+
* @param {{TextInput: any}} rn
|
|
20
|
+
*/
|
|
21
|
+
export function makeTextField(rn) {
|
|
22
|
+
const { TextInput } = rn;
|
|
23
|
+
|
|
24
|
+
const TextField = React.forwardRef(function TextField(props, ref) {
|
|
25
|
+
const {
|
|
26
|
+
style,
|
|
27
|
+
multiline = false,
|
|
28
|
+
placeholderTextColor,
|
|
29
|
+
onFocus,
|
|
30
|
+
onBlur,
|
|
31
|
+
...others
|
|
32
|
+
} = props;
|
|
33
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
34
|
+
// Native draws no focus ring, so the border itself shows focus.
|
|
35
|
+
const [focused, setFocused] = React.useState(false);
|
|
36
|
+
const overrides = {};
|
|
37
|
+
const rest = {};
|
|
38
|
+
for (const [key, value] of Object.entries(others)) {
|
|
39
|
+
if (TEXT_FIELD_TOKEN_NAMES.includes(key)) overrides[key] = value;
|
|
40
|
+
else rest[key] = value;
|
|
41
|
+
}
|
|
42
|
+
const t = resolveTextFieldTokens(theme, overrides);
|
|
43
|
+
return React.createElement(TextInput, {
|
|
44
|
+
ref,
|
|
45
|
+
multiline,
|
|
46
|
+
placeholderTextColor: placeholderTextColor || t.placeholderColor,
|
|
47
|
+
style: [
|
|
48
|
+
{
|
|
49
|
+
borderWidth: 1,
|
|
50
|
+
borderColor: t.borderColor,
|
|
51
|
+
borderRadius: t.radius,
|
|
52
|
+
paddingHorizontal: t.paddingHorizontal,
|
|
53
|
+
paddingVertical: t.paddingVertical,
|
|
54
|
+
minHeight: t.height,
|
|
55
|
+
backgroundColor: t.background,
|
|
56
|
+
color: t.textColor,
|
|
57
|
+
fontFamily: t.fontFamily,
|
|
58
|
+
fontSize: t.fontSize,
|
|
59
|
+
},
|
|
60
|
+
focused ? { borderColor: t.focusColor } : null,
|
|
61
|
+
style,
|
|
62
|
+
// After the caller's style: a paragraph field keeps its 88px floor.
|
|
63
|
+
multiline ? MULTILINE : null,
|
|
64
|
+
],
|
|
65
|
+
onFocus: (e) => {
|
|
66
|
+
setFocused(true);
|
|
67
|
+
if (typeof onFocus === "function") onFocus(e);
|
|
68
|
+
},
|
|
69
|
+
onBlur: (e) => {
|
|
70
|
+
setFocused(false);
|
|
71
|
+
if (typeof onBlur === "function") onBlur(e);
|
|
72
|
+
},
|
|
73
|
+
...rest,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
TextField.displayName = "TextField";
|
|
78
|
+
return TextField;
|
|
79
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<TextField>` (web). Binds ./text-field-view.js to react-native-web; see
|
|
2
|
+
// ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makeTextField } from "./text-field-view.js";
|
|
6
|
+
|
|
7
|
+
export const TextField = makeTextField(ReactNative);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<TextField>` (native). Binds ./text-field-view.js to React Native's own
|
|
2
|
+
// `TextInput`; only the import differs from ./text-field.js.
|
|
3
|
+
|
|
4
|
+
import { TextInput } from "react-native";
|
|
5
|
+
import { makeTextField } from "./text-field-view.js";
|
|
6
|
+
|
|
7
|
+
export const TextField = makeTextField({ TextInput });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.164.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",
|