@colixsystems/widget-sdk 0.207.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 +97 -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 +78 -4
- package/dist/contract.js +78 -4
- 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/hooks.js +13 -0
- package/dist/host.d.ts +46 -0
- package/dist/host.js +4 -0
- package/dist/index.d.ts +230 -6
- package/dist/index.js +11 -1
- package/dist/index.native.js +11 -1
- package/dist/markdown-input-view.js +2 -3
- package/dist/markdown-input.js +4 -2
- package/dist/markdown-input.native.js +4 -2
- package/dist/markdown-link-dialog.js +94 -108
- package/dist/modal-tokens.js +107 -0
- package/dist/modal-view.js +255 -0
- package/dist/modal.js +8 -0
- package/dist/modal.native.js +45 -0
- package/dist/overlay-tokens.js +69 -5
- package/dist/overlay-view.js +52 -16
- package/dist/overlay.native.js +8 -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/surface-theme.js +108 -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/dist/web-modal.js +24 -5
- package/package.json +1 -1
- package/src/contract.js +78 -4
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The ONE implementation behind the `<SearchField>` SDK primitive. It is
|
|
2
|
+
// `<TextField>` with a search glyph and a clear control, so it wears the same
|
|
3
|
+
// field and takes the same props; the platform bindings (`search-field.js` /
|
|
4
|
+
// `search-field.native.js`) differ only in where the primitives come from.
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { useHostTheme } from "./hooks.js";
|
|
8
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
9
|
+
import { Icon } from "./icon.js";
|
|
10
|
+
import { resolveFormControlTokens } from "./form-control-tokens.js";
|
|
11
|
+
import {
|
|
12
|
+
errorMessage,
|
|
13
|
+
hasNote,
|
|
14
|
+
isShown,
|
|
15
|
+
shareRef,
|
|
16
|
+
spokenLabel,
|
|
17
|
+
} from "./form-control-logic.js";
|
|
18
|
+
import { makeFieldFrame } from "./form-control-view.js";
|
|
19
|
+
|
|
20
|
+
const h = React.createElement;
|
|
21
|
+
const GLYPH_SIZE = 18;
|
|
22
|
+
// The glyph and the clear control each own a strip this wide inside the field.
|
|
23
|
+
const ADORNMENT_WIDTH = 40;
|
|
24
|
+
// A real target: the web host ignores hitSlop, so size carries it.
|
|
25
|
+
const CLEAR_MIN_SIZE = 36;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bind the shared search field to one platform's primitives.
|
|
29
|
+
*
|
|
30
|
+
* @param {{View: any, Text: any, Pressable: any, TextField: any}} rn
|
|
31
|
+
*/
|
|
32
|
+
export function makeSearchField(rn) {
|
|
33
|
+
const { View, Pressable, TextField } = rn;
|
|
34
|
+
const FieldFrame = makeFieldFrame(rn);
|
|
35
|
+
|
|
36
|
+
const SearchField = React.forwardRef(function SearchField(props, ref) {
|
|
37
|
+
const {
|
|
38
|
+
value,
|
|
39
|
+
onChangeText,
|
|
40
|
+
onClear,
|
|
41
|
+
placeholder = "Search",
|
|
42
|
+
clearAccessibilityLabel = "Clear search",
|
|
43
|
+
label,
|
|
44
|
+
helper,
|
|
45
|
+
error,
|
|
46
|
+
disabled = false,
|
|
47
|
+
accessibilityLabel,
|
|
48
|
+
style,
|
|
49
|
+
containerStyle,
|
|
50
|
+
testID,
|
|
51
|
+
...rest
|
|
52
|
+
} = props;
|
|
53
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
54
|
+
const t = resolveFormControlTokens(theme);
|
|
55
|
+
const inputRef = React.useRef(null);
|
|
56
|
+
const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
|
|
57
|
+
const noteId = React.useId();
|
|
58
|
+
const focusInput = () => {
|
|
59
|
+
const input = inputRef.current;
|
|
60
|
+
if (input && typeof input.focus === "function") input.focus();
|
|
61
|
+
};
|
|
62
|
+
const hasText = typeof value === "string" && value.length > 0;
|
|
63
|
+
const clear = () => {
|
|
64
|
+
if (typeof onChangeText === "function") onChangeText("");
|
|
65
|
+
if (typeof onClear === "function") onClear();
|
|
66
|
+
focusInput();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return h(
|
|
70
|
+
FieldFrame,
|
|
71
|
+
{
|
|
72
|
+
t,
|
|
73
|
+
label,
|
|
74
|
+
helper,
|
|
75
|
+
error: errorMessage(error),
|
|
76
|
+
disabled,
|
|
77
|
+
noteId,
|
|
78
|
+
onLabelPress: focusInput,
|
|
79
|
+
style: containerStyle,
|
|
80
|
+
},
|
|
81
|
+
h(
|
|
82
|
+
View,
|
|
83
|
+
{ style: { justifyContent: "center" } },
|
|
84
|
+
h(TextField, {
|
|
85
|
+
ref: setRef,
|
|
86
|
+
value,
|
|
87
|
+
onChangeText,
|
|
88
|
+
placeholder,
|
|
89
|
+
// The frame dims the whole control, glyph included.
|
|
90
|
+
...(disabled ? { editable: false, "aria-disabled": true } : null),
|
|
91
|
+
error: isShown(error),
|
|
92
|
+
...(hasNote({ helper, error }) ? { "aria-describedby": noteId } : null),
|
|
93
|
+
// `role` names it on the web; `accessibilityRole` on a device.
|
|
94
|
+
role: "searchbox",
|
|
95
|
+
accessibilityRole: "search",
|
|
96
|
+
accessibilityLabel: spokenLabel(accessibilityLabel, label) || placeholder,
|
|
97
|
+
enterKeyHint: "search",
|
|
98
|
+
autoCapitalize: "none",
|
|
99
|
+
autoCorrect: false,
|
|
100
|
+
testID,
|
|
101
|
+
...rest,
|
|
102
|
+
style: [
|
|
103
|
+
{ paddingLeft: ADORNMENT_WIDTH },
|
|
104
|
+
hasText && !disabled ? { paddingRight: ADORNMENT_WIDTH } : null,
|
|
105
|
+
style,
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
h(
|
|
109
|
+
View,
|
|
110
|
+
{
|
|
111
|
+
style: {
|
|
112
|
+
position: "absolute",
|
|
113
|
+
left: 0,
|
|
114
|
+
top: 0,
|
|
115
|
+
bottom: 0,
|
|
116
|
+
width: ADORNMENT_WIDTH,
|
|
117
|
+
alignItems: "center",
|
|
118
|
+
justifyContent: "center",
|
|
119
|
+
pointerEvents: "none",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
h(Icon, { name: "search", size: GLYPH_SIZE, color: t.placeholderColor }),
|
|
123
|
+
),
|
|
124
|
+
hasText && !disabled
|
|
125
|
+
? h(
|
|
126
|
+
Pressable,
|
|
127
|
+
{
|
|
128
|
+
onPress: clear,
|
|
129
|
+
accessibilityRole: "button",
|
|
130
|
+
accessibilityLabel: clearAccessibilityLabel,
|
|
131
|
+
testID: testID ? `${testID}-clear` : undefined,
|
|
132
|
+
style: ({ pressed }) => [
|
|
133
|
+
{
|
|
134
|
+
position: "absolute",
|
|
135
|
+
right: 2,
|
|
136
|
+
top: 0,
|
|
137
|
+
bottom: 0,
|
|
138
|
+
minWidth: CLEAR_MIN_SIZE,
|
|
139
|
+
minHeight: CLEAR_MIN_SIZE,
|
|
140
|
+
alignItems: "center",
|
|
141
|
+
justifyContent: "center",
|
|
142
|
+
},
|
|
143
|
+
pressed ? { opacity: 0.6 } : null,
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
h(Icon, { name: "x", size: GLYPH_SIZE, color: t.placeholderColor }),
|
|
147
|
+
)
|
|
148
|
+
: null,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
SearchField.displayName = "SearchField";
|
|
154
|
+
return SearchField;
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// `<SearchField>` (web). Binds ./search-field-view.js to react-native-web and
|
|
2
|
+
// the web `<TextField>`; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makeSearchField } from "./search-field-view.js";
|
|
6
|
+
import { TextField } from "./text-field.js";
|
|
7
|
+
|
|
8
|
+
export const SearchField = makeSearchField({
|
|
9
|
+
View: ReactNative.View,
|
|
10
|
+
Text: ReactNative.Text,
|
|
11
|
+
Pressable: ReactNative.Pressable,
|
|
12
|
+
TextField,
|
|
13
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `<SearchField>` (native). Binds ./search-field-view.js to React Native's own
|
|
2
|
+
// primitives and the native `<TextField>`; only the imports differ from
|
|
3
|
+
// ./search-field.js.
|
|
4
|
+
|
|
5
|
+
import { View, Text, Pressable } from "react-native";
|
|
6
|
+
import { makeSearchField } from "./search-field-view.js";
|
|
7
|
+
import { TextField } from "./text-field.native.js";
|
|
8
|
+
|
|
9
|
+
export const SearchField = makeSearchField({ View, Text, Pressable, TextField });
|
|
@@ -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,108 @@
|
|
|
1
|
+
// REQ-THEME-SURFACE: the theme a painted surface hands its subtree. Lives in
|
|
2
|
+
// the SDK so the web Player's containers and `<Modal>` on both hosts re-ink
|
|
3
|
+
// from one implementation.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
import {
|
|
7
|
+
INK_CONTRAST,
|
|
8
|
+
deriveSurfaceTokens,
|
|
9
|
+
inkForBackdrop,
|
|
10
|
+
isHexColor,
|
|
11
|
+
} from "./contract.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* REQ-THEME-SURFACE: the token overrides a container's CHILDREN inherit.
|
|
15
|
+
*
|
|
16
|
+
* A container that paints its own `backgroundColor` becomes the surface its
|
|
17
|
+
* subtree sits on, so an unset text colour there must read against THAT fill
|
|
18
|
+
* rather than the page's. Without this, the only way to keep a heading legible
|
|
19
|
+
* inside a dark card was to write a literal hex onto the node — which is what
|
|
20
|
+
* froze a built app's appearance against its own theme.
|
|
21
|
+
*
|
|
22
|
+
* Returns the SAME reference when nothing changes, so an untouched page
|
|
23
|
+
* allocates nothing and every child sees the identical context value.
|
|
24
|
+
*
|
|
25
|
+
* A photo-backed container is deliberately NOT handled here: no colour can be
|
|
26
|
+
* derived from an image, so its subtree keeps what it inherited and the scrim
|
|
27
|
+
* plus an explicit light text colour carries the hero.
|
|
28
|
+
*
|
|
29
|
+
* The web Player calls this; the export's generated layout renderer mirrors
|
|
30
|
+
* it — the two hosts must not differ here (CLAUDE.md §8).
|
|
31
|
+
*/
|
|
32
|
+
export function surfaceAwareTheme(tenantTheme, backgroundColor) {
|
|
33
|
+
const surfaceTokens = deriveSurfaceTokens(backgroundColor);
|
|
34
|
+
if (!surfaceTokens) return tenantTheme;
|
|
35
|
+
const t = tenantTheme && typeof tenantTheme === "object" ? tenantTheme : {};
|
|
36
|
+
const typography = t.typography || null;
|
|
37
|
+
// The look's UNTOUCHED ink, carried forward so nesting stays idempotent. A
|
|
38
|
+
// band re-inks to the platform's light ink; a white card inside it must be
|
|
39
|
+
// judged against the LOOK's ink, or it can only ever land back on the
|
|
40
|
+
// platform default and the look's voice is lost one level in.
|
|
41
|
+
const base =
|
|
42
|
+
(typography && (typography.rolesBase || typography.roles)) || null;
|
|
43
|
+
// `base` when nothing needs replacing, which is what RESTORES the look's ink
|
|
44
|
+
// on a card nested inside an already-re-inked band. Falling through to the
|
|
45
|
+
// inherited `roles` there would leave the band's platform ink in place and
|
|
46
|
+
// the look could never come back.
|
|
47
|
+
const nextRoles = rolesOnSurface(base, surfaceTokens, backgroundColor) || base;
|
|
48
|
+
// sc-9346: buttons judge this fill against the surface the look was
|
|
49
|
+
// designed for, taken once at the outermost section and kept on nesting.
|
|
50
|
+
const colors = t.colors || {};
|
|
51
|
+
const backdropBase =
|
|
52
|
+
colors.backdropBase || colors.surface || DEFAULT_THEME_TOKENS.colors.surface;
|
|
53
|
+
const next = {
|
|
54
|
+
...t,
|
|
55
|
+
colors: { ...colors, ...surfaceTokens, backdrop: backgroundColor, backdropBase },
|
|
56
|
+
};
|
|
57
|
+
if (typography && nextRoles && nextRoles !== typography.roles) {
|
|
58
|
+
next.typography = { ...typography, roles: nextRoles, rolesBase: base };
|
|
59
|
+
}
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* sc-8370: re-ink the type tiers for a surface their own ink cannot be read on.
|
|
65
|
+
*
|
|
66
|
+
* `roleColor` returns a tier's explicit hex before it ever consults the surface
|
|
67
|
+
* token, so patching `colors` alone cannot move text that carries one — and
|
|
68
|
+
* since sc-8131 every look states an ink for all six tiers, chosen against that
|
|
69
|
+
* colourway's PAGE background. A section painting its own fill is a different
|
|
70
|
+
* surface, and nothing re-derived the ink for it: a headline shipped dark on a
|
|
71
|
+
* dark opener band.
|
|
72
|
+
*
|
|
73
|
+
* The gate is MEASURED CONTRAST, not a light/dark polarity test. Polarity is
|
|
74
|
+
* the tempting rule and it is wrong in both directions. The platform's own
|
|
75
|
+
* predicate flips at luminance 0.55, while the real break-even between the two
|
|
76
|
+
* inks sits near 0.20 — so every mid-tone fill in that band reads as "wants
|
|
77
|
+
* light text" when dark text is in fact the stronger choice. On `#a8a29e`, a
|
|
78
|
+
* shipped preset secondary, a polarity rule swaps a look's ink at 7.0:1 for
|
|
79
|
+
* the platform's at 2.4:1: it would have turned a passing section into an AA
|
|
80
|
+
* failure, which is the very defect this function exists to remove.
|
|
81
|
+
*
|
|
82
|
+
* So a tier is left alone whenever it is already READABLE, which is what keeps
|
|
83
|
+
* a look's voice everywhere it works, and is replaced only where it is not AND
|
|
84
|
+
* the derived token is genuinely better. A wash with no good answer — a
|
|
85
|
+
* white-to-black band, whose midpoint favours neither ink — therefore keeps
|
|
86
|
+
* what it inherited instead of committing to a colour that is wrong for half
|
|
87
|
+
* the band.
|
|
88
|
+
*
|
|
89
|
+
* Returns null when nothing moves, so the caller leaves `typography` untouched
|
|
90
|
+
* and an unaffected page keeps its identical context value.
|
|
91
|
+
*/
|
|
92
|
+
function rolesOnSurface(roles, surfaceTokens, backgroundColor) {
|
|
93
|
+
let out = null;
|
|
94
|
+
for (const [role, seed] of Object.entries(
|
|
95
|
+
DEFAULT_THEME_TOKENS.typography.roles,
|
|
96
|
+
)) {
|
|
97
|
+
const current = roles && roles[role] && roles[role].color;
|
|
98
|
+
// A tier with no frozen ink already resolves through `colors` per render.
|
|
99
|
+
if (!isHexColor(current)) continue;
|
|
100
|
+
const derived = surfaceTokens[seed.colorToken];
|
|
101
|
+
// The quiet tiers are body-sized too, so they get no softer bar.
|
|
102
|
+
const ink = inkForBackdrop(current, derived, backgroundColor, INK_CONTRAST.text);
|
|
103
|
+
if (ink === current) continue;
|
|
104
|
+
if (!out) out = { ...roles };
|
|
105
|
+
out[role] = { ...roles[role], color: derived };
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|