@colixsystems/widget-sdk 0.210.0 → 0.212.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 +54 -2
- package/dist/checkbox-view.js +150 -0
- package/dist/checkbox.js +11 -0
- package/dist/checkbox.native.js +7 -0
- package/dist/contract.cjs +42 -3
- package/dist/contract.js +42 -3
- package/dist/form-control-logic.js +55 -0
- package/dist/form-control-tokens.js +121 -0
- package/dist/form-control-view.js +98 -0
- package/dist/index.d.ts +136 -6
- package/dist/index.js +9 -1
- package/dist/index.native.js +9 -1
- package/dist/search-field-view.js +155 -0
- package/dist/search-field.js +13 -0
- package/dist/search-field.native.js +9 -0
- package/dist/select-view.js +312 -0
- package/dist/select.js +15 -0
- package/dist/select.native.js +9 -0
- package/dist/switch-view.js +149 -0
- package/dist/switch.js +80 -0
- package/dist/switch.native.js +7 -0
- package/dist/text-field-view.js +54 -8
- package/dist/text-field.js +6 -1
- package/dist/text-field.native.js +3 -3
- package/package.json +1 -1
- package/src/contract.js +42 -3
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// The ONE implementation behind the `<Select>` SDK primitive. The platform
|
|
2
|
+
// bindings (`select.js` / `select.native.js`) differ only in where the
|
|
3
|
+
// primitives come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// The trigger wears the field `<TextField>` does. The options open in
|
|
6
|
+
// `<Overlay>`, at screen level, so no card or scroll container the widget
|
|
7
|
+
// sits in can clip them; a long list scrolls inside the sheet.
|
|
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 { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
|
|
14
|
+
import {
|
|
15
|
+
CONTROL_MIN_TARGET,
|
|
16
|
+
nextOptionIndex,
|
|
17
|
+
resolveFormControlTokens,
|
|
18
|
+
selectedOption,
|
|
19
|
+
} from "./form-control-tokens.js";
|
|
20
|
+
import {
|
|
21
|
+
errorMessage,
|
|
22
|
+
hasNote,
|
|
23
|
+
isShown,
|
|
24
|
+
isText,
|
|
25
|
+
shareRef,
|
|
26
|
+
spaceActivates,
|
|
27
|
+
spokenLabel,
|
|
28
|
+
} from "./form-control-logic.js";
|
|
29
|
+
import { makeFieldFrame } from "./form-control-view.js";
|
|
30
|
+
|
|
31
|
+
const h = React.createElement;
|
|
32
|
+
const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Home", "End"];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bind the shared select to one platform's primitives.
|
|
36
|
+
*
|
|
37
|
+
* @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any, Overlay: any}} rn
|
|
38
|
+
*/
|
|
39
|
+
export function makeSelect(rn) {
|
|
40
|
+
const { View, Text, Pressable, ScrollView, Platform, Overlay } = rn;
|
|
41
|
+
const FieldFrame = makeFieldFrame(rn);
|
|
42
|
+
|
|
43
|
+
// Keyboard focus is a web concern: a device moves its reader's focus into
|
|
44
|
+
// the sheet by itself, and a programmatic focus there would fight it.
|
|
45
|
+
function focusNode(node) {
|
|
46
|
+
if (Platform.OS === "web" && node && typeof node.focus === "function") node.focus();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function OptionSheet({ t, options, value, title, emptyText, listId, onPick, testID }) {
|
|
50
|
+
const nodes = React.useRef([]);
|
|
51
|
+
const chosen = options.findIndex((option) => option.value === value);
|
|
52
|
+
const firstEnabled = options.findIndex((option) => !option.disabled);
|
|
53
|
+
const initial = chosen !== -1 && !options[chosen].disabled ? chosen : firstEnabled;
|
|
54
|
+
// Focus lands on the current choice once, when the sheet mounts it; the
|
|
55
|
+
// browser scrolls a focused option into view in a long list.
|
|
56
|
+
const pendingFocus = React.useRef(true);
|
|
57
|
+
const text = { fontFamily: t.fontFamily, fontSize: t.fontSize, color: t.popover.textColor };
|
|
58
|
+
|
|
59
|
+
return h(
|
|
60
|
+
View,
|
|
61
|
+
{ style: { flexShrink: 1, gap: t.rowGap } },
|
|
62
|
+
isShown(title)
|
|
63
|
+
? h(
|
|
64
|
+
Text,
|
|
65
|
+
{ style: { ...text, fontWeight: "600", paddingHorizontal: t.paddingHorizontal } },
|
|
66
|
+
title,
|
|
67
|
+
)
|
|
68
|
+
: null,
|
|
69
|
+
options.length === 0
|
|
70
|
+
? h(
|
|
71
|
+
Text,
|
|
72
|
+
{ style: { ...text, color: t.helperColor, paddingHorizontal: t.paddingHorizontal } },
|
|
73
|
+
emptyText,
|
|
74
|
+
)
|
|
75
|
+
: h(
|
|
76
|
+
ScrollView,
|
|
77
|
+
{
|
|
78
|
+
style: { flexShrink: 1 },
|
|
79
|
+
nativeID: listId,
|
|
80
|
+
// Web only: native has no listbox, and ignores an unknown `role`.
|
|
81
|
+
role: "listbox",
|
|
82
|
+
accessibilityLabel: isText(title) ? String(title) : undefined,
|
|
83
|
+
keyboardShouldPersistTaps: "handled",
|
|
84
|
+
testID: testID ? `${testID}-options` : undefined,
|
|
85
|
+
},
|
|
86
|
+
...options.map((option, index) => {
|
|
87
|
+
const selected = option.value === value;
|
|
88
|
+
// The web host still delivers keys to a disabled Pressable.
|
|
89
|
+
const pick = () => {
|
|
90
|
+
if (!option.disabled) onPick(option);
|
|
91
|
+
};
|
|
92
|
+
return h(
|
|
93
|
+
Pressable,
|
|
94
|
+
{
|
|
95
|
+
// 1 and "1" are different values.
|
|
96
|
+
key: `${typeof option.value}:${option.value}`,
|
|
97
|
+
ref: (node) => {
|
|
98
|
+
nodes.current[index] = node;
|
|
99
|
+
if (node && index === initial && pendingFocus.current) {
|
|
100
|
+
pendingFocus.current = false;
|
|
101
|
+
focusNode(node);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
onPress: pick,
|
|
105
|
+
onKeyDown: (event) => {
|
|
106
|
+
if (NAVIGATION_KEYS.includes(event.key)) {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
focusNode(nodes.current[nextOptionIndex(options, index, event.key)]);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
spaceActivates(pick)(event);
|
|
112
|
+
},
|
|
113
|
+
disabled: option.disabled,
|
|
114
|
+
role: "option",
|
|
115
|
+
"aria-selected": selected,
|
|
116
|
+
"aria-disabled": !!option.disabled,
|
|
117
|
+
testID: option.testID,
|
|
118
|
+
style: ({ pressed }) => [
|
|
119
|
+
{
|
|
120
|
+
flexDirection: "row",
|
|
121
|
+
alignItems: "center",
|
|
122
|
+
justifyContent: "space-between",
|
|
123
|
+
gap: t.rowGap,
|
|
124
|
+
minHeight: CONTROL_MIN_TARGET,
|
|
125
|
+
paddingHorizontal: t.paddingHorizontal,
|
|
126
|
+
paddingVertical: t.paddingVertical,
|
|
127
|
+
borderRadius: t.radius,
|
|
128
|
+
},
|
|
129
|
+
pressed ? { opacity: 0.7 } : null,
|
|
130
|
+
option.disabled ? { opacity: t.disabledOpacity } : null,
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
h(
|
|
134
|
+
Text,
|
|
135
|
+
{ style: [text, { flexShrink: 1 }, selected ? { fontWeight: "600" } : null] },
|
|
136
|
+
option.label,
|
|
137
|
+
),
|
|
138
|
+
selected ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
|
|
139
|
+
);
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const Select = React.forwardRef(function Select(props, ref) {
|
|
146
|
+
const {
|
|
147
|
+
value,
|
|
148
|
+
onValueChange,
|
|
149
|
+
options,
|
|
150
|
+
placeholder = "Select",
|
|
151
|
+
title,
|
|
152
|
+
emptyText = "No options",
|
|
153
|
+
label,
|
|
154
|
+
helper,
|
|
155
|
+
error,
|
|
156
|
+
disabled = false,
|
|
157
|
+
accessibilityLabel,
|
|
158
|
+
closeAccessibilityLabel,
|
|
159
|
+
style,
|
|
160
|
+
containerStyle,
|
|
161
|
+
testID,
|
|
162
|
+
onFocus,
|
|
163
|
+
onBlur,
|
|
164
|
+
...others
|
|
165
|
+
} = props;
|
|
166
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
167
|
+
const overrides = {};
|
|
168
|
+
const rest = {};
|
|
169
|
+
for (const [key, own] of Object.entries(others)) {
|
|
170
|
+
if (TEXT_FIELD_TOKEN_NAMES.includes(key)) overrides[key] = own;
|
|
171
|
+
else rest[key] = own;
|
|
172
|
+
}
|
|
173
|
+
const t = resolveFormControlTokens(theme, overrides);
|
|
174
|
+
const [open, setOpen] = React.useState(false);
|
|
175
|
+
const [focused, setFocused] = React.useState(false);
|
|
176
|
+
const triggerRef = React.useRef(null);
|
|
177
|
+
const setRef = React.useMemo(() => shareRef(ref, triggerRef), [ref]);
|
|
178
|
+
const listId = React.useId();
|
|
179
|
+
const noteId = React.useId();
|
|
180
|
+
const list = Array.isArray(options) ? options.filter(Boolean) : [];
|
|
181
|
+
const current = selectedOption(list, value);
|
|
182
|
+
const invalid = isShown(error);
|
|
183
|
+
const sheetTitle = isShown(title) ? title : isText(label) ? label : undefined;
|
|
184
|
+
const name = spokenLabel(accessibilityLabel, label);
|
|
185
|
+
const shown = current ? current.label : placeholder;
|
|
186
|
+
const show = () => {
|
|
187
|
+
if (!disabled) setOpen(true);
|
|
188
|
+
};
|
|
189
|
+
const close = () => setOpen(false);
|
|
190
|
+
// The option that held focus is gone once the sheet closes. After the
|
|
191
|
+
// sheet unmounts, or its focus trap would pull focus straight back in.
|
|
192
|
+
const wasOpen = React.useRef(false);
|
|
193
|
+
React.useEffect(() => {
|
|
194
|
+
if (wasOpen.current && !open) focusNode(triggerRef.current);
|
|
195
|
+
wasOpen.current = open;
|
|
196
|
+
}, [open]);
|
|
197
|
+
|
|
198
|
+
return h(
|
|
199
|
+
FieldFrame,
|
|
200
|
+
{
|
|
201
|
+
t,
|
|
202
|
+
label,
|
|
203
|
+
helper,
|
|
204
|
+
error: errorMessage(error),
|
|
205
|
+
disabled,
|
|
206
|
+
noteId,
|
|
207
|
+
onLabelPress: () => focusNode(triggerRef.current),
|
|
208
|
+
style: containerStyle,
|
|
209
|
+
},
|
|
210
|
+
h(
|
|
211
|
+
Pressable,
|
|
212
|
+
{
|
|
213
|
+
...rest,
|
|
214
|
+
ref: setRef,
|
|
215
|
+
onPress: show,
|
|
216
|
+
onKeyDown: (event) => {
|
|
217
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
show();
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
onFocus: (event) => {
|
|
223
|
+
setFocused(true);
|
|
224
|
+
if (typeof onFocus === "function") onFocus(event);
|
|
225
|
+
},
|
|
226
|
+
onBlur: (event) => {
|
|
227
|
+
setFocused(false);
|
|
228
|
+
if (typeof onBlur === "function") onBlur(event);
|
|
229
|
+
},
|
|
230
|
+
disabled,
|
|
231
|
+
// A button that opens a dialog: the options are a sheet, not a popup.
|
|
232
|
+
accessibilityRole: "button",
|
|
233
|
+
"aria-haspopup": "dialog",
|
|
234
|
+
"aria-expanded": open,
|
|
235
|
+
...(open ? { "aria-controls": listId } : null),
|
|
236
|
+
"aria-disabled": disabled,
|
|
237
|
+
...(invalid ? { "aria-invalid": true } : null),
|
|
238
|
+
...(hasNote({ helper, error }) ? { "aria-describedby": noteId } : null),
|
|
239
|
+
// The choice is part of the name: a button's own label replaces its text.
|
|
240
|
+
accessibilityLabel: [name, isText(shown) ? shown : null].filter(Boolean).join(", ") || undefined,
|
|
241
|
+
testID,
|
|
242
|
+
style: [
|
|
243
|
+
{
|
|
244
|
+
flexDirection: "row",
|
|
245
|
+
alignItems: "center",
|
|
246
|
+
justifyContent: "space-between",
|
|
247
|
+
gap: t.rowGap,
|
|
248
|
+
minHeight: t.height,
|
|
249
|
+
paddingHorizontal: t.paddingHorizontal,
|
|
250
|
+
paddingVertical: t.paddingVertical,
|
|
251
|
+
borderWidth: 1,
|
|
252
|
+
borderColor: t.borderColor,
|
|
253
|
+
borderRadius: t.radius,
|
|
254
|
+
backgroundColor: t.background,
|
|
255
|
+
},
|
|
256
|
+
focused ? { borderColor: t.focusColor } : null,
|
|
257
|
+
invalid ? { borderColor: t.errorColor } : null,
|
|
258
|
+
style,
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
h(
|
|
262
|
+
Text,
|
|
263
|
+
{
|
|
264
|
+
numberOfLines: 1,
|
|
265
|
+
style: {
|
|
266
|
+
flexShrink: 1,
|
|
267
|
+
color: current ? t.textColor : t.placeholderColor,
|
|
268
|
+
fontFamily: t.fontFamily,
|
|
269
|
+
fontSize: t.fontSize,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
shown,
|
|
273
|
+
),
|
|
274
|
+
h(Icon, { name: "chevron-down", size: 18, color: t.placeholderColor }),
|
|
275
|
+
),
|
|
276
|
+
h(
|
|
277
|
+
Overlay,
|
|
278
|
+
{
|
|
279
|
+
visible: open,
|
|
280
|
+
onRequestClose: close,
|
|
281
|
+
size: "sm",
|
|
282
|
+
accessibilityLabel: isText(sheetTitle) ? String(sheetTitle) : name,
|
|
283
|
+
...(closeAccessibilityLabel ? { closeAccessibilityLabel } : null),
|
|
284
|
+
style: {
|
|
285
|
+
backgroundColor: t.popover.background,
|
|
286
|
+
borderWidth: 1,
|
|
287
|
+
borderColor: t.popover.borderColor,
|
|
288
|
+
padding: t.rowGap,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
h(OptionSheet, {
|
|
292
|
+
t,
|
|
293
|
+
options: list,
|
|
294
|
+
value,
|
|
295
|
+
title: sheetTitle,
|
|
296
|
+
emptyText,
|
|
297
|
+
listId,
|
|
298
|
+
testID,
|
|
299
|
+
onPick: (option) => {
|
|
300
|
+
close();
|
|
301
|
+
if (option.value !== value && typeof onValueChange === "function") {
|
|
302
|
+
onValueChange(option.value, option);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
Select.displayName = "Select";
|
|
311
|
+
return Select;
|
|
312
|
+
}
|
package/dist/select.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `<Select>` (web). Binds ./select-view.js to react-native-web and the web
|
|
2
|
+
// `<Overlay>`; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makeSelect } from "./select-view.js";
|
|
6
|
+
import { Overlay } from "./overlay.js";
|
|
7
|
+
|
|
8
|
+
export const Select = makeSelect({
|
|
9
|
+
View: ReactNative.View,
|
|
10
|
+
Text: ReactNative.Text,
|
|
11
|
+
Pressable: ReactNative.Pressable,
|
|
12
|
+
ScrollView: ReactNative.ScrollView,
|
|
13
|
+
Platform: ReactNative.Platform,
|
|
14
|
+
Overlay,
|
|
15
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `<Select>` (native). Binds ./select-view.js to React Native's own
|
|
2
|
+
// primitives and the native `<Overlay>`; only the imports differ from
|
|
3
|
+
// ./select.js.
|
|
4
|
+
|
|
5
|
+
import { View, Text, Pressable, ScrollView, Platform } from "react-native";
|
|
6
|
+
import { makeSelect } from "./select-view.js";
|
|
7
|
+
import { Overlay } from "./overlay.native.js";
|
|
8
|
+
|
|
9
|
+
export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay });
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The ONE implementation behind the `<Switch>` SDK primitive. The platform
|
|
2
|
+
// bindings (`switch.js` / `switch.native.js`) differ only in where the
|
|
3
|
+
// primitives come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// It is the platform's own switch, so it keeps the role, state, keys and
|
|
6
|
+
// disabled look each host gives it; this view adds the theme's track colours,
|
|
7
|
+
// a label, a helper and an error.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useHostTheme } from "./hooks.js";
|
|
11
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
|
+
import {
|
|
13
|
+
CONTROL_MIN_TARGET,
|
|
14
|
+
focusHaloStyle,
|
|
15
|
+
resolveFormControlTokens,
|
|
16
|
+
resolveSwitchColors,
|
|
17
|
+
} from "./form-control-tokens.js";
|
|
18
|
+
import {
|
|
19
|
+
errorMessage,
|
|
20
|
+
hasNote,
|
|
21
|
+
isShown,
|
|
22
|
+
isText,
|
|
23
|
+
needsFrame,
|
|
24
|
+
spokenLabel,
|
|
25
|
+
} from "./form-control-logic.js";
|
|
26
|
+
import { makeFieldFrame } from "./form-control-view.js";
|
|
27
|
+
|
|
28
|
+
const h = React.createElement;
|
|
29
|
+
const TRACK_RADIUS = 16;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bind the shared switch to one platform's primitives.
|
|
33
|
+
*
|
|
34
|
+
* @param {{View: any, Text: any, Pressable: any, Switch: any}} rn
|
|
35
|
+
*/
|
|
36
|
+
export function makeSwitch(rn) {
|
|
37
|
+
const { View, Text, Pressable, Switch: PlatformSwitch } = rn;
|
|
38
|
+
const FieldFrame = makeFieldFrame(rn);
|
|
39
|
+
|
|
40
|
+
const Switch = React.forwardRef(function Switch(props, ref) {
|
|
41
|
+
const {
|
|
42
|
+
value = false,
|
|
43
|
+
onValueChange,
|
|
44
|
+
label,
|
|
45
|
+
helper,
|
|
46
|
+
error,
|
|
47
|
+
disabled = false,
|
|
48
|
+
accessibilityLabel,
|
|
49
|
+
trackColor,
|
|
50
|
+
ios_backgroundColor: iosBackgroundColor,
|
|
51
|
+
containerStyle,
|
|
52
|
+
...rest
|
|
53
|
+
} = props;
|
|
54
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
55
|
+
const t = resolveFormControlTokens(theme);
|
|
56
|
+
const [focused, setFocused] = React.useState(false);
|
|
57
|
+
const noteId = React.useId();
|
|
58
|
+
const on = Boolean(value);
|
|
59
|
+
const invalid = isShown(error);
|
|
60
|
+
const noted = hasNote({ helper, error });
|
|
61
|
+
const colors = resolveSwitchColors(t, { trackColor, iosBackgroundColor, invalid });
|
|
62
|
+
|
|
63
|
+
// The thumb is left to the platform unless the caller colours it.
|
|
64
|
+
const control = h(PlatformSwitch, {
|
|
65
|
+
...rest,
|
|
66
|
+
ref,
|
|
67
|
+
value: on,
|
|
68
|
+
onValueChange,
|
|
69
|
+
disabled,
|
|
70
|
+
trackColor: colors.trackColor,
|
|
71
|
+
ios_backgroundColor: colors.iosBackgroundColor,
|
|
72
|
+
accessibilityLabel: spokenLabel(accessibilityLabel, label),
|
|
73
|
+
...(invalid ? { "aria-invalid": true } : null),
|
|
74
|
+
...(noted ? { "aria-describedby": noteId } : null),
|
|
75
|
+
});
|
|
76
|
+
if (!needsFrame({ label, helper, error })) return control;
|
|
77
|
+
|
|
78
|
+
const toggle = () => {
|
|
79
|
+
if (!disabled && typeof onValueChange === "function") onValueChange(!on);
|
|
80
|
+
};
|
|
81
|
+
const row = h(
|
|
82
|
+
View,
|
|
83
|
+
{
|
|
84
|
+
style: {
|
|
85
|
+
flexDirection: "row",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "space-between",
|
|
88
|
+
gap: t.rowGap,
|
|
89
|
+
minHeight: CONTROL_MIN_TARGET,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
isShown(label)
|
|
93
|
+
? h(
|
|
94
|
+
Pressable,
|
|
95
|
+
{
|
|
96
|
+
onPress: toggle,
|
|
97
|
+
disabled,
|
|
98
|
+
// The switch itself is the one stop for a keyboard and a reader.
|
|
99
|
+
tabIndex: -1,
|
|
100
|
+
accessible: false,
|
|
101
|
+
style: [
|
|
102
|
+
{ flexShrink: 1, minHeight: CONTROL_MIN_TARGET, justifyContent: "center" },
|
|
103
|
+
// The control dims itself on each host; only the label is ours.
|
|
104
|
+
disabled ? { opacity: t.disabledOpacity } : null,
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
isText(label)
|
|
108
|
+
? h(
|
|
109
|
+
Text,
|
|
110
|
+
{
|
|
111
|
+
style: {
|
|
112
|
+
color: t.labelColor,
|
|
113
|
+
fontFamily: t.fontFamily,
|
|
114
|
+
fontSize: t.fontSize,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
label,
|
|
118
|
+
)
|
|
119
|
+
: label,
|
|
120
|
+
)
|
|
121
|
+
: null,
|
|
122
|
+
h(
|
|
123
|
+
View,
|
|
124
|
+
{
|
|
125
|
+
// Focus events bubble from the control on the web host.
|
|
126
|
+
onFocus: () => setFocused(true),
|
|
127
|
+
onBlur: () => setFocused(false),
|
|
128
|
+
style: focusHaloStyle(t, focused, TRACK_RADIUS),
|
|
129
|
+
},
|
|
130
|
+
control,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
return h(
|
|
134
|
+
FieldFrame,
|
|
135
|
+
{
|
|
136
|
+
t,
|
|
137
|
+
helper,
|
|
138
|
+
error: errorMessage(error),
|
|
139
|
+
noteId,
|
|
140
|
+
noteDisabled: disabled,
|
|
141
|
+
style: containerStyle,
|
|
142
|
+
},
|
|
143
|
+
row,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
Switch.displayName = "Switch";
|
|
148
|
+
return Switch;
|
|
149
|
+
}
|
package/dist/switch.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `<Switch>` (web). Binds ./switch-view.js to react-native-web; see
|
|
2
|
+
// ./primitives.js for why not the bare specifier.
|
|
3
|
+
//
|
|
4
|
+
// This host's switch differs from a device's in ways the shared view cannot
|
|
5
|
+
// see, so they are bridged here and nowhere else:
|
|
6
|
+
// - it takes `activeTrackColor` and a STRING `trackColor` (the off colour),
|
|
7
|
+
// and ignores both once it is handed the `{ false, true }` object;
|
|
8
|
+
// - it colours an "on" thumb from `activeThumbColor`, not `thumbColor`;
|
|
9
|
+
// - it stops greying a disabled switch once it is given any track colour;
|
|
10
|
+
// - it forwards only a label to its `<input>`, dropping the other ARIA props.
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
import * as ReactNative from "react-native-web";
|
|
14
|
+
import { makeSwitch } from "./switch-view.js";
|
|
15
|
+
import { DISABLED_OPACITY } from "./form-control-tokens.js";
|
|
16
|
+
import { shareRef } from "./form-control-logic.js";
|
|
17
|
+
|
|
18
|
+
const ON_THUMB_COLOR = "#ffffff";
|
|
19
|
+
// A caller's own `style.opacity` comes later in the list and wins.
|
|
20
|
+
const DIMMED = { opacity: DISABLED_OPACITY };
|
|
21
|
+
|
|
22
|
+
function isSet(value) {
|
|
23
|
+
return value !== undefined && value !== null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The caller's colours in the object form the shared view merges. */
|
|
27
|
+
export function webTrackColor(trackColor, activeTrackColor) {
|
|
28
|
+
if (trackColor && typeof trackColor === "object") return trackColor;
|
|
29
|
+
const own = {};
|
|
30
|
+
if (typeof trackColor === "string") own.false = trackColor;
|
|
31
|
+
if (isSet(activeTrackColor)) own.true = activeTrackColor;
|
|
32
|
+
return Object.keys(own).length > 0 ? own : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const INPUT_ARIA = ["aria-describedby", "aria-invalid"];
|
|
36
|
+
|
|
37
|
+
const PlatformSwitch = React.forwardRef(function PlatformSwitch(props, ref) {
|
|
38
|
+
const { "aria-describedby": describedBy, "aria-invalid": invalid, ...rest } = props;
|
|
39
|
+
const inputRef = React.useRef(null);
|
|
40
|
+
const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
const input = inputRef.current;
|
|
43
|
+
if (!input || typeof input.setAttribute !== "function") return;
|
|
44
|
+
const values = { "aria-describedby": describedBy, "aria-invalid": invalid ? "true" : null };
|
|
45
|
+
for (const name of INPUT_ARIA) {
|
|
46
|
+
if (isSet(values[name])) input.setAttribute(name, values[name]);
|
|
47
|
+
else input.removeAttribute(name);
|
|
48
|
+
}
|
|
49
|
+
}, [describedBy, invalid]);
|
|
50
|
+
return React.createElement(ReactNative.Switch, {
|
|
51
|
+
activeThumbColor: rest.thumbColor ?? ON_THUMB_COLOR,
|
|
52
|
+
...rest,
|
|
53
|
+
ref: setRef,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const ThemedSwitch = makeSwitch({
|
|
58
|
+
View: ReactNative.View,
|
|
59
|
+
Text: ReactNative.Text,
|
|
60
|
+
Pressable: ReactNative.Pressable,
|
|
61
|
+
Switch: PlatformSwitch,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const Switch = React.forwardRef(function Switch(props, ref) {
|
|
65
|
+
const { trackColor, activeTrackColor, style, ...rest } = props;
|
|
66
|
+
const ownColours =
|
|
67
|
+
isSet(trackColor) ||
|
|
68
|
+
isSet(activeTrackColor) ||
|
|
69
|
+
isSet(rest.thumbColor) ||
|
|
70
|
+
isSet(rest.activeThumbColor);
|
|
71
|
+
return React.createElement(ThemedSwitch, {
|
|
72
|
+
...rest,
|
|
73
|
+
ref,
|
|
74
|
+
trackColor: webTrackColor(trackColor, activeTrackColor),
|
|
75
|
+
// Only a switch that left its colours to us lost the host's grey look.
|
|
76
|
+
style: rest.disabled && !ownColours ? [DIMMED, style] : style,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
Switch.displayName = "Switch";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<Switch>` (native). Binds ./switch-view.js to React Native's own
|
|
2
|
+
// primitives; only the import differs from ./switch.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable, Switch as PlatformSwitch } from "react-native";
|
|
5
|
+
import { makeSwitch } from "./switch-view.js";
|
|
6
|
+
|
|
7
|
+
export const Switch = makeSwitch({ View, Text, Pressable, Switch: PlatformSwitch });
|
package/dist/text-field-view.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
// The ONE text-entry field, behind the `<TextField>` SDK primitive and the
|
|
2
|
-
// built-in form fields. The bindings differ only in where
|
|
2
|
+
// built-in form fields. The bindings differ only in where the primitives come
|
|
3
3
|
// from, so the hosts cannot drift.
|
|
4
4
|
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { useHostTheme } from "./hooks.js";
|
|
7
7
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
|
+
import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
|
|
9
|
+
import { resolveFormControlTokens } from "./form-control-tokens.js";
|
|
8
10
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
errorMessage,
|
|
12
|
+
hasNote,
|
|
13
|
+
isShown,
|
|
14
|
+
needsFrame,
|
|
15
|
+
shareRef,
|
|
16
|
+
spokenLabel,
|
|
17
|
+
} from "./form-control-logic.js";
|
|
18
|
+
import { makeFieldFrame } from "./form-control-view.js";
|
|
12
19
|
|
|
13
20
|
// A paragraph field grows to a ceiling, then scrolls inside itself.
|
|
14
21
|
const MULTILINE = { minHeight: 88, maxHeight: 220, textAlignVertical: "top" };
|
|
@@ -16,10 +23,11 @@ const MULTILINE = { minHeight: 88, maxHeight: 220, textAlignVertical: "top" };
|
|
|
16
23
|
/**
|
|
17
24
|
* Bind the shared field to one platform's primitives.
|
|
18
25
|
*
|
|
19
|
-
* @param {{TextInput: any}} rn
|
|
26
|
+
* @param {{TextInput: any, View: any, Text: any, Pressable: any}} rn
|
|
20
27
|
*/
|
|
21
28
|
export function makeTextField(rn) {
|
|
22
29
|
const { TextInput } = rn;
|
|
30
|
+
const FieldFrame = makeFieldFrame(rn);
|
|
23
31
|
|
|
24
32
|
const TextField = React.forwardRef(function TextField(props, ref) {
|
|
25
33
|
const {
|
|
@@ -28,6 +36,12 @@ export function makeTextField(rn) {
|
|
|
28
36
|
placeholderTextColor,
|
|
29
37
|
onFocus,
|
|
30
38
|
onBlur,
|
|
39
|
+
label,
|
|
40
|
+
helper,
|
|
41
|
+
error,
|
|
42
|
+
disabled = false,
|
|
43
|
+
containerStyle,
|
|
44
|
+
accessibilityLabel,
|
|
31
45
|
...others
|
|
32
46
|
} = props;
|
|
33
47
|
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
@@ -39,11 +53,21 @@ export function makeTextField(rn) {
|
|
|
39
53
|
if (TEXT_FIELD_TOKEN_NAMES.includes(key)) overrides[key] = value;
|
|
40
54
|
else rest[key] = value;
|
|
41
55
|
}
|
|
42
|
-
const t =
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
const t = resolveFormControlTokens(theme, overrides);
|
|
57
|
+
const invalid = isShown(error);
|
|
58
|
+
const framed = needsFrame({ label, helper, error });
|
|
59
|
+
const inputRef = React.useRef(null);
|
|
60
|
+
// Stable, so the host does not detach and re-attach the node every render.
|
|
61
|
+
const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
|
|
62
|
+
const noteId = React.useId();
|
|
63
|
+
const input = React.createElement(TextInput, {
|
|
64
|
+
ref: setRef,
|
|
45
65
|
multiline,
|
|
46
66
|
placeholderTextColor: placeholderTextColor || t.placeholderColor,
|
|
67
|
+
accessibilityLabel: spokenLabel(accessibilityLabel, label),
|
|
68
|
+
...(disabled ? { editable: false, "aria-disabled": true } : null),
|
|
69
|
+
...(invalid ? { "aria-invalid": true } : null),
|
|
70
|
+
...(hasNote({ helper, error }) ? { "aria-describedby": noteId } : null),
|
|
47
71
|
style: [
|
|
48
72
|
{
|
|
49
73
|
borderWidth: 1,
|
|
@@ -58,6 +82,10 @@ export function makeTextField(rn) {
|
|
|
58
82
|
fontSize: t.fontSize,
|
|
59
83
|
},
|
|
60
84
|
focused ? { borderColor: t.focusColor } : null,
|
|
85
|
+
// After focus: an invalid field stays marked while it is being fixed.
|
|
86
|
+
invalid ? { borderColor: t.errorColor } : null,
|
|
87
|
+
// The frame dims a framed field as a whole.
|
|
88
|
+
disabled && !framed ? { opacity: t.disabledOpacity } : null,
|
|
61
89
|
style,
|
|
62
90
|
// After the caller's style: a paragraph field keeps its 88px floor.
|
|
63
91
|
multiline ? MULTILINE : null,
|
|
@@ -72,6 +100,24 @@ export function makeTextField(rn) {
|
|
|
72
100
|
},
|
|
73
101
|
...rest,
|
|
74
102
|
});
|
|
103
|
+
if (!framed) return input;
|
|
104
|
+
return React.createElement(
|
|
105
|
+
FieldFrame,
|
|
106
|
+
{
|
|
107
|
+
t,
|
|
108
|
+
label,
|
|
109
|
+
helper,
|
|
110
|
+
error: errorMessage(error),
|
|
111
|
+
disabled,
|
|
112
|
+
noteId,
|
|
113
|
+
onLabelPress: () => {
|
|
114
|
+
const node = inputRef.current;
|
|
115
|
+
if (node && typeof node.focus === "function") node.focus();
|
|
116
|
+
},
|
|
117
|
+
style: containerStyle,
|
|
118
|
+
},
|
|
119
|
+
input,
|
|
120
|
+
);
|
|
75
121
|
});
|
|
76
122
|
|
|
77
123
|
TextField.displayName = "TextField";
|
package/dist/text-field.js
CHANGED
|
@@ -5,4 +5,9 @@ import * as ReactNative from "react-native-web";
|
|
|
5
5
|
import { makeTextField } from "./text-field-view.js";
|
|
6
6
|
import { TextInput } from "./primitives.js";
|
|
7
7
|
|
|
8
|
-
export const TextField = makeTextField({
|
|
8
|
+
export const TextField = makeTextField({
|
|
9
|
+
TextInput,
|
|
10
|
+
View: ReactNative.View,
|
|
11
|
+
Text: ReactNative.Text,
|
|
12
|
+
Pressable: ReactNative.Pressable,
|
|
13
|
+
});
|