@colixsystems/widget-sdk 0.236.0 → 0.238.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 +29 -4
- package/dist/checkbox-view.js +28 -16
- package/dist/contract.cjs +57 -24
- package/dist/contract.js +57 -24
- package/dist/file-upload-logic.js +178 -0
- package/dist/file-upload-view.js +321 -0
- package/dist/file-upload.js +62 -0
- package/dist/file-upload.native.js +11 -0
- package/dist/filepicker.js +11 -0
- package/dist/filepicker.native.js +10 -1
- package/dist/form-control-view.js +8 -1
- package/dist/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +168 -19
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/list-tokens.js +54 -14
- package/dist/list-view.js +199 -82
- package/dist/list.js +7 -1
- package/dist/list.native.js +3 -2
- package/dist/primitives.js +1 -3
- package/dist/primitives.native.js +2 -4
- package/dist/search-field-tokens.js +85 -0
- package/dist/search-field-view.js +39 -26
- package/dist/select-logic.js +83 -0
- package/dist/select-view.js +165 -32
- package/dist/select.js +6 -1
- package/dist/select.native.js +5 -3
- package/package.json +1 -1
- package/src/contract.js +57 -24
package/dist/list-view.js
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
resolveListTokens,
|
|
15
15
|
listRowFrame,
|
|
16
16
|
listSideEdgeWidth,
|
|
17
|
+
listCardRowFrame,
|
|
18
|
+
listCardGap,
|
|
19
|
+
listVariant,
|
|
17
20
|
LIST_TOKEN_NAMES,
|
|
18
21
|
LIST_SELECTED_BAR_WIDTH,
|
|
19
22
|
} from "./list-tokens.js";
|
|
@@ -33,6 +36,10 @@ function listOverrides(props) {
|
|
|
33
36
|
return out;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
function hasNode(node) {
|
|
40
|
+
return node !== undefined && node !== null && node !== false;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
function soleElement(children) {
|
|
37
44
|
const parts = React.Children.toArray(children);
|
|
38
45
|
return parts.length === 1 && React.isValidElement(parts[0]) ? parts[0] : null;
|
|
@@ -50,16 +57,31 @@ const HORIZONTAL_PADDING = [
|
|
|
50
57
|
/**
|
|
51
58
|
* Bind the shared list components to one platform's primitives.
|
|
52
59
|
*
|
|
53
|
-
* @param {{View: any, Text: any, Pressable: any}} rn
|
|
60
|
+
* @param {{View: any, Text: any, Pressable: any, Card: any}} rn — `Card` is
|
|
61
|
+
* the platform's bound `<Card>`, the surface of a card-style list's rows.
|
|
54
62
|
*/
|
|
55
63
|
export function makeList(rn) {
|
|
56
|
-
const { View, Text, Pressable } = rn;
|
|
64
|
+
const { View, Text, Pressable, Card } = rn;
|
|
57
65
|
const ListContext = React.createContext(null);
|
|
58
66
|
|
|
67
|
+
// What a `<List>` hands its rows: overrides, the widget's defaults, its variant.
|
|
68
|
+
// Tokens carry into a nested list; the variant is each list's own.
|
|
69
|
+
function listContextValue(props, inherited) {
|
|
70
|
+
const { variant, defaults } = props;
|
|
71
|
+
return {
|
|
72
|
+
overrides: { ...(inherited && inherited.overrides), ...listOverrides(props) },
|
|
73
|
+
defaults: { ...(inherited && inherited.defaults), ...defaults },
|
|
74
|
+
variant: listVariant(variant),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
function useRowTokens(props) {
|
|
60
79
|
const theme = useListTheme();
|
|
61
80
|
const inherited = React.useContext(ListContext);
|
|
62
|
-
|
|
81
|
+
const list = listContextValue(props, inherited);
|
|
82
|
+
// Only a `<List>` chooses the variant; a row follows it.
|
|
83
|
+
const variant = inherited ? inherited.variant : "plain";
|
|
84
|
+
return [theme, resolveListTokens(theme, list.overrides, list.defaults), { ...list, variant }];
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
// A lone row View spans the row, as the title block does. Anything
|
|
@@ -71,52 +93,67 @@ export function makeList(rn) {
|
|
|
71
93
|
: children;
|
|
72
94
|
}
|
|
73
95
|
|
|
96
|
+
function titleBlock(title, subtitle, typography, colors) {
|
|
97
|
+
return React.createElement(
|
|
98
|
+
View,
|
|
99
|
+
{ style: { flex: 1, gap: 2 } },
|
|
100
|
+
title !== undefined && title !== null
|
|
101
|
+
? React.createElement(
|
|
102
|
+
Text,
|
|
103
|
+
{ style: textRoleStyle("subtitle", typography, colors), numberOfLines: 1 },
|
|
104
|
+
title,
|
|
105
|
+
)
|
|
106
|
+
: null,
|
|
107
|
+
subtitle !== undefined && subtitle !== null
|
|
108
|
+
? React.createElement(
|
|
109
|
+
Text,
|
|
110
|
+
{ style: textRoleStyle("caption", typography, colors), numberOfLines: 2 },
|
|
111
|
+
subtitle,
|
|
112
|
+
)
|
|
113
|
+
: null,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rowBody(props, typography, colors) {
|
|
118
|
+
return props.children !== undefined
|
|
119
|
+
? customBody(props.children)
|
|
120
|
+
: titleBlock(props.title, props.subtitle, typography, colors);
|
|
121
|
+
}
|
|
122
|
+
|
|
74
123
|
function setsOwnInset(style) {
|
|
75
124
|
const flat = flattenStyle(style);
|
|
76
125
|
return HORIZONTAL_PADDING.some((key) => flat[key] !== undefined);
|
|
77
126
|
}
|
|
78
127
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const inherited = React.useContext(ListContext);
|
|
83
|
-
const overrides = { ...inherited, ...listOverrides(props) };
|
|
84
|
-
const tokens = resolveListTokens(theme, overrides);
|
|
85
|
-
// No divider under the last row: the list's own edge ends it.
|
|
86
|
-
const rows = React.Children.toArray(children);
|
|
87
|
-
const last = rows.length - 1;
|
|
88
|
-
const content = rows.map((row, i) =>
|
|
89
|
-
i === last && React.isValidElement(row) && row.props.divider === undefined
|
|
90
|
-
? React.cloneElement(row, { divider: false })
|
|
91
|
-
: row,
|
|
92
|
-
);
|
|
93
|
-
return React.createElement(
|
|
94
|
-
ListContext.Provider,
|
|
95
|
-
{ value: overrides },
|
|
96
|
-
React.createElement(View, { style: [{ gap: tokens.gap }, style], testID }, content),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
List.displayName = "List";
|
|
100
|
-
|
|
101
|
-
function ListRow(props) {
|
|
102
|
-
const {
|
|
103
|
-
title,
|
|
104
|
-
subtitle,
|
|
105
|
-
leading,
|
|
106
|
-
trailing,
|
|
128
|
+
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
129
|
+
function pressProps({ onPress, disabled = false, selected = false, accessibilityLabel }) {
|
|
130
|
+
return {
|
|
107
131
|
onPress,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
divider = true,
|
|
132
|
+
disabled,
|
|
133
|
+
accessibilityRole: "button",
|
|
111
134
|
accessibilityLabel,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const [theme, tokens] = useRowTokens(props);
|
|
117
|
-
const { colors, spacing, typography } = theme;
|
|
135
|
+
"aria-selected": selected,
|
|
136
|
+
"aria-disabled": disabled,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
118
139
|
|
|
119
|
-
|
|
140
|
+
// Beside `actions`, the tap target is the row minus its buttons,
|
|
141
|
+
// laid out as the row is so nothing moves.
|
|
142
|
+
function pressRegionStyle(style, spacing) {
|
|
143
|
+
const flat = flattenStyle(style);
|
|
144
|
+
return {
|
|
145
|
+
flex: 1,
|
|
146
|
+
minWidth: 0,
|
|
147
|
+
alignSelf: "stretch",
|
|
148
|
+
flexDirection: "row",
|
|
149
|
+
alignItems: flat.alignItems ?? "center",
|
|
150
|
+
gap: flat.gap ?? spacing.md,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function plainRowStyle(props, tokens, spacing) {
|
|
155
|
+
const { selected = false, divider = true, style } = props;
|
|
156
|
+
const pressable = typeof props.onPress === "function";
|
|
120
157
|
// A tile is outlined; a flush band is separated by its bottom divider.
|
|
121
158
|
const edge = tokens.separated
|
|
122
159
|
? tokens.dividerWidth > 0
|
|
@@ -125,7 +162,7 @@ export function makeList(rn) {
|
|
|
125
162
|
: divider && tokens.dividerWidth > 0
|
|
126
163
|
? { borderBottomWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
|
|
127
164
|
: null;
|
|
128
|
-
|
|
165
|
+
return {
|
|
129
166
|
// An inset the widget sets itself is left exactly as it asked.
|
|
130
167
|
...listRowFrame(tokens, spacing, selected && !setsOwnInset(style)),
|
|
131
168
|
paddingVertical: tokens.rowPadding,
|
|
@@ -143,44 +180,44 @@ export function makeList(rn) {
|
|
|
143
180
|
}
|
|
144
181
|
: null),
|
|
145
182
|
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The row's slots, with its own buttons kept outside the tap target.
|
|
186
|
+
function rowSlots(props, row) {
|
|
187
|
+
const { leading, trailing, actions, disabled = false, style } = props;
|
|
188
|
+
const body = rowBody(props, row.theme.typography, row.colors);
|
|
189
|
+
if (!row.splitPress) return [leading || null, body, trailing || null, actions || null];
|
|
190
|
+
return [
|
|
191
|
+
React.createElement(
|
|
192
|
+
Pressable,
|
|
193
|
+
{
|
|
194
|
+
...pressProps(props),
|
|
195
|
+
onPressIn: () => row.setPressed(true),
|
|
196
|
+
onPressOut: () => row.setPressed(false),
|
|
197
|
+
style: [pressRegionStyle(style, row.theme.spacing), disabled ? { opacity: 0.5 } : null],
|
|
198
|
+
},
|
|
199
|
+
leading || null,
|
|
200
|
+
body,
|
|
201
|
+
trailing || null,
|
|
202
|
+
),
|
|
203
|
+
actions,
|
|
204
|
+
];
|
|
205
|
+
}
|
|
146
206
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
{ style: { flex: 1, gap: 2 } },
|
|
153
|
-
title !== undefined && title !== null
|
|
154
|
-
? React.createElement(
|
|
155
|
-
Text,
|
|
156
|
-
{
|
|
157
|
-
style: textRoleStyle("subtitle", typography, colors),
|
|
158
|
-
numberOfLines: 1,
|
|
159
|
-
},
|
|
160
|
-
title,
|
|
161
|
-
)
|
|
162
|
-
: null,
|
|
163
|
-
subtitle !== undefined && subtitle !== null
|
|
164
|
-
? React.createElement(
|
|
165
|
-
Text,
|
|
166
|
-
{
|
|
167
|
-
style: textRoleStyle("caption", typography, colors),
|
|
168
|
-
numberOfLines: 2,
|
|
169
|
-
},
|
|
170
|
-
subtitle,
|
|
171
|
-
)
|
|
172
|
-
: null,
|
|
173
|
-
);
|
|
174
|
-
const content = [leading || null, body, trailing || null];
|
|
207
|
+
function PlainRow({ props, row }) {
|
|
208
|
+
const { disabled = false, accessibilityLabel, style, testID } = props;
|
|
209
|
+
const rowStyle = plainRowStyle(props, row.tokens, row.theme.spacing);
|
|
210
|
+
const pressedFill = { backgroundColor: row.tokens.pressedBackground };
|
|
211
|
+
const content = rowSlots(props, { ...row, colors: row.theme.colors });
|
|
175
212
|
|
|
176
|
-
if (!pressable) {
|
|
213
|
+
if (!row.pressable || row.splitPress) {
|
|
177
214
|
return React.createElement(
|
|
178
215
|
View,
|
|
179
216
|
{
|
|
180
|
-
style: [rowStyle, style],
|
|
217
|
+
style: [rowStyle, row.pressed ? pressedFill : null, style],
|
|
181
218
|
testID,
|
|
182
|
-
accessibilityLabel,
|
|
183
|
-
accessible: accessibilityLabel ? true : undefined,
|
|
219
|
+
accessibilityLabel: row.splitPress ? undefined : accessibilityLabel,
|
|
220
|
+
accessible: accessibilityLabel && !row.splitPress ? true : undefined,
|
|
184
221
|
},
|
|
185
222
|
...content,
|
|
186
223
|
);
|
|
@@ -188,17 +225,11 @@ export function makeList(rn) {
|
|
|
188
225
|
return React.createElement(
|
|
189
226
|
Pressable,
|
|
190
227
|
{
|
|
191
|
-
|
|
192
|
-
disabled,
|
|
193
|
-
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
194
|
-
accessibilityRole: "button",
|
|
195
|
-
accessibilityLabel,
|
|
196
|
-
"aria-selected": selected,
|
|
197
|
-
"aria-disabled": disabled,
|
|
228
|
+
...pressProps(props),
|
|
198
229
|
testID,
|
|
199
230
|
style: ({ pressed }) => [
|
|
200
231
|
rowStyle,
|
|
201
|
-
pressed ?
|
|
232
|
+
pressed ? pressedFill : null,
|
|
202
233
|
disabled ? { opacity: 0.5 } : null,
|
|
203
234
|
style,
|
|
204
235
|
],
|
|
@@ -206,6 +237,92 @@ export function makeList(rn) {
|
|
|
206
237
|
...content,
|
|
207
238
|
);
|
|
208
239
|
}
|
|
240
|
+
|
|
241
|
+
// Each row is the app's `<Card>`; the card scope paints it, the list
|
|
242
|
+
// scope arranges what is inside.
|
|
243
|
+
function CardRow({ props, row }) {
|
|
244
|
+
const { selected = false, disabled = false, accessibilityLabel, style, testID } = props;
|
|
245
|
+
const { tokens, theme } = row;
|
|
246
|
+
const frame = listCardRowFrame(tokens, theme.spacing, row.pressable);
|
|
247
|
+
// The card's own border carries the accent, so nothing shifts.
|
|
248
|
+
const mark = selected
|
|
249
|
+
? { backgroundColor: tokens.selectedBackground, borderColor: tokens.selectedAccent }
|
|
250
|
+
: null;
|
|
251
|
+
const pressedFill = { backgroundColor: tokens.pressedBackground };
|
|
252
|
+
// A function child reads the inks of the card's own fill.
|
|
253
|
+
const content = ({ colors }) =>
|
|
254
|
+
React.createElement(React.Fragment, null, ...rowSlots(props, { ...row, colors }));
|
|
255
|
+
|
|
256
|
+
if (!row.pressable || row.splitPress) {
|
|
257
|
+
return React.createElement(
|
|
258
|
+
Card,
|
|
259
|
+
{
|
|
260
|
+
testID,
|
|
261
|
+
accessibilityLabel: row.splitPress ? undefined : accessibilityLabel,
|
|
262
|
+
accessible: accessibilityLabel && !row.splitPress ? true : undefined,
|
|
263
|
+
containerStyle: [frame, mark, row.pressed ? pressedFill : null, style],
|
|
264
|
+
},
|
|
265
|
+
content,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return React.createElement(
|
|
269
|
+
Card,
|
|
270
|
+
{
|
|
271
|
+
as: Pressable,
|
|
272
|
+
fillInGradient: true,
|
|
273
|
+
...pressProps(props),
|
|
274
|
+
testID,
|
|
275
|
+
containerStyle: ({ pressed }) => [
|
|
276
|
+
frame,
|
|
277
|
+
mark,
|
|
278
|
+
pressed ? pressedFill : null,
|
|
279
|
+
disabled ? { opacity: 0.5 } : null,
|
|
280
|
+
style,
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
content,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function List(props) {
|
|
288
|
+
const { children, style, testID } = props;
|
|
289
|
+
const theme = useListTheme();
|
|
290
|
+
const value = listContextValue(props, React.useContext(ListContext));
|
|
291
|
+
const tokens = resolveListTokens(theme, value.overrides, value.defaults);
|
|
292
|
+
const gap = value.variant === "card" ? listCardGap(tokens, theme.spacing) : tokens.gap;
|
|
293
|
+
// No divider under the last row: the list's own edge ends it.
|
|
294
|
+
const rows = React.Children.toArray(children);
|
|
295
|
+
const last = rows.length - 1;
|
|
296
|
+
const content = rows.map((row, i) =>
|
|
297
|
+
i === last && React.isValidElement(row) && row.props.divider === undefined
|
|
298
|
+
? React.cloneElement(row, { divider: false })
|
|
299
|
+
: row,
|
|
300
|
+
);
|
|
301
|
+
return React.createElement(
|
|
302
|
+
ListContext.Provider,
|
|
303
|
+
{ value },
|
|
304
|
+
React.createElement(View, { style: [{ gap }, style], testID }, content),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
List.displayName = "List";
|
|
308
|
+
|
|
309
|
+
function ListRow(props) {
|
|
310
|
+
const [theme, tokens, list] = useRowTokens(props);
|
|
311
|
+
const [pressed, setPressed] = React.useState(false);
|
|
312
|
+
const pressable = typeof props.onPress === "function";
|
|
313
|
+
const row = {
|
|
314
|
+
theme,
|
|
315
|
+
tokens,
|
|
316
|
+
pressable,
|
|
317
|
+
// Buttons inside a button are unreachable; `actions` sit beside it.
|
|
318
|
+
splitPress: pressable && hasNode(props.actions),
|
|
319
|
+
pressed,
|
|
320
|
+
setPressed,
|
|
321
|
+
};
|
|
322
|
+
return list.variant === "card"
|
|
323
|
+
? React.createElement(CardRow, { props, row })
|
|
324
|
+
: React.createElement(PlainRow, { props, row });
|
|
325
|
+
}
|
|
209
326
|
ListRow.displayName = "ListRow";
|
|
210
327
|
|
|
211
328
|
// A table's column labels: a ListRow's frame and body slot with none of its
|
package/dist/list.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
// ./list-view.js to react-native-web; see ./primitives.js for why not the bare specifier.
|
|
3
3
|
|
|
4
4
|
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { Card } from "./card.js";
|
|
5
6
|
import { makeList } from "./list-view.js";
|
|
6
7
|
|
|
7
|
-
export const { List, ListRow, ListHeader, EmptyState } = makeList(
|
|
8
|
+
export const { List, ListRow, ListHeader, EmptyState } = makeList({
|
|
9
|
+
View: ReactNative.View,
|
|
10
|
+
Text: ReactNative.Text,
|
|
11
|
+
Pressable: ReactNative.Pressable,
|
|
12
|
+
Card,
|
|
13
|
+
});
|
package/dist/list.native.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `<List>`, `<ListRow>`, `<ListHeader>`, `<EmptyState>` (native). Binds
|
|
2
|
-
// ./list-view.js to React Native's own primitives; only the
|
|
2
|
+
// ./list-view.js to React Native's own primitives; only the imports differ from ./list.js.
|
|
3
3
|
|
|
4
4
|
import { View, Text, Pressable } from "react-native";
|
|
5
|
+
import { Card } from "./card.native.js";
|
|
5
6
|
import { makeList } from "./list-view.js";
|
|
6
7
|
|
|
7
|
-
export const { List, ListRow, ListHeader, EmptyState } = makeList({ View, Text, Pressable });
|
|
8
|
+
export const { List, ListRow, ListHeader, EmptyState } = makeList({ View, Text, Pressable, Card });
|
package/dist/primitives.js
CHANGED
|
@@ -73,9 +73,7 @@ export { Icon } from "./icon.js";
|
|
|
73
73
|
export { DateTimePicker } from "./datetimepicker.js";
|
|
74
74
|
// sc-1378 — `<FilePicker>` (web). Wraps a hidden DOM `<input type="file">`
|
|
75
75
|
// because react-native-web has no native equivalent. The native build's
|
|
76
|
-
// counterpart (./filepicker.native.js)
|
|
77
|
-
// a vetted expo-document-picker pin lands. `isSupported` is a static
|
|
78
|
-
// boolean widgets branch on to hide the trigger on native.
|
|
76
|
+
// counterpart (./filepicker.native.js) opens the OS document picker.
|
|
79
77
|
export { FilePicker } from "./filepicker.js";
|
|
80
78
|
// REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (web). Paints a CSS
|
|
81
79
|
// `linear-gradient` on a View; the native build (./gradient.native.js) delegates
|
|
@@ -31,10 +31,8 @@ export { Icon } from "./icon.js";
|
|
|
31
31
|
// `./datetimepicker.js`. Both files honour the same ISO 8601 value /
|
|
32
32
|
// onChange contract.
|
|
33
33
|
export { DateTimePicker } from "./datetimepicker.native.js";
|
|
34
|
-
// sc-1378 — `<FilePicker>` (native).
|
|
35
|
-
// wraps a hidden
|
|
36
|
-
// equivalent yet, so this build renders a disabled trigger and exposes
|
|
37
|
-
// `FilePicker.isSupported = false` for widgets to branch on.
|
|
34
|
+
// sc-1378 — `<FilePicker>` (native). Opens the OS document picker
|
|
35
|
+
// (expo-document-picker); the web counterpart wraps a hidden file input.
|
|
38
36
|
export { FilePicker } from "./filepicker.native.js";
|
|
39
37
|
// REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (native). Delegates to
|
|
40
38
|
// expo-linear-gradient (pinned unconditionally in every export); the web build
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// What `<SearchField>` draws from the theme's `search` scope: where its
|
|
2
|
+
// magnifier sits, and the field look it lays over `<TextField>`'s `muted` one.
|
|
3
|
+
// Platform-free, so both hosts lay the field out from the same answer.
|
|
4
|
+
|
|
5
|
+
import { CONTRACT } from "./contract.js";
|
|
6
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
7
|
+
import { resolveTextFieldTokens } from "./text-field-tokens.js";
|
|
8
|
+
|
|
9
|
+
export const SEARCH_ICON_POSITIONS = CONTRACT.themeComponentSearchIconPositions;
|
|
10
|
+
export const DEFAULT_SEARCH_ICON_POSITION = "start";
|
|
11
|
+
|
|
12
|
+
// The `search` tokens that restyle the field; each is a `<TextField>` override.
|
|
13
|
+
export const SEARCH_FIELD_LOOK_TOKENS = Object.freeze([
|
|
14
|
+
"background",
|
|
15
|
+
"borderColor",
|
|
16
|
+
"radius",
|
|
17
|
+
"height",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function searchScope(theme) {
|
|
21
|
+
const components = theme && typeof theme === "object" ? theme.components : undefined;
|
|
22
|
+
return normaliseThemeComponents({ search: components && components.search }).search || {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The magnifier's position: the field's own prop, then the theme, then start.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown} own — the `iconPosition` prop.
|
|
29
|
+
* @param {unknown} themed — the theme's `search.iconPosition`.
|
|
30
|
+
*/
|
|
31
|
+
export function searchIconPosition(own, themed) {
|
|
32
|
+
if (SEARCH_ICON_POSITIONS.includes(own)) return own;
|
|
33
|
+
if (SEARCH_ICON_POSITIONS.includes(themed)) return themed;
|
|
34
|
+
return DEFAULT_SEARCH_ICON_POSITION;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve one search field. `field` holds only the tokens the theme set, so
|
|
39
|
+
* an unset one leaves `<TextField>` on the `muted` look it always drew.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
42
|
+
* @param {{ iconPosition?: string }} [own] — the field's own props.
|
|
43
|
+
* @returns {{ iconPosition: string, field: Record<string, string|number> }}
|
|
44
|
+
*/
|
|
45
|
+
export function resolveSearchFieldTokens(theme, own) {
|
|
46
|
+
const scope = searchScope(theme);
|
|
47
|
+
const field = {};
|
|
48
|
+
for (const name of SEARCH_FIELD_LOOK_TOKENS) {
|
|
49
|
+
if (scope[name] !== undefined) field[name] = scope[name];
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
iconPosition: searchIconPosition(own && own.iconPosition, scope.iconPosition),
|
|
53
|
+
field,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where the glyph and the clear control go. At the end, the clear control
|
|
59
|
+
* takes the glyph's slot while there is text, so the two never overlap.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} iconPosition — a resolved SEARCH_ICON_POSITIONS value.
|
|
62
|
+
* @param {boolean} clearable — the field has text and is enabled.
|
|
63
|
+
* @returns {{ glyph: "start"|"end"|null, padStart: boolean, padEnd: boolean }}
|
|
64
|
+
*/
|
|
65
|
+
export function searchFieldLayout(iconPosition, clearable) {
|
|
66
|
+
const glyphAtEnd = iconPosition === "end" && !clearable;
|
|
67
|
+
return {
|
|
68
|
+
glyph: iconPosition === "start" ? "start" : glyphAtEnd ? "end" : null,
|
|
69
|
+
padStart: iconPosition === "start",
|
|
70
|
+
padEnd: clearable || iconPosition === "end",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What each `search` token resolves to while unset, keyed by token name, for
|
|
76
|
+
* the Studio to preview: the `muted` field the search box falls back to.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} theme — the resolved workspace theme.
|
|
79
|
+
*/
|
|
80
|
+
export function searchScopeDefaults(theme) {
|
|
81
|
+
const field = resolveTextFieldTokens(theme);
|
|
82
|
+
const out = { iconPosition: DEFAULT_SEARCH_ICON_POSITION };
|
|
83
|
+
for (const name of SEARCH_FIELD_LOOK_TOKENS) out[name] = field[name];
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// The ONE implementation behind the `<SearchField>` SDK primitive. It is
|
|
2
2
|
// `<TextField>` with a search glyph and a clear control, so it wears the same
|
|
3
|
-
// field and takes the same props
|
|
4
|
-
// `search-field.native.js`) differ only in where
|
|
3
|
+
// field and takes the same props, plus the theme's `search` look; the platform
|
|
4
|
+
// bindings (`search-field.js` / `search-field.native.js`) differ only in where
|
|
5
|
+
// the primitives come from.
|
|
5
6
|
|
|
6
7
|
import React from "react";
|
|
7
8
|
import { useHostTheme } from "./hooks.js";
|
|
8
9
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
9
10
|
import { Icon } from "./icon.js";
|
|
10
11
|
import { resolveFormControlTokens } from "./form-control-tokens.js";
|
|
12
|
+
import { resolveSearchFieldTokens, searchFieldLayout } from "./search-field-tokens.js";
|
|
11
13
|
import {
|
|
12
14
|
errorMessage,
|
|
13
15
|
hasNote,
|
|
@@ -24,6 +26,17 @@ const ADORNMENT_WIDTH = 40;
|
|
|
24
26
|
// A real target: the web host ignores hitSlop, so size carries it.
|
|
25
27
|
const CLEAR_MIN_SIZE = 36;
|
|
26
28
|
|
|
29
|
+
function adornmentSlot(side) {
|
|
30
|
+
return {
|
|
31
|
+
position: "absolute",
|
|
32
|
+
top: 0,
|
|
33
|
+
bottom: 0,
|
|
34
|
+
alignItems: "center",
|
|
35
|
+
justifyContent: "center",
|
|
36
|
+
...(side === "start" ? { left: 0 } : { right: 0 }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/**
|
|
28
41
|
* Bind the shared search field to one platform's primitives.
|
|
29
42
|
*
|
|
@@ -45,6 +58,7 @@ export function makeSearchField(rn) {
|
|
|
45
58
|
error,
|
|
46
59
|
disabled = false,
|
|
47
60
|
accessibilityLabel,
|
|
61
|
+
iconPosition,
|
|
48
62
|
style,
|
|
49
63
|
containerStyle,
|
|
50
64
|
testID,
|
|
@@ -52,6 +66,7 @@ export function makeSearchField(rn) {
|
|
|
52
66
|
} = props;
|
|
53
67
|
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
54
68
|
const t = resolveFormControlTokens(theme);
|
|
69
|
+
const search = resolveSearchFieldTokens(theme, { iconPosition });
|
|
55
70
|
const inputRef = React.useRef(null);
|
|
56
71
|
const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
|
|
57
72
|
const noteId = React.useId();
|
|
@@ -60,6 +75,8 @@ export function makeSearchField(rn) {
|
|
|
60
75
|
if (input && typeof input.focus === "function") input.focus();
|
|
61
76
|
};
|
|
62
77
|
const hasText = typeof value === "string" && value.length > 0;
|
|
78
|
+
const clearable = hasText && !disabled;
|
|
79
|
+
const layout = searchFieldLayout(search.iconPosition, clearable);
|
|
63
80
|
const clear = () => {
|
|
64
81
|
if (typeof onChangeText === "function") onChangeText("");
|
|
65
82
|
if (typeof onClear === "function") onClear();
|
|
@@ -98,30 +115,30 @@ export function makeSearchField(rn) {
|
|
|
98
115
|
autoCapitalize: "none",
|
|
99
116
|
autoCorrect: false,
|
|
100
117
|
testID,
|
|
118
|
+
// The field's own props beat the theme's search look.
|
|
119
|
+
...search.field,
|
|
101
120
|
...rest,
|
|
102
121
|
style: [
|
|
103
|
-
{ paddingLeft: ADORNMENT_WIDTH },
|
|
104
|
-
|
|
122
|
+
layout.padStart ? { paddingLeft: ADORNMENT_WIDTH } : null,
|
|
123
|
+
layout.padEnd ? { paddingRight: ADORNMENT_WIDTH } : null,
|
|
105
124
|
style,
|
|
106
125
|
],
|
|
107
126
|
}),
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
),
|
|
124
|
-
hasText && !disabled
|
|
127
|
+
layout.glyph
|
|
128
|
+
? h(
|
|
129
|
+
View,
|
|
130
|
+
{
|
|
131
|
+
testID: testID ? `${testID}-icon` : undefined,
|
|
132
|
+
style: {
|
|
133
|
+
...adornmentSlot(layout.glyph),
|
|
134
|
+
width: ADORNMENT_WIDTH,
|
|
135
|
+
pointerEvents: "none",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
h(Icon, { name: "search", size: GLYPH_SIZE, color: t.placeholderColor }),
|
|
139
|
+
)
|
|
140
|
+
: null,
|
|
141
|
+
clearable
|
|
125
142
|
? h(
|
|
126
143
|
Pressable,
|
|
127
144
|
{
|
|
@@ -131,14 +148,10 @@ export function makeSearchField(rn) {
|
|
|
131
148
|
testID: testID ? `${testID}-clear` : undefined,
|
|
132
149
|
style: ({ pressed }) => [
|
|
133
150
|
{
|
|
134
|
-
|
|
151
|
+
...adornmentSlot("end"),
|
|
135
152
|
right: 2,
|
|
136
|
-
top: 0,
|
|
137
|
-
bottom: 0,
|
|
138
153
|
minWidth: CLEAR_MIN_SIZE,
|
|
139
154
|
minHeight: CLEAR_MIN_SIZE,
|
|
140
|
-
alignItems: "center",
|
|
141
|
-
justifyContent: "center",
|
|
142
155
|
},
|
|
143
156
|
pressed ? { opacity: 0.6 } : null,
|
|
144
157
|
],
|