@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.
@@ -0,0 +1,169 @@
1
+ // The ONE implementation behind the `<Tabs>` SDK primitive. The platform
2
+ // bindings (`tabs.js` / `tabs.native.js`) differ only in where the primitives
3
+ // come from, so the hosts cannot drift.
4
+ //
5
+ // The page Tabs container, the built-in widgets and custom widgets each drew
6
+ // their own tab strip, so an app's tabs looked different on every screen and
7
+ // between web and native. This strip reads the theme's `tabs` scope.
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 { resolveTabsTokens } from "./tabs-tokens.js";
14
+
15
+ const SEGMENT_INSET = 4;
16
+
17
+ function stripStyles(t, theme) {
18
+ const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
19
+ const elevation = theme.elevation || DEFAULT_THEME_TOKENS.elevation;
20
+ if (t.style === "pill") {
21
+ // An idle chip is outlined: a muted fill vanishes on a white card.
22
+ return {
23
+ bar: null,
24
+ content: { gap: spacing.sm },
25
+ item: (active) => ({
26
+ borderRadius: t.radius,
27
+ borderWidth: 1,
28
+ borderColor: active ? t.activeColor : t.rule,
29
+ backgroundColor: active ? t.activeColor : "transparent",
30
+ }),
31
+ };
32
+ }
33
+ if (t.style === "underline") {
34
+ // The rule sits on the content, so the scroller cannot clip the accent.
35
+ return {
36
+ bar: null,
37
+ content: { gap: spacing.lg, borderBottomWidth: 1, borderColor: t.rule },
38
+ item: (active) => ({
39
+ paddingHorizontal: 4,
40
+ borderBottomWidth: 2,
41
+ borderColor: active ? t.activeColor : "transparent",
42
+ }),
43
+ };
44
+ }
45
+ // A concentric outer radius keeps the inset chip's corners parallel. The
46
+ // bar hugs its segments, and the active one is outlined as well as lifted
47
+ // because a shadow alone disappears on a dark theme.
48
+ return {
49
+ bar: {
50
+ backgroundColor: t.barBackground,
51
+ borderRadius: t.radius + 2,
52
+ alignSelf: "flex-start",
53
+ maxWidth: "100%",
54
+ },
55
+ content: { padding: SEGMENT_INSET, gap: SEGMENT_INSET },
56
+ item: (active) => ({
57
+ borderRadius: t.radius,
58
+ borderWidth: 1,
59
+ borderColor: active ? t.rule : "transparent",
60
+ ...(active
61
+ ? { backgroundColor: t.activeSurface, ...(elevation.sm || {}) }
62
+ : null),
63
+ }),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Bind the shared tab strip to one platform's primitives.
69
+ *
70
+ * @param {{View: any, Text: any, Pressable: any, ScrollView: any}} rn
71
+ */
72
+ export function makeTabs(rn) {
73
+ const { Text, Pressable, ScrollView } = rn;
74
+
75
+ function Tabs({
76
+ items = [],
77
+ value,
78
+ onChange,
79
+ variant,
80
+ defaultVariant,
81
+ activeColor,
82
+ activeTextColor,
83
+ textColor,
84
+ radius,
85
+ theme: explicitTheme,
86
+ accessibilityLabel,
87
+ style,
88
+ testID,
89
+ }) {
90
+ const hostTheme = useHostTheme();
91
+ // Layout containers render outside a widget context, so they pass theirs.
92
+ const theme = explicitTheme || hostTheme || DEFAULT_THEME_TOKENS;
93
+ const t = resolveTabsTokens(theme, {
94
+ defaultStyle: defaultVariant,
95
+ style: variant,
96
+ activeColor,
97
+ activeTextColor,
98
+ textColor,
99
+ radius,
100
+ });
101
+ const typography = theme.typography || DEFAULT_THEME_TOKENS.typography;
102
+ const strip = stripStyles(t, theme);
103
+
104
+ return React.createElement(
105
+ ScrollView,
106
+ {
107
+ horizontal: true,
108
+ showsHorizontalScrollIndicator: false,
109
+ // A strip never grows into, or shrinks away from, the panel below.
110
+ style: [{ flexGrow: 0, flexShrink: 0 }, strip.bar, style],
111
+ contentContainerStyle: [{ flexDirection: "row" }, strip.content],
112
+ accessibilityRole: "tablist",
113
+ accessibilityLabel,
114
+ testID,
115
+ },
116
+ ...items.map((item) => {
117
+ const active = item.key === value;
118
+ const ink = active ? t.activeTextColor : t.textColor;
119
+ return React.createElement(
120
+ Pressable,
121
+ {
122
+ key: item.key,
123
+ onPress: () => {
124
+ if (!active && typeof onChange === "function") onChange(item.key);
125
+ },
126
+ disabled: item.disabled,
127
+ // accessibilityRole + aria-*: the pair that announces on both hosts.
128
+ accessibilityRole: "tab",
129
+ "aria-selected": active,
130
+ "aria-disabled": !!item.disabled,
131
+ testID: item.testID,
132
+ style: [
133
+ {
134
+ flexDirection: "row",
135
+ alignItems: "center",
136
+ justifyContent: "center",
137
+ gap: 6,
138
+ minHeight: 44,
139
+ paddingHorizontal: 12,
140
+ paddingVertical: 8,
141
+ },
142
+ strip.item(active),
143
+ item.disabled ? { opacity: 0.5 } : null,
144
+ ],
145
+ },
146
+ item.icon
147
+ ? React.createElement(Icon, { name: item.icon, size: 16, color: ink })
148
+ : null,
149
+ React.createElement(
150
+ Text,
151
+ {
152
+ numberOfLines: 1,
153
+ style: {
154
+ fontFamily: typography.fontFamily,
155
+ fontSize: typography.sizes.sm,
156
+ fontWeight: active ? "600" : "500",
157
+ color: ink,
158
+ },
159
+ },
160
+ item.label,
161
+ ),
162
+ );
163
+ }),
164
+ );
165
+ }
166
+
167
+ Tabs.displayName = "Tabs";
168
+ return Tabs;
169
+ }
package/dist/tabs.js ADDED
@@ -0,0 +1,7 @@
1
+ // `<Tabs>` (web). Binds ./tabs-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 { makeTabs } from "./tabs-view.js";
6
+
7
+ export const Tabs = makeTabs(ReactNative);
@@ -0,0 +1,7 @@
1
+ // `<Tabs>` (native). Binds ./tabs-view.js to React Native's own primitives;
2
+ // only the import differs from ./tabs.js.
3
+
4
+ import { View, Text, Pressable, ScrollView } from "react-native";
5
+ import { makeTabs } from "./tabs-view.js";
6
+
7
+ export const Tabs = makeTabs({ View, Text, Pressable, ScrollView });
@@ -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 });
@@ -65,6 +65,11 @@ function coerceToken(def, value) {
65
65
  if (def.type === "gradient") {
66
66
  return normaliseComponentGradient(value) || undefined;
67
67
  }
68
+ if (def.type === "enum") {
69
+ return Array.isArray(def.values) && def.values.includes(value)
70
+ ? value
71
+ : undefined;
72
+ }
68
73
  return undefined;
69
74
  }
70
75
 
@@ -57,6 +57,11 @@ function coerceToken(def, value) {
57
57
  if (def.type === "gradient") {
58
58
  return normaliseComponentGradient(value) || undefined;
59
59
  }
60
+ if (def.type === "enum") {
61
+ return Array.isArray(def.values) && def.values.includes(value)
62
+ ? value
63
+ : undefined;
64
+ }
60
65
  return undefined;
61
66
  }
62
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.162.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",