@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.
@@ -0,0 +1,321 @@
1
+ // The ONE implementation behind `<FileUpload>`. It is built on `<FilePicker>`,
2
+ // which owns opening each platform's file dialog, and draws the themed field
3
+ // around it: the button, the chosen files with their size and a remove
4
+ // control, and the uploading, error and disabled states. It uploads nothing
5
+ // itself; the caller passes `onPick` to `useFilestoreUpload().upload`.
6
+ //
7
+ // The bindings differ in their primitives and in one extra: the web binding
8
+ // passes a `DropZone`, so files can also be dropped onto the field there.
9
+
10
+ import React from "react";
11
+ import { useHostSharedText, useHostTheme } from "./hooks.js";
12
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
13
+ import { Icon } from "./icon.js";
14
+ import { focusHaloStyle, resolveFormControlTokens } from "./form-control-tokens.js";
15
+ import { errorMessage, hasNote, isShown, isText } from "./form-control-logic.js";
16
+ import { makeFieldFrame } from "./form-control-view.js";
17
+ import { fileKey, formatFileSize, pickPayload, planPick } from "./file-upload-logic.js";
18
+
19
+ const h = React.createElement;
20
+ // The remove control's real size; a web host ignores hitSlop.
21
+ const REMOVE_TARGET = 36;
22
+ const HIDDEN = {
23
+ "aria-hidden": true,
24
+ importantForAccessibility: "no-hide-descendants",
25
+ accessibilityElementsHidden: true,
26
+ };
27
+
28
+ /**
29
+ * Bind the shared file upload to one platform's primitives.
30
+ *
31
+ * @param {{View: any, Text: any, Pressable: any, Platform: any, FilePicker: any, Button: any, Spinner: any}} rn
32
+ * @param {{DropZone?: any}} [extras] — the web's drop target; absent on native.
33
+ */
34
+ export function makeFileUpload(rn, extras) {
35
+ const { View, Text, Pressable, Platform, FilePicker, Button, Spinner } = rn;
36
+ const DropZone = (extras && extras.DropZone) || null;
37
+ const FieldFrame = makeFieldFrame(rn);
38
+
39
+ // Keyboard focus is a web concern; a device's reader moves on by itself.
40
+ function focusNode(node) {
41
+ if (Platform.OS !== "web" || !node) return false;
42
+ const target =
43
+ node.tabIndex >= 0 || typeof node.querySelector !== "function"
44
+ ? node
45
+ : node.querySelector("input");
46
+ if (!target || typeof target.focus !== "function") return false;
47
+ target.focus();
48
+ return true;
49
+ }
50
+
51
+ function FileRow({ t, file, index, blocked, removeLabel, onRemove, removeRef, testID }) {
52
+ const size = formatFileSize(file && file.size);
53
+ const name = file && typeof file.name === "string" && file.name ? file.name : "file";
54
+ return h(
55
+ View,
56
+ {
57
+ testID: testID ? `${testID}-file-${index}` : undefined,
58
+ style: {
59
+ flexDirection: "row",
60
+ alignItems: "center",
61
+ gap: t.rowGap,
62
+ paddingLeft: t.paddingHorizontal,
63
+ minHeight: REMOVE_TARGET,
64
+ },
65
+ },
66
+ h(View, HIDDEN, h(Icon, { name: "file", size: 16, color: t.placeholderColor })),
67
+ h(
68
+ Text,
69
+ {
70
+ numberOfLines: 1,
71
+ style: { flexShrink: 1, color: t.textColor, fontFamily: t.fontFamily, fontSize: t.labelSize },
72
+ },
73
+ name,
74
+ ),
75
+ size
76
+ ? h(
77
+ Text,
78
+ { style: { color: t.helperColor, fontFamily: t.fontFamily, fontSize: t.helperSize } },
79
+ size,
80
+ )
81
+ : null,
82
+ h(View, { style: { flex: 1 } }),
83
+ h(
84
+ Pressable,
85
+ {
86
+ ref: removeRef,
87
+ onPress: () => onRemove(index),
88
+ disabled: blocked,
89
+ accessibilityRole: "button",
90
+ accessibilityLabel: `${removeLabel} ${name}`,
91
+ "aria-disabled": blocked,
92
+ testID: testID ? `${testID}-remove-${index}` : undefined,
93
+ style: ({ pressed }) => [
94
+ {
95
+ width: REMOVE_TARGET,
96
+ height: REMOVE_TARGET,
97
+ alignItems: "center",
98
+ justifyContent: "center",
99
+ borderRadius: t.radius,
100
+ },
101
+ pressed ? { opacity: 0.7 } : null,
102
+ blocked ? { opacity: t.disabledOpacity } : null,
103
+ ],
104
+ },
105
+ h(Icon, { name: "x", size: 16, color: t.placeholderColor }),
106
+ ),
107
+ );
108
+ }
109
+
110
+ /**
111
+ * @param {object} props — see `FileUploadProps` in index.d.ts.
112
+ */
113
+ function FileUpload(props) {
114
+ const {
115
+ accept,
116
+ multiple = false,
117
+ files,
118
+ onFilesChange,
119
+ onPick,
120
+ onRemove,
121
+ onReject,
122
+ uploading = false,
123
+ label,
124
+ helper,
125
+ error,
126
+ disabled = false,
127
+ buttonLabel = multiple ? "Choose files" : "Choose file",
128
+ buttonVariant = "outlined",
129
+ uploadingLabel = "Uploading…",
130
+ emptyLabel = multiple ? "No files chosen" : "No file chosen",
131
+ dropLabel = multiple ? "or drop files here" : "or drop a file here",
132
+ rejectedMessage = "That file type isn't accepted here.",
133
+ singleFileMessage = "Only one file can be added here, so the first was kept.",
134
+ unsupportedMessage = "Choosing files isn't available here.",
135
+ removeAccessibilityLabel,
136
+ accessibilityLabel,
137
+ style,
138
+ containerStyle,
139
+ testID,
140
+ } = props;
141
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
142
+ const t = resolveFormControlTokens(theme);
143
+ const sharedRemove = useHostSharedText("remove", "Remove");
144
+ const [ownFiles, setOwnFiles] = React.useState([]);
145
+ const [rejection, setRejection] = React.useState(null);
146
+ const [focused, setFocused] = React.useState(false);
147
+ const [dragging, setDragging] = React.useState(false);
148
+ const noteId = React.useId();
149
+ const pickerRef = React.useRef(null);
150
+ const removeRefs = React.useRef([]);
151
+ const pendingFocus = React.useRef(null);
152
+
153
+ // The newest message shows: a new `error` replaces an older refusal.
154
+ const [seenError, setSeenError] = React.useState(error);
155
+ if (error !== seenError) {
156
+ setSeenError(error);
157
+ if (isShown(error)) setRejection(null);
158
+ }
159
+
160
+ const supported = FilePicker.isSupported !== false;
161
+ const blocked = disabled || uploading || !supported;
162
+ const controlled = Array.isArray(files);
163
+ const chosen = controlled ? files.filter(Boolean) : ownFiles;
164
+ const shownError = rejection || errorMessage(error);
165
+ const invalid = isShown(error) || rejection !== null;
166
+ const helperShown = supported ? helper : unsupportedMessage;
167
+ const noted = hasNote({ helper: helperShown, error: shownError });
168
+
169
+ const commit = (next) => {
170
+ if (!controlled) setOwnFiles(next);
171
+ if (typeof onFilesChange === "function") onFilesChange(next);
172
+ };
173
+
174
+ const receive = (picked) => {
175
+ if (blocked) return;
176
+ const { added, rejected, extra, next } = planPick(chosen, picked, accept, multiple);
177
+ setRejection(
178
+ rejected.length > 0 ? rejectedMessage : extra.length > 0 ? singleFileMessage : null,
179
+ );
180
+ if (typeof onReject === "function") {
181
+ if (rejected.length > 0) onReject(rejected, "type");
182
+ if (extra.length > 0) onReject(extra, "count");
183
+ }
184
+ if (added.length === 0) return;
185
+ commit(next);
186
+ if (typeof onPick === "function") onPick(pickPayload(added, multiple));
187
+ };
188
+
189
+ const remove = (index) => {
190
+ if (blocked) return;
191
+ const file = chosen[index];
192
+ setRejection(null);
193
+ pendingFocus.current = { file, index };
194
+ commit(chosen.filter((_, i) => i !== index));
195
+ if (typeof onRemove === "function") onRemove(file, index);
196
+ };
197
+
198
+ // The pressed remove button is gone: focus its neighbour, else the picker.
199
+ React.useEffect(() => {
200
+ const pending = pendingFocus.current;
201
+ if (!pending || chosen[pending.index] === pending.file) return;
202
+ pendingFocus.current = null;
203
+ const neighbour = Math.min(pending.index, chosen.length - 1);
204
+ if (!focusNode(neighbour >= 0 ? removeRefs.current[neighbour] : null)) {
205
+ focusNode(pickerRef.current);
206
+ }
207
+ });
208
+
209
+ const status = uploading
210
+ ? null
211
+ : DropZone && !blocked
212
+ ? dropLabel
213
+ : chosen.length === 0
214
+ ? emptyLabel
215
+ : null;
216
+ const statusText = { color: t.placeholderColor, fontFamily: t.fontFamily, fontSize: t.labelSize };
217
+
218
+ const field = h(
219
+ View,
220
+ {
221
+ testID,
222
+ style: [
223
+ {
224
+ flexDirection: "row",
225
+ alignItems: "center",
226
+ flexWrap: "wrap",
227
+ gap: t.rowGap,
228
+ minHeight: t.height,
229
+ padding: t.rowGap,
230
+ borderWidth: 1,
231
+ borderStyle: DropZone ? "dashed" : "solid",
232
+ borderColor: invalid ? t.errorColor : t.borderColor,
233
+ borderRadius: t.radius,
234
+ backgroundColor: t.background,
235
+ },
236
+ dragging && !blocked ? { borderColor: t.focusColor, borderStyle: "solid" } : null,
237
+ style,
238
+ ],
239
+ },
240
+ h(
241
+ View,
242
+ { ref: pickerRef, style: focusHaloStyle(t, focused && !blocked, t.radius) },
243
+ h(
244
+ FilePicker,
245
+ {
246
+ accept,
247
+ multiple,
248
+ disabled: blocked,
249
+ onPick: receive,
250
+ onFocus: () => setFocused(true),
251
+ onBlur: () => setFocused(false),
252
+ accessibilityLabel,
253
+ "aria-invalid": invalid || undefined,
254
+ "aria-describedby": noted ? noteId : undefined,
255
+ },
256
+ h(Button, {
257
+ title: buttonLabel,
258
+ icon: uploading ? undefined : "upload",
259
+ variant: buttonVariant,
260
+ testID: testID ? `${testID}-button` : undefined,
261
+ }),
262
+ ),
263
+ ),
264
+ uploading
265
+ ? h(
266
+ View,
267
+ { style: { flexDirection: "row", alignItems: "center", gap: t.gap } },
268
+ h(Spinner, { size: "sm", accessibilityLabel: isText(uploadingLabel) ? String(uploadingLabel) : undefined }),
269
+ h(Text, { ...HIDDEN, style: statusText }, uploadingLabel),
270
+ )
271
+ : null,
272
+ isShown(status)
273
+ ? h(Text, { style: [statusText, { flexShrink: 1 }, blocked ? { opacity: t.disabledOpacity } : null] }, status)
274
+ : null,
275
+ );
276
+
277
+ const list =
278
+ chosen.length > 0
279
+ ? h(
280
+ View,
281
+ { style: [{ gap: 2 }, disabled ? { opacity: t.disabledOpacity } : null] },
282
+ ...chosen.map((file, index) =>
283
+ h(FileRow, {
284
+ key: fileKey(file, index),
285
+ t,
286
+ file,
287
+ index,
288
+ blocked,
289
+ removeLabel: isShown(removeAccessibilityLabel) ? removeAccessibilityLabel : sharedRemove,
290
+ onRemove: remove,
291
+ removeRef: (node) => {
292
+ removeRefs.current[index] = node;
293
+ },
294
+ testID,
295
+ }),
296
+ ),
297
+ )
298
+ : null;
299
+
300
+ const body = h(View, { style: { gap: t.gap } }, field, list);
301
+
302
+ return h(
303
+ FieldFrame,
304
+ {
305
+ t,
306
+ label,
307
+ helper: helperShown,
308
+ error: shownError,
309
+ noteId,
310
+ alertOnError: true,
311
+ style: containerStyle,
312
+ },
313
+ DropZone
314
+ ? h(DropZone, { disabled: blocked, onFiles: receive, onActiveChange: setDragging }, body)
315
+ : body,
316
+ );
317
+ }
318
+
319
+ FileUpload.displayName = "FileUpload";
320
+ return FileUpload;
321
+ }
@@ -0,0 +1,62 @@
1
+ // `<FileUpload>` (web). Binds ./file-upload-view.js to react-native-web and
2
+ // the web `<FilePicker>`, and adds the one web extra: files dropped onto the
3
+ // field are taken as if picked. Keyboard and screen-reader users have the
4
+ // button, which is the whole control on native.
5
+
6
+ import React from "react";
7
+ import { View, Text, Pressable, Platform } from "react-native-web";
8
+ import { makeFileUpload } from "./file-upload-view.js";
9
+ import { FilePicker } from "./filepicker.js";
10
+ import { Button } from "./button.js";
11
+ import { Spinner } from "./loading.js";
12
+
13
+ const DROP_ZONE_STYLE = { display: "flex", flexDirection: "column", minWidth: 0 };
14
+
15
+ function carriesFiles(event) {
16
+ const types = event.dataTransfer && event.dataTransfer.types;
17
+ return Boolean(types) && Array.from(types).includes("Files");
18
+ }
19
+
20
+ /**
21
+ * The field's drop target. A drop is always claimed, even while disabled,
22
+ * so the browser never navigates away to the dropped file.
23
+ */
24
+ function DropZone({ disabled, onFiles, onActiveChange, children }) {
25
+ // Entering a child fires dragleave on the parent; count the depth.
26
+ const depth = React.useRef(0);
27
+ return React.createElement(
28
+ "div",
29
+ {
30
+ style: DROP_ZONE_STYLE,
31
+ onDragEnter: (event) => {
32
+ if (!carriesFiles(event)) return;
33
+ event.preventDefault();
34
+ depth.current += 1;
35
+ if (!disabled) onActiveChange(true);
36
+ },
37
+ onDragOver: (event) => {
38
+ if (!carriesFiles(event)) return;
39
+ event.preventDefault();
40
+ event.dataTransfer.dropEffect = disabled ? "none" : "copy";
41
+ },
42
+ onDragLeave: (event) => {
43
+ if (!carriesFiles(event)) return;
44
+ depth.current = Math.max(0, depth.current - 1);
45
+ if (depth.current === 0) onActiveChange(false);
46
+ },
47
+ onDrop: (event) => {
48
+ if (!carriesFiles(event)) return;
49
+ event.preventDefault();
50
+ depth.current = 0;
51
+ onActiveChange(false);
52
+ if (!disabled) onFiles(Array.from(event.dataTransfer.files || []));
53
+ },
54
+ },
55
+ children,
56
+ );
57
+ }
58
+
59
+ export const FileUpload = makeFileUpload(
60
+ { View, Text, Pressable, Platform, FilePicker, Button, Spinner },
61
+ { DropZone },
62
+ );
@@ -0,0 +1,11 @@
1
+ // `<FileUpload>` (native). Binds ./file-upload-view.js to React Native and the
2
+ // native `<FilePicker>` (the OS document picker). No drop target: React Native
3
+ // has no events for files dragged onto an app, so the button is the control.
4
+
5
+ import { View, Text, Pressable, Platform } from "react-native";
6
+ import { makeFileUpload } from "./file-upload-view.js";
7
+ import { FilePicker } from "./filepicker.native.js";
8
+ import { Button } from "./button.native.js";
9
+ import { Spinner } from "./loading.native.js";
10
+
11
+ export const FileUpload = makeFileUpload({ View, Text, Pressable, Platform, FilePicker, Button, Spinner });
@@ -21,6 +21,9 @@
21
21
  // `<label>` so the click reaches the hidden input
22
22
  // everywhere — no manual ref / `.click()` plumbing needed.
23
23
  // accessibilityLabel: string? — written to the wrapper label.
24
+ // onFocus / onBlur: () => void — the hidden input's focus, so a
25
+ // caller can draw a focus ring on its visible trigger.
26
+ // aria-describedby / aria-invalid — tie the picker to a field's note.
24
27
 
25
28
  import React, { useRef, useCallback } from "react";
26
29
 
@@ -54,6 +57,10 @@ export function FilePicker({
54
57
  onPick,
55
58
  children,
56
59
  accessibilityLabel,
60
+ onFocus,
61
+ onBlur,
62
+ "aria-describedby": describedBy,
63
+ "aria-invalid": invalid,
57
64
  }) {
58
65
  const inputRef = useRef(null);
59
66
 
@@ -84,6 +91,10 @@ export function FilePicker({
84
91
  multiple,
85
92
  disabled,
86
93
  onChange: handleChange,
94
+ onFocus,
95
+ onBlur,
96
+ "aria-describedby": describedBy,
97
+ "aria-invalid": invalid || undefined,
87
98
  style: HIDDEN_INPUT_STYLE,
88
99
  }),
89
100
  children,
@@ -6,7 +6,8 @@
6
6
  // `<input type="file">` because react-native-web has no equivalent of the
7
7
  // system file picker; this native variant calls the OS document picker
8
8
  // directly. Both honour the same public contract — `accept` / `multiple`
9
- // / `disabled` / `onPick` / `children` / `accessibilityLabel`.
9
+ // / `disabled` / `onPick` / `children` / `accessibilityLabel` / `onFocus`
10
+ // / `onBlur` / `aria-describedby` / `aria-invalid`.
10
11
  //
11
12
  // `onPick` receives the same shape on both platforms:
12
13
  // - web → the browser's `File` object (a `Blob` with name + type).
@@ -73,6 +74,10 @@ export function FilePicker({
73
74
  onPick,
74
75
  children,
75
76
  accessibilityLabel,
77
+ onFocus,
78
+ onBlur,
79
+ "aria-describedby": describedBy,
80
+ "aria-invalid": invalid,
76
81
  }) {
77
82
  const type = useMemo(() => _mapAccept(accept), [accept]);
78
83
 
@@ -103,10 +108,14 @@ export function FilePicker({
103
108
  Pressable,
104
109
  {
105
110
  onPress: handlePress,
111
+ onFocus,
112
+ onBlur,
106
113
  disabled,
107
114
  accessibilityRole: "button",
108
115
  accessibilityLabel,
109
116
  accessibilityState: { disabled },
117
+ "aria-describedby": describedBy,
118
+ "aria-invalid": invalid || undefined,
110
119
  },
111
120
  React.createElement(View, { style: disabled ? { opacity: 0.5 } : undefined }, children),
112
121
  );
@@ -26,6 +26,8 @@ export function makeFieldFrame(rn) {
26
26
  * @param {() => void} [props.onLabelPress] — a text label then acts for the control.
27
27
  * @param {boolean} [props.noteDisabled] — dim the note alone, when the
28
28
  * control dims itself and `disabled` would dim it twice.
29
+ * @param {boolean} [props.alertOnError] — an error interrupts, as an alert,
30
+ * for one that follows an action rather than typing.
29
31
  */
30
32
  function FieldFrame({
31
33
  t,
@@ -36,6 +38,7 @@ export function makeFieldFrame(rn) {
36
38
  noteId,
37
39
  onLabelPress,
38
40
  noteDisabled,
41
+ alertOnError,
39
42
  style,
40
43
  children,
41
44
  }) {
@@ -79,7 +82,11 @@ export function makeFieldFrame(rn) {
79
82
  {
80
83
  nativeID: noteId,
81
84
  // Announced where the host can: the web and Android.
82
- ...(message ? { accessibilityLiveRegion: "polite" } : null),
85
+ ...(message
86
+ ? alertOnError
87
+ ? { accessibilityRole: "alert", accessibilityLiveRegion: "assertive" }
88
+ : { accessibilityLiveRegion: "polite" }
89
+ : null),
83
90
  style: {
84
91
  color: message ? t.errorColor : t.helperColor,
85
92
  fontFamily: t.fontFamily,
package/dist/host.d.ts CHANGED
@@ -51,6 +51,12 @@ export function bannerScopeDefaults(theme: unknown): Record<string, string | num
51
51
  */
52
52
  export function chipScopeDefaults(theme: unknown): Record<string, string | number>;
53
53
 
54
+ /**
55
+ * What each token of the `search` component scope resolves to while unset,
56
+ * keyed by token name: the input look `<SearchField>` falls back to.
57
+ */
58
+ export function searchScopeDefaults(theme: unknown): Record<string, string | number>;
59
+
54
60
  /**
55
61
  * The theme a surface painting `backgroundColor` hands its subtree: surface
56
62
  * tokens derived for that fill, and any type tier whose own ink cannot be read
package/dist/host.js CHANGED
@@ -54,6 +54,8 @@ export { resolveModalTokens, modalScopeDefaults } from "./modal-tokens.js";
54
54
  // What each `chip` scope token resolves to while unset, for the Studio's
55
55
  // Design page to preview beside its controls.
56
56
  export { chipScopeDefaults } from "./chip-tokens.js";
57
+ // The input look each unset `search` token falls back to, for the same page.
58
+ export { searchScopeDefaults } from "./search-field-tokens.js";
57
59
 
58
60
  export {
59
61
  createToastController,