@colixsystems/widget-sdk 0.247.0 → 0.248.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,310 @@
1
+ // The drag machinery `<SortableList>` and `<Board>` share: the move handle
2
+ // that owns the gesture, the session that measures, tracks and settles a
3
+ // drag, and the announcement a move makes. Built from `Animated` and
4
+ // `PanResponder`, the one drag mechanism both hosts run the same way.
5
+
6
+ import React from "react";
7
+ import { Icon } from "./icon.js";
8
+ import { fillLabel, isDragGesture } from "./sortable-logic.js";
9
+ import { HANDLE_ICON_SIZE, handleStyle } from "./sortable-tokens.js";
10
+
11
+ const h = React.createElement;
12
+ const ACTIVATE_KEYS = new Set(["Enter", " ", "Spacebar"]);
13
+ const SPRING = Object.freeze({ friction: 8, tension: 80, useNativeDriver: false });
14
+ const VISUALLY_HIDDEN = Object.freeze({
15
+ position: "absolute",
16
+ width: 1,
17
+ height: 1,
18
+ overflow: "hidden",
19
+ opacity: 0,
20
+ });
21
+
22
+ /** A node's window rectangle, or null when it cannot be measured. */
23
+ export function measureNode(node) {
24
+ return new Promise((resolve) => {
25
+ if (!node || typeof node.measureInWindow !== "function") {
26
+ resolve(null);
27
+ return;
28
+ }
29
+ node.measureInWindow((x, y, width, height) => resolve({ x, y, width, height }));
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Bind the shared drag machinery to one platform's primitives.
35
+ *
36
+ * @param {{View: any, Text: any, Animated: any, PanResponder: any,
37
+ * Platform: any, AccessibilityInfo: any, Menu: any, handleSurface: object|null,
38
+ * onOutsideScroll?: ((root: any, handler: () => void) => () => void)|null}} rn —
39
+ * `handleSurface` is the host's extra handle style (the web keeps a touch
40
+ * drag from scrolling the page with it); `onOutsideScroll(root, handler)`
41
+ * calls `handler` when anything outside `root` scrolls, and returns its
42
+ * unsubscribe. A device's handle keeps the page still, so it passes none.
43
+ */
44
+ export function makeDragKit(rn) {
45
+ const { View, Text, Animated, PanResponder, Platform, AccessibilityInfo, Menu, handleSurface, onOutsideScroll } = rn;
46
+ const isWeb = Platform.OS === "web";
47
+
48
+ // Everything the responder reads comes through `latest`, so a prop that
49
+ // changes after the first render (a permission that loads) is honoured.
50
+ function useHandleResponder(latest) {
51
+ const responder = React.useRef(null);
52
+ const dragging = React.useRef(false);
53
+ if (responder.current === null) {
54
+ responder.current = PanResponder.create({
55
+ // Only an item that may move renders a handle, so it always claims.
56
+ onStartShouldSetPanResponder: () => true,
57
+ onMoveShouldSetPanResponder: () => true,
58
+ // A scroll beneath must not take the gesture from a held item.
59
+ onPanResponderTerminationRequest: () => false,
60
+ onPanResponderGrant: () => {
61
+ dragging.current = false;
62
+ },
63
+ onPanResponderMove: (event, gesture) => {
64
+ if (!dragging.current) {
65
+ if (!isDragGesture(gesture.dx, gesture.dy)) return;
66
+ dragging.current = true;
67
+ latest.current.onDragStart(gesture);
68
+ }
69
+ latest.current.onDragMove(gesture);
70
+ },
71
+ onPanResponderRelease: (event, gesture) => {
72
+ const wasDragging = dragging.current;
73
+ dragging.current = false;
74
+ if (wasDragging) latest.current.onDragEnd(gesture);
75
+ else if (typeof latest.current.onPress === "function") latest.current.onPress();
76
+ },
77
+ // A gesture the system takes away is a cancel, never a drop.
78
+ onPanResponderTerminate: () => {
79
+ if (!dragging.current) return;
80
+ dragging.current = false;
81
+ latest.current.onDragCancel();
82
+ },
83
+ });
84
+ }
85
+ return responder.current;
86
+ }
87
+
88
+ /**
89
+ * The grip an item is dragged by. A tap (or Enter / Space) opens its move
90
+ * options; the arrow keys move it a step on the web.
91
+ */
92
+ const Handle = React.forwardRef(function Handle(props, ref) {
93
+ const latest = React.useRef(props);
94
+ latest.current = props;
95
+ const responder = useHandleResponder(latest);
96
+ const onKeyDown = (event) => {
97
+ if (ACTIVATE_KEYS.has(event.key)) {
98
+ event.preventDefault();
99
+ if (typeof latest.current.onPress === "function") latest.current.onPress();
100
+ return;
101
+ }
102
+ if (latest.current.onKey(event.key)) event.preventDefault();
103
+ };
104
+ // A reader's browse mode sends a bare click (detail 0); a mouse press
105
+ // (detail >= 1) already opened the menu through the responder.
106
+ const onClick = (event) => {
107
+ if (event && event.detail === 0 && typeof latest.current.onPress === "function") latest.current.onPress();
108
+ };
109
+ return h(
110
+ View,
111
+ {
112
+ ref,
113
+ ...responder.panHandlers,
114
+ accessible: true,
115
+ // `role` makes it a keyboard tab stop on the web.
116
+ role: "button",
117
+ accessibilityRole: "button",
118
+ accessibilityLabel: props.accessibilityLabel,
119
+ accessibilityHint: props.accessibilityHint,
120
+ accessibilityActions: props.accessibilityActions,
121
+ onAccessibilityAction: props.onAccessibilityAction,
122
+ "aria-haspopup": props["aria-haspopup"],
123
+ "aria-expanded": props["aria-expanded"],
124
+ onKeyDown: isWeb ? onKeyDown : undefined,
125
+ onClick: isWeb ? onClick : undefined,
126
+ testID: props.testID,
127
+ style: [handleStyle(), handleSurface],
128
+ },
129
+ h(Icon, { name: "grip-vertical", size: HANDLE_ICON_SIZE, color: props.color }),
130
+ );
131
+ });
132
+ Handle.displayName = "DragHandle";
133
+
134
+ /**
135
+ * An item's handle inside its move options: the same moves as a menu for a
136
+ * tap or Enter, as accessibility actions for a screen reader, and as a drag.
137
+ */
138
+ function moveHandle({ itemKey, name, actions, run, onKey, session, handles, color, words, testID }) {
139
+ const label = fillLabel(words.handle, { item: name });
140
+ const handle = h(Handle, {
141
+ ref: (node) => (node ? handles.current.set(itemKey, node) : handles.current.delete(itemKey)),
142
+ color,
143
+ accessibilityLabel: label,
144
+ accessibilityHint: words.hint,
145
+ accessibilityActions: actions.map((action) => ({ name: action.name, label: action.label })),
146
+ onAccessibilityAction: (event) => run(event.nativeEvent.actionName),
147
+ onKey,
148
+ onDragStart: (gesture) => session.start(itemKey, gesture),
149
+ onDragMove: session.move,
150
+ onDragEnd: session.end,
151
+ onDragCancel: session.cancel,
152
+ testID: testID ? `${testID}-handle-${itemKey}` : undefined,
153
+ });
154
+ return h(Menu, {
155
+ trigger: handle,
156
+ accessibilityLabel: label,
157
+ items: actions.map((action) => ({ key: action.name, label: action.label, onPress: () => run(action.name) })),
158
+ testID: testID ? `${testID}-menu-${itemKey}` : undefined,
159
+ });
160
+ }
161
+
162
+ // After a keyboard or menu move, the moved item's handle keeps the focus.
163
+ function useFocusAfterMove(handles) {
164
+ const pending = React.useRef(null);
165
+ React.useEffect(() => {
166
+ const key = pending.current;
167
+ if (key === null || !isWeb) return;
168
+ pending.current = null;
169
+ const node = handles.current.get(key);
170
+ if (node && typeof node.focus === "function") node.focus();
171
+ });
172
+ return pending;
173
+ }
174
+
175
+ // The handle's look without its gesture, for the lifted copy.
176
+ function HandleMark({ color }) {
177
+ return h(View, { style: handleStyle() }, h(Icon, { name: "grip-vertical", size: HANDLE_ICON_SIZE, color }));
178
+ }
179
+
180
+ /**
181
+ * A move's result, said aloud: a polite live region on the web, the
182
+ * platform's screen-reader announcement on a device.
183
+ */
184
+ function useAnnouncer() {
185
+ const [message, setMessage] = React.useState("");
186
+ const announce = (text) => {
187
+ if (isWeb) setMessage(text);
188
+ else if (AccessibilityInfo && typeof AccessibilityInfo.announceForAccessibility === "function") {
189
+ AccessibilityInfo.announceForAccessibility(text);
190
+ }
191
+ };
192
+ const region = isWeb ? h(Text, { "aria-live": "polite", style: VISUALLY_HIDDEN }, message) : null;
193
+ return [announce, region];
194
+ }
195
+
196
+ // A page scrolled under a held item leaves its measured frames stale, so
197
+ // the drag is called off, as an open menu closes.
198
+ function useCancelOnOutsideScroll(active, latest, cancel) {
199
+ const cancelRef = React.useRef(cancel);
200
+ cancelRef.current = cancel;
201
+ React.useEffect(() => {
202
+ if (typeof onOutsideScroll !== "function" || !active) return undefined;
203
+ const root = latest.current.rootRef ? latest.current.rootRef.current : null;
204
+ return onOutsideScroll(root, () => cancelRef.current());
205
+ }, [active, latest]);
206
+ }
207
+
208
+ /**
209
+ * One drag at a time: measure when it starts, follow the pointer, then
210
+ * settle the lifted copy onto its slot or spring it back.
211
+ *
212
+ * `config` is read fresh on every call: `measure(key)` resolves the
213
+ * frames, `track(data, gesture)` names the target, `drop(data, target)`
214
+ * commits a real change and returns where the copy lands (or null to
215
+ * spring it back), `axis` "y" locks a list's copy to its column,
216
+ * `onCancel()` runs when a drag is called off, and `rootRef` is the
217
+ * component, whose own scrolling the drag already follows.
218
+ */
219
+ function useDragSession(config) {
220
+ const latest = React.useRef(config);
221
+ latest.current = config;
222
+ const pan = React.useRef(null);
223
+ if (pan.current === null) pan.current = new Animated.ValueXY({ x: 0, y: 0 });
224
+ const session = React.useRef(null);
225
+ const mounted = React.useRef(true);
226
+ const [drag, setDrag] = React.useState(null);
227
+ React.useEffect(() => {
228
+ mounted.current = true;
229
+ return () => {
230
+ mounted.current = false;
231
+ session.current = null;
232
+ };
233
+ }, []);
234
+
235
+ // Only the drag that is settling ends: a spring stopped by a new drag
236
+ // still calls back, and must not clear the drag that stopped it.
237
+ const settle = (key, toValue) => {
238
+ Animated.spring(pan.current, { ...SPRING, toValue }).start(() => {
239
+ if (!mounted.current) return;
240
+ setDrag((current) => (current && current.key === key && current.settling ? null : current));
241
+ });
242
+ };
243
+ const follow = (own, gesture) => {
244
+ const x = latest.current.axis === "y" ? 0 : gesture.dx;
245
+ pan.current.setValue({ x, y: gesture.dy });
246
+ const target = latest.current.track(own.data, gesture);
247
+ if (!latest.current.sameTarget(target, own.target)) {
248
+ own.target = target;
249
+ setDrag((current) => (current && current.key === own.key ? { ...current, target } : current));
250
+ }
251
+ if (typeof latest.current.afterMove === "function") latest.current.afterMove(own.data, gesture);
252
+ };
253
+ const springBack = (own) => {
254
+ setDrag((current) =>
255
+ current && current.key === own.key ? { ...current, target: own.data.resting, settling: true } : current,
256
+ );
257
+ settle(own.key, { x: 0, y: 0 });
258
+ };
259
+
260
+ const start = (key, gesture) => {
261
+ const own = { key, data: null, target: null, gesture };
262
+ session.current = own;
263
+ latest.current.measure(key).then((data) => {
264
+ // Released, or another drag began, while the frames were measured.
265
+ if (!mounted.current || session.current !== own || !data) return;
266
+ own.data = data;
267
+ own.target = data.resting;
268
+ pan.current.setValue({ x: 0, y: 0 });
269
+ setDrag({ key, data, target: data.resting, settling: false });
270
+ follow(own, own.gesture);
271
+ });
272
+ };
273
+ const move = (gesture) => {
274
+ const own = session.current;
275
+ if (!own) return;
276
+ own.gesture = gesture;
277
+ if (own.data) follow(own, gesture);
278
+ };
279
+ const end = (gesture) => {
280
+ const own = session.current;
281
+ session.current = null;
282
+ if (!own || !own.data) return;
283
+ const target = latest.current.track(own.data, gesture);
284
+ const landing = latest.current.drop(own.data, target);
285
+ if (!landing) {
286
+ springBack(own);
287
+ return;
288
+ }
289
+ setDrag((current) => (current && current.key === own.key ? { ...current, target, settling: true } : current));
290
+ settle(own.key, landing);
291
+ };
292
+ const cancel = () => {
293
+ const own = session.current;
294
+ session.current = null;
295
+ if (typeof latest.current.onCancel === "function") latest.current.onCancel();
296
+ if (own && own.data) springBack(own);
297
+ };
298
+ // A narrow board scrolled under a still pointer: re-aim at the new spot.
299
+ const retrack = () => {
300
+ const own = session.current;
301
+ if (own && own.data) follow(own, own.gesture);
302
+ };
303
+
304
+ useCancelOnOutsideScroll(drag !== null && !drag.settling, latest, cancel);
305
+
306
+ return { drag, pan: pan.current, start, move, end, cancel, retrack };
307
+ }
308
+
309
+ return { HandleMark, moveHandle, useAnnouncer, useDragSession, useFocusAfterMove };
310
+ }
@@ -12,7 +12,7 @@ import { useHostSharedText, useHostTheme } from "./hooks.js";
12
12
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
13
13
  import { Icon } from "./icon.js";
14
14
  import { focusHaloStyle, resolveFormControlTokens } from "./form-control-tokens.js";
15
- import { errorMessage, hasNote, isShown, isText } from "./form-control-logic.js";
15
+ import { DRAWS_FIELD_FRAME, errorMessage, hasNote, isShown, isText, spokenLabel } from "./form-control-logic.js";
16
16
  import { makeFieldFrame } from "./form-control-view.js";
17
17
  import { fileKey, formatFileSize, pickPayload, planPick } from "./file-upload-logic.js";
18
18
 
@@ -124,6 +124,7 @@ export function makeFileUpload(rn, extras) {
124
124
  helper,
125
125
  error,
126
126
  disabled = false,
127
+ required = false,
127
128
  buttonLabel = multiple ? "Choose files" : "Choose file",
128
129
  buttonVariant = "outlined",
129
130
  uploadingLabel = "Uploading…",
@@ -141,6 +142,7 @@ export function makeFileUpload(rn, extras) {
141
142
  const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
142
143
  const t = resolveFormControlTokens(theme);
143
144
  const sharedRemove = useHostSharedText("remove", "Remove");
145
+ const requiredWord = useHostSharedText("required", "Required");
144
146
  const [ownFiles, setOwnFiles] = React.useState([]);
145
147
  const [rejection, setRejection] = React.useState(null);
146
148
  const [focused, setFocused] = React.useState(false);
@@ -165,6 +167,13 @@ export function makeFileUpload(rn, extras) {
165
167
  const invalid = isShown(error) || rejection !== null;
166
168
  const helperShown = supported ? helper : unsupportedMessage;
167
169
  const noted = hasNote({ helper: helperShown, error: shownError });
170
+ // Named by its field label, the picker still says what it does.
171
+ const fieldName = spokenLabel(undefined, label, required ? requiredWord : null);
172
+ const pickerName = isShown(accessibilityLabel)
173
+ ? accessibilityLabel
174
+ : fieldName && isText(buttonLabel)
175
+ ? `${fieldName}, ${buttonLabel}`
176
+ : fieldName;
168
177
 
169
178
  const commit = (next) => {
170
179
  if (!controlled) setOwnFiles(next);
@@ -249,7 +258,7 @@ export function makeFileUpload(rn, extras) {
249
258
  onPick: receive,
250
259
  onFocus: () => setFocused(true),
251
260
  onBlur: () => setFocused(false),
252
- accessibilityLabel,
261
+ accessibilityLabel: pickerName,
253
262
  "aria-invalid": invalid || undefined,
254
263
  "aria-describedby": noted ? noteId : undefined,
255
264
  },
@@ -308,6 +317,7 @@ export function makeFileUpload(rn, extras) {
308
317
  error: shownError,
309
318
  noteId,
310
319
  alertOnError: true,
320
+ required,
311
321
  style: containerStyle,
312
322
  },
313
323
  DropZone
@@ -317,5 +327,6 @@ export function makeFileUpload(rn, extras) {
317
327
  }
318
328
 
319
329
  FileUpload.displayName = "FileUpload";
330
+ FileUpload[DRAWS_FIELD_FRAME] = true;
320
331
  return FileUpload;
321
332
  }
@@ -0,0 +1,143 @@
1
+ // The `<FilterBar>` rules that need no renderer: which facets are applied,
2
+ // what their chips read, how one edit changes the filter value, and whether
3
+ // the bar fits on one toolbar row at its measured width. Both hosts share
4
+ // them, and the node suite tests them.
5
+
6
+ import { widthTier } from "./container-width.js";
7
+
8
+ /** The room the search field keeps before the facets give way to a sheet. */
9
+ export const FILTER_SEARCH_MIN_WIDTH = 160;
10
+ /** The width of one compact facet or sort dropdown on the toolbar row. */
11
+ export const FILTER_FACET_WIDTH = 150;
12
+
13
+ /** Whether a facet value filters anything: a non-empty list or a value. */
14
+ export function isApplied(value) {
15
+ if (Array.isArray(value)) return value.length > 0;
16
+ return value !== undefined && value !== null && value !== "";
17
+ }
18
+
19
+ /**
20
+ * The facets the bar can draw: a string `key`, a `label` and an `options`
21
+ * list, each option `{ value, label }`. Anything else is left out, and a
22
+ * repeated key keeps its first facet.
23
+ *
24
+ * @param {unknown} facets
25
+ */
26
+ export function usableFacets(facets) {
27
+ if (!Array.isArray(facets)) return [];
28
+ const seen = new Set();
29
+ const out = [];
30
+ for (const facet of facets) {
31
+ if (!facet || typeof facet.key !== "string" || facet.key.length === 0) continue;
32
+ if (seen.has(facet.key)) continue;
33
+ seen.add(facet.key);
34
+ out.push({
35
+ ...facet,
36
+ label: typeof facet.label === "string" ? facet.label : facet.key,
37
+ options: Array.isArray(facet.options) ? facet.options.filter(Boolean) : [],
38
+ multiple: facet.multiple === true,
39
+ });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function valueMap(value) {
45
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
46
+ }
47
+
48
+ /** The facets with a value applied, in the order they are given. */
49
+ export function appliedFacets(facets, value) {
50
+ const own = valueMap(value);
51
+ return facets.filter((facet) => isApplied(own[facet.key]));
52
+ }
53
+
54
+ /**
55
+ * The filter value with one facet set; an empty value removes the facet's
56
+ * key, so a cleared filter never lingers as `null` or `[]`.
57
+ *
58
+ * @param {Record<string, unknown>} value
59
+ * @param {string} key
60
+ * @param {unknown} next
61
+ */
62
+ export function withFacet(value, key, next) {
63
+ const rest = { ...valueMap(value) };
64
+ delete rest[key];
65
+ return isApplied(next) ? { ...rest, [key]: next } : rest;
66
+ }
67
+
68
+ function sameFacetValue(a, b) {
69
+ if (Array.isArray(a) || Array.isArray(b)) {
70
+ const left = Array.isArray(a) ? a : [];
71
+ const right = Array.isArray(b) ? b : [];
72
+ return left.length === right.length && left.every((v) => right.includes(v));
73
+ }
74
+ return a === b;
75
+ }
76
+
77
+ /** Whether two filter values apply the same facets, ignoring list order. */
78
+ export function sameFilters(a, b) {
79
+ const left = valueMap(a);
80
+ const right = valueMap(b);
81
+ const keys = new Set(
82
+ [...Object.keys(left), ...Object.keys(right)].filter(
83
+ (key) => isApplied(left[key]) || isApplied(right[key]),
84
+ ),
85
+ );
86
+ for (const key of keys) {
87
+ if (!sameFacetValue(left[key], right[key])) return false;
88
+ }
89
+ return true;
90
+ }
91
+
92
+ function optionLabel(facet, value) {
93
+ const option = facet.options.find((own) => own.value === value);
94
+ return option && typeof option.label === "string" ? option.label : String(value);
95
+ }
96
+
97
+ /** An applied facet's chip: "Status: Open", or "Tags: Red, Blue". */
98
+ export function chipLabel(facet, value) {
99
+ const picked = Array.isArray(value) ? value : [value];
100
+ return `${facet.label}: ${picked.map((own) => optionLabel(facet, own)).join(", ")}`;
101
+ }
102
+
103
+ /** A compact facet dropdown's placeholder: "Status: Any". */
104
+ export function facetPlaceholder(facet, anyText) {
105
+ return `${facet.label}: ${anyText}`;
106
+ }
107
+
108
+ /**
109
+ * Copy with a `{name}` slot filled, or a function of the value. Lets the
110
+ * widget translate "Remove {label}" as one string.
111
+ *
112
+ * @param {string|((value: any) => string)} template
113
+ * @param {string} slot
114
+ * @param {string|number} value
115
+ */
116
+ export function fillText(template, slot, value) {
117
+ if (typeof template === "function") return String(template(value));
118
+ return String(template).split(`{${slot}}`).join(String(value));
119
+ }
120
+
121
+ /** The narrow bar's button: "Filters", or "Filters · 2" while two apply. */
122
+ export function filtersButtonText(text, count) {
123
+ return count > 0 ? `${text} · ${count}` : text;
124
+ }
125
+
126
+ /**
127
+ * How the bar lays out at a measured width: `"row"`, search and every
128
+ * dropdown on one toolbar line, or `"sheet"`, the search field and one
129
+ * Filters button opening the facets. Narrow is always a sheet; a wider bar
130
+ * falls back to it only when its dropdowns would not fit beside the search.
131
+ * Unmeasured (0) is a row, the wide form, as `widthTier` reads it.
132
+ *
133
+ * @param {{ width: number, dropdowns: number, gap: number, search: boolean }} bar
134
+ * @returns {"row"|"sheet"}
135
+ */
136
+ export function filterBarLayout({ width, dropdowns, gap, search }) {
137
+ const tier = widthTier(width);
138
+ if (tier === "narrow") return "sheet";
139
+ if (!(width > 0) || dropdowns === 0) return "row";
140
+ const needed =
141
+ (search ? FILTER_SEARCH_MIN_WIDTH + gap : 0) + dropdowns * FILTER_FACET_WIDTH + (dropdowns - 1) * gap;
142
+ return needed > width ? "sheet" : "row";
143
+ }