@colixsystems/widget-sdk 0.246.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.
- package/README.md +76 -6
- package/dist/board-auto-scroll.js +59 -0
- package/dist/board-view.js +327 -0
- package/dist/chart-logic.js +575 -0
- package/dist/chart-tokens.js +131 -0
- package/dist/chart-view.js +262 -0
- package/dist/chart.js +21 -0
- package/dist/chart.native.js +10 -0
- package/dist/contract.cjs +119 -7
- package/dist/contract.js +119 -7
- package/dist/data-table-logic.js +179 -0
- package/dist/data-table-view.js +388 -0
- package/dist/data-table.js +22 -0
- package/dist/data-table.native.js +14 -0
- package/dist/drag-kit-view.js +310 -0
- package/dist/file-upload-view.js +13 -2
- package/dist/filter-bar-logic.js +143 -0
- package/dist/filter-bar-view.js +381 -0
- package/dist/filter-bar.js +15 -0
- package/dist/filter-bar.native.js +13 -0
- package/dist/form-control-logic.js +20 -3
- package/dist/form-control-view.js +18 -0
- package/dist/form-layout-logic.js +92 -0
- package/dist/form-view.js +268 -0
- package/dist/form.js +15 -0
- package/dist/form.native.js +9 -0
- package/dist/grid-logic.js +222 -0
- package/dist/grid-view.js +109 -0
- package/dist/grid.js +7 -0
- package/dist/grid.native.js +7 -0
- package/dist/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +606 -7
- package/dist/index.js +15 -0
- package/dist/index.native.js +15 -0
- package/dist/menu-view.js +5 -2
- package/dist/people-picker-logic.js +203 -0
- package/dist/people-picker-view.js +250 -0
- package/dist/people-picker.js +11 -0
- package/dist/people-picker.native.js +9 -0
- package/dist/radio-group-view.js +7 -3
- package/dist/select-search-logic.js +150 -0
- package/dist/select-view.js +251 -18
- package/dist/select.js +6 -2
- package/dist/select.native.js +5 -3
- package/dist/sortable-list-view.js +228 -0
- package/dist/sortable-logic.js +273 -0
- package/dist/sortable-tokens.js +120 -0
- package/dist/sortable-view.js +24 -0
- package/dist/sortable.js +39 -0
- package/dist/sortable.native.js +30 -0
- package/dist/text-field-view.js +7 -2
- package/package.json +1 -1
- package/src/contract.js +119 -7
|
@@ -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
|
+
}
|
package/dist/file-upload-view.js
CHANGED
|
@@ -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
|
+
}
|