@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,228 @@
|
|
|
1
|
+
// The ONE implementation behind `<SortableList>`: rows the user reorders by
|
|
2
|
+
// dragging a handle, or by its move options and arrow keys. The rows are the
|
|
3
|
+
// app's `<ListRow>`s, so the list wears the theme's `list` scope.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { useHostTheme } from "./hooks.js";
|
|
7
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
|
+
import { measureNode } from "./drag-kit-view.js";
|
|
9
|
+
import {
|
|
10
|
+
canDragItem,
|
|
11
|
+
fillLabel,
|
|
12
|
+
insertionIndex,
|
|
13
|
+
itemKey,
|
|
14
|
+
itemLabel,
|
|
15
|
+
keyboardStep,
|
|
16
|
+
listMoveActions,
|
|
17
|
+
listShiftOffsets,
|
|
18
|
+
moveItem,
|
|
19
|
+
resolveSortableLabels,
|
|
20
|
+
} from "./sortable-logic.js";
|
|
21
|
+
import {
|
|
22
|
+
HIDDEN,
|
|
23
|
+
HIDDEN_FROM_READERS,
|
|
24
|
+
PROXY_SCALE,
|
|
25
|
+
proxyStyle,
|
|
26
|
+
resolveSortableTokens,
|
|
27
|
+
slotStyle,
|
|
28
|
+
} from "./sortable-tokens.js";
|
|
29
|
+
|
|
30
|
+
const h = React.createElement;
|
|
31
|
+
|
|
32
|
+
const sameIndex = (a, b) => a === b;
|
|
33
|
+
|
|
34
|
+
/** How a row is drawn while a drag is under way. */
|
|
35
|
+
function rowSlot(drag, key) {
|
|
36
|
+
if (!drag || drag.key !== key) return null;
|
|
37
|
+
if (drag.settling) return "hidden";
|
|
38
|
+
return drag.target === drag.data.from ? "vacated" : "target";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bind the shared sortable list to one platform's primitives.
|
|
43
|
+
*
|
|
44
|
+
* @param {{View: any, Animated: any, List: any, ListRow: any}} rn
|
|
45
|
+
* @param {ReturnType<import("./drag-kit-view.js").makeDragKit>} kit
|
|
46
|
+
*/
|
|
47
|
+
export function makeSortableList(rn, kit) {
|
|
48
|
+
const { View, Animated, List, ListRow } = rn;
|
|
49
|
+
const { HandleMark, moveHandle, useAnnouncer, useDragSession, useFocusAfterMove } = kit;
|
|
50
|
+
|
|
51
|
+
// Rows move aside with a transform, never a re-order, so the held
|
|
52
|
+
// handle's node stays where the pointer pressed it.
|
|
53
|
+
function SortableRow({ divider, rowRef, shift, slot, tokens, leading, title, testID, children }) {
|
|
54
|
+
const frame = [shift ? { transform: [{ translateY: shift }] } : null, slot && slot !== "hidden" ? slotStyle(tokens, slot) : null];
|
|
55
|
+
return h(
|
|
56
|
+
View,
|
|
57
|
+
{ ref: rowRef, style: frame, testID },
|
|
58
|
+
h(View, { style: slot ? HIDDEN : null }, h(ListRow, { divider, leading, title }, children)),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A list the user reorders. `onReorder(nextItems, { from, to, item })`
|
|
64
|
+
* runs once per real change; a drag released where it started, or taken
|
|
65
|
+
* away, springs back and calls nothing.
|
|
66
|
+
*/
|
|
67
|
+
function SortableList({
|
|
68
|
+
items,
|
|
69
|
+
onReorder,
|
|
70
|
+
renderItem,
|
|
71
|
+
keyExtractor,
|
|
72
|
+
getItemLabel,
|
|
73
|
+
canDrag = true,
|
|
74
|
+
variant,
|
|
75
|
+
labels,
|
|
76
|
+
style,
|
|
77
|
+
testID,
|
|
78
|
+
accessibilityLabel,
|
|
79
|
+
}) {
|
|
80
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
81
|
+
const tokens = resolveSortableTokens(theme);
|
|
82
|
+
const words = resolveSortableLabels(labels);
|
|
83
|
+
const list = Array.isArray(items) ? items : [];
|
|
84
|
+
const keys = list.map((item, i) => itemKey(item, i, keyExtractor));
|
|
85
|
+
const order = keys.join("\u0000");
|
|
86
|
+
const rootRef = React.useRef(null);
|
|
87
|
+
const rows = React.useRef(new Map());
|
|
88
|
+
const handles = React.useRef(new Map());
|
|
89
|
+
const pendingFocus = useFocusAfterMove(handles);
|
|
90
|
+
const [announce, liveRegion] = useAnnouncer();
|
|
91
|
+
|
|
92
|
+
// Presses faster than the widget re-renders would repeat the last move.
|
|
93
|
+
const lastCommit = React.useRef(null);
|
|
94
|
+
const commit = (from, to) => {
|
|
95
|
+
if (from < 0 || to < 0 || to >= list.length || to === from) return false;
|
|
96
|
+
const move = `${order}\u0001${from}\u0001${to}`;
|
|
97
|
+
if (lastCommit.current === move) return false;
|
|
98
|
+
lastCommit.current = move;
|
|
99
|
+
const item = list[from];
|
|
100
|
+
if (typeof onReorder === "function") onReorder(moveItem(list, from, to), { from, to, item });
|
|
101
|
+
announce(fillLabel(words.moved, { item: itemLabel(item, keys[from], getItemLabel), position: to + 1, total: list.length }));
|
|
102
|
+
return true;
|
|
103
|
+
};
|
|
104
|
+
const moveKey = (key, to) => {
|
|
105
|
+
if (commit(keys.indexOf(key), to)) pendingFocus.current = key;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const session = useDragSession({
|
|
109
|
+
axis: "y",
|
|
110
|
+
rootRef,
|
|
111
|
+
sameTarget: sameIndex,
|
|
112
|
+
measure: async (key) => {
|
|
113
|
+
const from = keys.indexOf(key);
|
|
114
|
+
const [root, ...frames] = await Promise.all([rootRef.current, ...keys.map((k) => rows.current.get(k))].map(measureNode));
|
|
115
|
+
if (from < 0 || !root || !frames[from]) return null;
|
|
116
|
+
const own = frames[from];
|
|
117
|
+
return {
|
|
118
|
+
key,
|
|
119
|
+
from,
|
|
120
|
+
frames,
|
|
121
|
+
order,
|
|
122
|
+
resting: from,
|
|
123
|
+
origin: { left: own.x - root.x, top: own.y - root.y, width: own.width },
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
track: (data, gesture) => {
|
|
127
|
+
const own = data.frames[data.from];
|
|
128
|
+
return insertionIndex(data.frames, data.from, own.y + own.height / 2 + gesture.dy);
|
|
129
|
+
},
|
|
130
|
+
drop: (data, to) => {
|
|
131
|
+
if (!commit(keys.indexOf(data.key), to)) return null;
|
|
132
|
+
return { x: 0, y: listShiftOffsets(data.frames, data.from, to)[data.from] };
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const { drag } = session;
|
|
136
|
+
// Frames measured for another order would aim and commit at the wrong rows.
|
|
137
|
+
const stale = drag !== null && !drag.settling && drag.data.order !== order;
|
|
138
|
+
React.useEffect(() => {
|
|
139
|
+
if (stale) session.cancel();
|
|
140
|
+
});
|
|
141
|
+
const shifts =
|
|
142
|
+
drag && !drag.settling && !stale ? listShiftOffsets(drag.data.frames, drag.data.from, drag.target) : null;
|
|
143
|
+
|
|
144
|
+
const handleFor = (item, key, index) => {
|
|
145
|
+
const actions = listMoveActions(index, list.length, words);
|
|
146
|
+
return moveHandle({
|
|
147
|
+
itemKey: key,
|
|
148
|
+
name: itemLabel(item, key, getItemLabel),
|
|
149
|
+
actions,
|
|
150
|
+
run: (name) => {
|
|
151
|
+
const action = actions.find((a) => a.name === name);
|
|
152
|
+
if (action) moveKey(key, action.to);
|
|
153
|
+
},
|
|
154
|
+
onKey: (pressed) => {
|
|
155
|
+
const step = keyboardStep(pressed);
|
|
156
|
+
if (step) moveKey(key, index + step.by);
|
|
157
|
+
return step !== null;
|
|
158
|
+
},
|
|
159
|
+
session,
|
|
160
|
+
handles,
|
|
161
|
+
color: tokens.handleColor,
|
|
162
|
+
words,
|
|
163
|
+
testID,
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// With no renderItem a row shows the item's label as its title.
|
|
168
|
+
const draw = (item, index, dragging) =>
|
|
169
|
+
typeof renderItem === "function" ? renderItem({ item, index, dragging }) : undefined;
|
|
170
|
+
const content = list.map((item, index) => {
|
|
171
|
+
const key = keys[index];
|
|
172
|
+
return h(
|
|
173
|
+
SortableRow,
|
|
174
|
+
{
|
|
175
|
+
key,
|
|
176
|
+
rowRef: (node) => (node ? rows.current.set(key, node) : rows.current.delete(key)),
|
|
177
|
+
shift: shifts ? shifts[index] : 0,
|
|
178
|
+
slot: rowSlot(drag, key),
|
|
179
|
+
tokens,
|
|
180
|
+
title: itemLabel(item, key, getItemLabel),
|
|
181
|
+
testID: testID ? `${testID}-row-${key}` : undefined,
|
|
182
|
+
leading: canDragItem(canDrag, item) ? handleFor(item, key, index) : null,
|
|
183
|
+
},
|
|
184
|
+
draw(item, index, false),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const lifted = drag ? list[keys.indexOf(drag.key)] : undefined;
|
|
189
|
+
const proxy =
|
|
190
|
+
drag && lifted !== undefined
|
|
191
|
+
? h(
|
|
192
|
+
Animated.View,
|
|
193
|
+
{
|
|
194
|
+
...HIDDEN_FROM_READERS,
|
|
195
|
+
testID: testID ? `${testID}-proxy` : undefined,
|
|
196
|
+
style: [
|
|
197
|
+
proxyStyle(tokens, drag.data.origin, variant !== "card"),
|
|
198
|
+
{ transform: [...session.pan.getTranslateTransform(), { scale: PROXY_SCALE }] },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
h(
|
|
202
|
+
List,
|
|
203
|
+
{ variant },
|
|
204
|
+
h(
|
|
205
|
+
ListRow,
|
|
206
|
+
{
|
|
207
|
+
divider: false,
|
|
208
|
+
leading: h(HandleMark, { color: tokens.handleColor }),
|
|
209
|
+
title: itemLabel(lifted, drag.key, getItemLabel),
|
|
210
|
+
},
|
|
211
|
+
draw(lifted, keys.indexOf(drag.key), true),
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
: null;
|
|
216
|
+
|
|
217
|
+
return h(
|
|
218
|
+
View,
|
|
219
|
+
{ ref: rootRef, testID, accessibilityLabel, style: [{ alignSelf: "stretch" }, style] },
|
|
220
|
+
h(List, { variant }, content),
|
|
221
|
+
proxy,
|
|
222
|
+
liveRegion,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
SortableList.displayName = "SortableList";
|
|
227
|
+
return SortableList;
|
|
228
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// The drag-and-drop arithmetic behind `<SortableList>` and `<Board>`, without
|
|
2
|
+
// a renderer: where an item lands, how far the other rows move aside, which
|
|
3
|
+
// column the dragged card is over, how fast a narrow board scrolls, and the
|
|
4
|
+
// move actions offered when dragging is not possible. Every frame is a
|
|
5
|
+
// window rectangle from `measureInWindow`, so no parent padding or scroll
|
|
6
|
+
// offset enters a comparison.
|
|
7
|
+
|
|
8
|
+
/** How far a press must travel before it becomes a drag rather than a tap. */
|
|
9
|
+
export const DRAG_SLOP = 6;
|
|
10
|
+
/** The band at a narrow board's edge that scrolls it while a card is held there. */
|
|
11
|
+
export const AUTO_SCROLL_EDGE = 48;
|
|
12
|
+
/** The most a held card scrolls the board per tick, at the very edge. */
|
|
13
|
+
export const AUTO_SCROLL_MAX_STEP = 16;
|
|
14
|
+
/** A column's width when a narrow board scrolls its columns sideways. */
|
|
15
|
+
export const BOARD_COLUMN_WIDTH = 280;
|
|
16
|
+
|
|
17
|
+
/** The words a move announces and its controls carry; `{item}` and the like are filled in. */
|
|
18
|
+
export const DEFAULT_SORTABLE_LABELS = Object.freeze({
|
|
19
|
+
handle: "Move {item}",
|
|
20
|
+
hint: "Drag to move it, or open the move options. The arrow keys move it too.",
|
|
21
|
+
moveUp: "Move up",
|
|
22
|
+
moveDown: "Move down",
|
|
23
|
+
moveToTop: "Move to top",
|
|
24
|
+
moveToBottom: "Move to bottom",
|
|
25
|
+
moveToColumn: "Move to {column}",
|
|
26
|
+
moved: "{item} moved to position {position} of {total}",
|
|
27
|
+
movedToColumn: "{item} moved to {column}, position {position}",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The labels a widget passed, over the defaults; a blank entry keeps the default. */
|
|
31
|
+
export function resolveSortableLabels(labels) {
|
|
32
|
+
const out = { ...DEFAULT_SORTABLE_LABELS };
|
|
33
|
+
if (!labels || typeof labels !== "object") return out;
|
|
34
|
+
for (const name of Object.keys(DEFAULT_SORTABLE_LABELS)) {
|
|
35
|
+
if (typeof labels[name] === "string" && labels[name] !== "") out[name] = labels[name];
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A label with its `{name}` placeholders filled from `values`. */
|
|
41
|
+
export function fillLabel(template, values) {
|
|
42
|
+
return String(template).replace(/\{(\w+)\}/g, (whole, name) =>
|
|
43
|
+
values && values[name] !== undefined && values[name] !== null ? String(values[name]) : whole,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An item's key: `keyExtractor` when given, else its `id`, `key`, or position. */
|
|
48
|
+
export function itemKey(item, index, keyExtractor) {
|
|
49
|
+
if (typeof keyExtractor === "function") return String(keyExtractor(item, index));
|
|
50
|
+
if (item && typeof item === "object") {
|
|
51
|
+
if (item.id !== undefined && item.id !== null) return String(item.id);
|
|
52
|
+
if (item.key !== undefined && item.key !== null) return String(item.key);
|
|
53
|
+
}
|
|
54
|
+
return String(index);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The words that name an item to a screen reader. */
|
|
58
|
+
export function itemLabel(item, key, getItemLabel) {
|
|
59
|
+
if (typeof getItemLabel === "function") {
|
|
60
|
+
const label = getItemLabel(item);
|
|
61
|
+
if (label !== undefined && label !== null && label !== "") return String(label);
|
|
62
|
+
}
|
|
63
|
+
if (item && typeof item === "object") {
|
|
64
|
+
for (const name of ["title", "name", "label"]) {
|
|
65
|
+
if (typeof item[name] === "string" && item[name] !== "") return item[name];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (typeof item === "string" || typeof item === "number") return String(item);
|
|
69
|
+
return key;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether the drag may start for this item: a flag, or a per-item answer. */
|
|
73
|
+
export function canDragItem(canDrag, item) {
|
|
74
|
+
if (typeof canDrag === "function") return Boolean(canDrag(item));
|
|
75
|
+
return canDrag !== false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function clampIndex(index, length) {
|
|
79
|
+
if (length <= 0) return 0;
|
|
80
|
+
return Math.max(0, Math.min(length - 1, index));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A copy of `items` with the one at `from` moved to `to`. */
|
|
84
|
+
export function moveItem(items, from, to) {
|
|
85
|
+
const next = [...items];
|
|
86
|
+
if (from < 0 || from >= next.length) return next;
|
|
87
|
+
const [moved] = next.splice(from, 1);
|
|
88
|
+
next.splice(clampIndex(to, next.length + 1), 0, moved);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether a press has travelled far enough to be a drag. */
|
|
93
|
+
export function isDragGesture(dx, dy, slop = DRAG_SLOP) {
|
|
94
|
+
return Math.max(Math.abs(dx), Math.abs(dy)) >= slop;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const midY = (frame) => frame.y + frame.height / 2;
|
|
98
|
+
const bottom = (frame) => frame.y + frame.height;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where the dragged row lands: the count of the OTHER rows whose middle sits
|
|
102
|
+
* above the dragged row's middle. Read against the frames measured when the
|
|
103
|
+
* drag began, so rows moving aside never feed back into the answer.
|
|
104
|
+
*/
|
|
105
|
+
export function insertionIndex(frames, from, centerY) {
|
|
106
|
+
let index = 0;
|
|
107
|
+
frames.forEach((frame, i) => {
|
|
108
|
+
if (i !== from && frame && midY(frame) < centerY) index += 1;
|
|
109
|
+
});
|
|
110
|
+
return index;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How far each row moves while the dragged one (at `from`) is held over
|
|
115
|
+
* `to`: the rows it passed step aside by its own slot, and the vacated slot
|
|
116
|
+
* travels to where it will land, so the gap always shows the drop.
|
|
117
|
+
*/
|
|
118
|
+
export function listShiftOffsets(frames, from, to) {
|
|
119
|
+
const offsets = frames.map(() => 0);
|
|
120
|
+
if (from === to || !frames[from] || !frames[to]) return offsets;
|
|
121
|
+
if (from < to) {
|
|
122
|
+
const step = frames[from + 1].y - frames[from].y;
|
|
123
|
+
for (let i = from + 1; i <= to; i += 1) offsets[i] = -step;
|
|
124
|
+
offsets[from] = bottom(frames[to]) - bottom(frames[from]);
|
|
125
|
+
} else {
|
|
126
|
+
const step = bottom(frames[from]) - bottom(frames[from - 1]);
|
|
127
|
+
for (let i = to; i < from; i += 1) offsets[i] = step;
|
|
128
|
+
offsets[from] = frames[to].y - frames[from].y;
|
|
129
|
+
}
|
|
130
|
+
return offsets;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The column whose frame holds `x`, or -1 when it is over none. */
|
|
134
|
+
export function columnAt(columnFrames, x) {
|
|
135
|
+
return columnFrames.findIndex((frame) => Boolean(frame) && x >= frame.x && x < frame.x + frame.width);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Where a card lands in a column: the count of its cards whose middle is above `centerY`. */
|
|
139
|
+
export function indexInColumn(cardFrames, centerY) {
|
|
140
|
+
return cardFrames.filter((frame) => frame && midY(frame) < centerY).length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The drop line's distance from the top of its column: halfway between the
|
|
145
|
+
* cards it falls between, below the last card, or under the header of an
|
|
146
|
+
* empty column.
|
|
147
|
+
*/
|
|
148
|
+
export function dropLineOffset(columnFrame, cardFrames, index, headerHeight, gap) {
|
|
149
|
+
const cards = cardFrames.filter(Boolean);
|
|
150
|
+
if (cards.length === 0) return headerHeight + gap / 2;
|
|
151
|
+
if (index <= 0) return cards[0].y - columnFrame.y - gap / 2;
|
|
152
|
+
if (index >= cards.length) return bottom(cards[cards.length - 1]) - columnFrame.y + gap / 2;
|
|
153
|
+
return (bottom(cards[index - 1]) + cards[index].y) / 2 - columnFrame.y;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Where a dropped card's top settles, from the top of its column: under the
|
|
158
|
+
* header, then below each card it lands after. Its slot is gone from
|
|
159
|
+
* `cardFrames` already, so this is the column once the move is made.
|
|
160
|
+
*/
|
|
161
|
+
export function landingTop(headerHeight, cardFrames, index, gap) {
|
|
162
|
+
const before = cardFrames.filter(Boolean).slice(0, Math.max(0, index));
|
|
163
|
+
return before.reduce((top, frame) => top + frame.height + gap, headerHeight + gap);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* How far to scroll a narrow board this tick: nothing in the middle, faster
|
|
168
|
+
* the deeper the held card sits in the band at either edge.
|
|
169
|
+
*/
|
|
170
|
+
export function autoScrollStep(position, start, end, edge = AUTO_SCROLL_EDGE, maxStep = AUTO_SCROLL_MAX_STEP) {
|
|
171
|
+
if (!(end > start) || !(edge > 0)) return 0;
|
|
172
|
+
const band = Math.min(edge, (end - start) / 2);
|
|
173
|
+
if (position < start + band) {
|
|
174
|
+
return -Math.round(maxStep * Math.min(1, (start + band - position) / band));
|
|
175
|
+
}
|
|
176
|
+
if (position > end - band) {
|
|
177
|
+
return Math.round(maxStep * Math.min(1, (position - (end - band)) / band));
|
|
178
|
+
}
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A scroll offset kept within what the content can scroll. */
|
|
183
|
+
export function clampScroll(offset, contentSize, viewportSize) {
|
|
184
|
+
return Math.max(0, Math.min(offset, Math.max(0, contentSize - viewportSize)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The moves a list row offers instead of dragging, each with where it puts
|
|
189
|
+
* the row. A move that changes nothing is left out, and so is "to top" or
|
|
190
|
+
* "to bottom" when it is the same step as "up" or "down".
|
|
191
|
+
*/
|
|
192
|
+
export function listMoveActions(index, count, labels) {
|
|
193
|
+
const last = count - 1;
|
|
194
|
+
return [
|
|
195
|
+
{ name: "moveToTop", label: labels.moveToTop, to: 0, also: index - 1 },
|
|
196
|
+
{ name: "moveUp", label: labels.moveUp, to: index - 1 },
|
|
197
|
+
{ name: "moveDown", label: labels.moveDown, to: index + 1 },
|
|
198
|
+
{ name: "moveToBottom", label: labels.moveToBottom, to: last, also: index + 1 },
|
|
199
|
+
]
|
|
200
|
+
.filter((action) => action.to >= 0 && action.to <= last && action.to !== index && action.to !== action.also)
|
|
201
|
+
.map(({ name, label, to }) => ({ name, label, to }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The moves a board card offers instead of dragging: up and down its column
|
|
206
|
+
* (when cards keep an order) and to the end of every other column.
|
|
207
|
+
*
|
|
208
|
+
* @param {{ columns: Array<{key: string, title: string}>, columnIndex: number,
|
|
209
|
+
* index: number, counts: number[], reorderInColumn: boolean, labels: object }} spec
|
|
210
|
+
*/
|
|
211
|
+
export function boardMoveActions({ columns, columnIndex, index, counts, reorderInColumn, labels }) {
|
|
212
|
+
const actions = [];
|
|
213
|
+
if (reorderInColumn) {
|
|
214
|
+
if (index > 0) actions.push({ name: "moveUp", label: labels.moveUp, column: columnIndex, index: index - 1 });
|
|
215
|
+
if (index < counts[columnIndex] - 1) {
|
|
216
|
+
actions.push({ name: "moveDown", label: labels.moveDown, column: columnIndex, index: index + 1 });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
columns.forEach((column, i) => {
|
|
220
|
+
if (i === columnIndex) return;
|
|
221
|
+
actions.push({
|
|
222
|
+
name: `moveTo:${column.key}`,
|
|
223
|
+
label: fillLabel(labels.moveToColumn, { column: column.title }),
|
|
224
|
+
column: i,
|
|
225
|
+
index: counts[i],
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
return actions;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const LIST_KEYS = Object.freeze({ ArrowUp: -1, ArrowDown: 1 });
|
|
232
|
+
const COLUMN_KEYS = Object.freeze({ ArrowLeft: -1, ArrowRight: 1 });
|
|
233
|
+
|
|
234
|
+
/** The step a key asks of a focused handle: `{ by }` in a list, `{ by }` or `{ column }` on a board. */
|
|
235
|
+
export function keyboardStep(key, board = false) {
|
|
236
|
+
if (key in LIST_KEYS) return { by: LIST_KEYS[key] };
|
|
237
|
+
if (board && key in COLUMN_KEYS) return { column: COLUMN_KEYS[key] };
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The board's cards grouped by column, each with its key; unknown columns are left out. */
|
|
242
|
+
export function boardLanes(columns, items, getColumn, keyExtractor) {
|
|
243
|
+
const lanes = columns.map(() => []);
|
|
244
|
+
const where = new Map(columns.map((column, i) => [String(column.key), i]));
|
|
245
|
+
items.forEach((item, position) => {
|
|
246
|
+
const lane = where.get(String(typeof getColumn === "function" ? getColumn(item) : undefined));
|
|
247
|
+
if (lane !== undefined) lanes[lane].push({ item, key: itemKey(item, position, keyExtractor) });
|
|
248
|
+
});
|
|
249
|
+
return lanes;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Where a card sits: its column and place in it, or null. */
|
|
253
|
+
export function findCard(lanes, key) {
|
|
254
|
+
for (let column = 0; column < lanes.length; column += 1) {
|
|
255
|
+
const index = lanes[column].findIndex((card) => card.key === key);
|
|
256
|
+
if (index !== -1) return { column, index };
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Where a card dropped in `column` at `index` actually lands: within the
|
|
263
|
+
* column's room, and null when that is where it already is (or a card may
|
|
264
|
+
* not change place within its own column).
|
|
265
|
+
*/
|
|
266
|
+
export function boardLanding(lanes, from, column, index, reorderInColumn) {
|
|
267
|
+
if (!from || column < 0 || column >= lanes.length) return null;
|
|
268
|
+
const sameColumn = column === from.column;
|
|
269
|
+
const room = lanes[column].length - (sameColumn ? 1 : 0);
|
|
270
|
+
const at = Math.max(0, Math.min(index, room));
|
|
271
|
+
if (sameColumn && (!reorderInColumn || at === from.index)) return null;
|
|
272
|
+
return at;
|
|
273
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The values `<SortableList>` and `<Board>` paint a drag with, from the
|
|
2
|
+
// theme's own tokens: the lifted copy, the vacated slot, the drop target and
|
|
3
|
+
// the board's columns. Platform-free, so both hosts draw a drag from the same
|
|
4
|
+
// numbers and tests can pin them without a renderer. There is no theme scope
|
|
5
|
+
// of their own: rows wear the `list` scope and cards the `card` scope.
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
|
+
|
|
9
|
+
/** The lifted copy grows a little and lets the slot beneath show through. */
|
|
10
|
+
export const PROXY_SCALE = 1.03;
|
|
11
|
+
export const PROXY_OPACITY = 0.9;
|
|
12
|
+
/** The move handle: a compact control in a dense row, never below 36px. */
|
|
13
|
+
export const HANDLE_SIZE = 36;
|
|
14
|
+
export const HANDLE_ICON_SIZE = 18;
|
|
15
|
+
/** The line a board draws where a held card will land. */
|
|
16
|
+
export const DROP_LINE_HEIGHT = 3;
|
|
17
|
+
const TARGET_BORDER_WIDTH = 2;
|
|
18
|
+
|
|
19
|
+
/** A held item's own content, kept in layout but unseen. */
|
|
20
|
+
export const HIDDEN = Object.freeze({ opacity: 0 });
|
|
21
|
+
/** The lifted copy repeats an item the reader already knows. */
|
|
22
|
+
export const HIDDEN_FROM_READERS = Object.freeze({
|
|
23
|
+
"aria-hidden": true,
|
|
24
|
+
accessibilityElementsHidden: true,
|
|
25
|
+
importantForAccessibility: "no-hide-descendants",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The resolved drag palette and spacing for one theme. */
|
|
29
|
+
export function resolveSortableTokens(theme) {
|
|
30
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
31
|
+
const fallback = DEFAULT_THEME_TOKENS;
|
|
32
|
+
const colors = base.colors || fallback.colors;
|
|
33
|
+
const radii = base.radii || fallback.radii;
|
|
34
|
+
const spacing = base.spacing || fallback.spacing;
|
|
35
|
+
const elevation = base.elevation || fallback.elevation;
|
|
36
|
+
return {
|
|
37
|
+
handleColor: colors.onSurfaceMuted,
|
|
38
|
+
vacated: colors.surfaceMuted,
|
|
39
|
+
target: colors.primarySoft || colors.surfaceMuted,
|
|
40
|
+
targetBorder: colors.primary,
|
|
41
|
+
dropLine: colors.primary,
|
|
42
|
+
proxySurface: colors.surface,
|
|
43
|
+
radius: radii.md,
|
|
44
|
+
columnRadius: radii.lg,
|
|
45
|
+
columnBackground: colors.surfaceMuted,
|
|
46
|
+
columnPadding: spacing.sm,
|
|
47
|
+
columnGap: spacing.md,
|
|
48
|
+
cardGap: spacing.sm,
|
|
49
|
+
lift: elevation.lg || {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The slot a held item left (`vacated`) or will land in (`target`). */
|
|
54
|
+
export function slotStyle(tokens, kind) {
|
|
55
|
+
return {
|
|
56
|
+
backgroundColor: kind === "target" ? tokens.target : tokens.vacated,
|
|
57
|
+
borderRadius: tokens.radius,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The lifted copy that follows the pointer, placed over where the item was
|
|
63
|
+
* lifted from inside the component's own box. A card paints its own surface.
|
|
64
|
+
*/
|
|
65
|
+
export function proxyStyle(tokens, origin, withSurface = true) {
|
|
66
|
+
return {
|
|
67
|
+
position: "absolute",
|
|
68
|
+
left: origin.left,
|
|
69
|
+
top: origin.top,
|
|
70
|
+
width: origin.width,
|
|
71
|
+
zIndex: 10,
|
|
72
|
+
opacity: PROXY_OPACITY,
|
|
73
|
+
pointerEvents: "none",
|
|
74
|
+
borderRadius: tokens.radius,
|
|
75
|
+
...(withSurface ? { backgroundColor: tokens.proxySurface } : null),
|
|
76
|
+
...tokens.lift,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A board column; the one a card is held over takes the soft primary. */
|
|
81
|
+
export function columnStyle(tokens, isTarget) {
|
|
82
|
+
return {
|
|
83
|
+
gap: tokens.cardGap,
|
|
84
|
+
padding: tokens.columnPadding,
|
|
85
|
+
borderRadius: tokens.columnRadius,
|
|
86
|
+
borderWidth: TARGET_BORDER_WIDTH,
|
|
87
|
+
borderColor: isTarget ? tokens.targetBorder : tokens.columnBackground,
|
|
88
|
+
backgroundColor: isTarget ? tokens.target : tokens.columnBackground,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How far a column's cards sit in from its outer edge: border, then padding. */
|
|
93
|
+
export function columnContentInset(tokens) {
|
|
94
|
+
return TARGET_BORDER_WIDTH + tokens.columnPadding;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The drop line inside a column, `offset` from the column's top edge. */
|
|
98
|
+
export function dropLineStyle(tokens, offset) {
|
|
99
|
+
return {
|
|
100
|
+
position: "absolute",
|
|
101
|
+
left: tokens.columnPadding,
|
|
102
|
+
right: tokens.columnPadding,
|
|
103
|
+
top: offset - DROP_LINE_HEIGHT / 2 - TARGET_BORDER_WIDTH,
|
|
104
|
+
height: DROP_LINE_HEIGHT,
|
|
105
|
+
borderRadius: DROP_LINE_HEIGHT,
|
|
106
|
+
backgroundColor: tokens.dropLine,
|
|
107
|
+
pointerEvents: "none",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The move handle's own box: a real press target with the icon centred. */
|
|
112
|
+
export function handleStyle() {
|
|
113
|
+
return {
|
|
114
|
+
width: HANDLE_SIZE,
|
|
115
|
+
minHeight: HANDLE_SIZE,
|
|
116
|
+
alignSelf: "stretch",
|
|
117
|
+
alignItems: "center",
|
|
118
|
+
justifyContent: "center",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The ONE implementation behind `<SortableList>` and `<Board>`. The platform
|
|
2
|
+
// bindings (`sortable.js` / `sortable.native.js`) differ only in where the
|
|
3
|
+
// primitives come from and in the handle's touch style on the web.
|
|
4
|
+
|
|
5
|
+
import { makeDragKit } from "./drag-kit-view.js";
|
|
6
|
+
import { makeSortableList } from "./sortable-list-view.js";
|
|
7
|
+
import { makeBoard } from "./board-view.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bind both drag-and-drop components to one platform's primitives.
|
|
11
|
+
*
|
|
12
|
+
* @param {{View: any, Text: any, ScrollView: any, Animated: any,
|
|
13
|
+
* PanResponder: any, Platform: any, AccessibilityInfo: any, List: any,
|
|
14
|
+
* ListRow: any, Card: any, Menu: any, handleSurface: object|null,
|
|
15
|
+
* onOutsideScroll?: Function|null}} rn
|
|
16
|
+
*/
|
|
17
|
+
export function makeDragAndDrop(rn) {
|
|
18
|
+
const { View, Text, ScrollView, Animated, PanResponder, Platform, AccessibilityInfo, List, ListRow, Card, Menu, handleSurface, onOutsideScroll } = rn;
|
|
19
|
+
const kit = makeDragKit({ View, Text, Animated, PanResponder, Platform, AccessibilityInfo, Menu, handleSurface, onOutsideScroll });
|
|
20
|
+
return {
|
|
21
|
+
SortableList: makeSortableList({ View, Animated, List, ListRow }, kit),
|
|
22
|
+
Board: makeBoard({ View, Text, ScrollView, Animated, Card }, kit),
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/sortable.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `<SortableList>` and `<Board>` (web). Bind ./sortable-view.js to
|
|
2
|
+
// react-native-web; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { List, ListRow } from "./list.js";
|
|
6
|
+
import { Card } from "./card.js";
|
|
7
|
+
import { Menu } from "./popover.js";
|
|
8
|
+
import { makeDragAndDrop } from "./sortable-view.js";
|
|
9
|
+
|
|
10
|
+
// Without these a touch drag on the handle scrolls the page, and a mouse
|
|
11
|
+
// drag selects the text it passes over.
|
|
12
|
+
const handleSurface = Object.freeze({ touchAction: "none", userSelect: "none", cursor: "grab" });
|
|
13
|
+
|
|
14
|
+
// A wheel turned mid-drag scrolls the page under the pointer; the component's
|
|
15
|
+
// own scrolling is followed by the drag already.
|
|
16
|
+
function onOutsideScroll(root, handler) {
|
|
17
|
+
const listener = (event) => {
|
|
18
|
+
if (root && event.target instanceof Node && root.contains(event.target)) return;
|
|
19
|
+
handler();
|
|
20
|
+
};
|
|
21
|
+
document.addEventListener("scroll", listener, { capture: true, passive: true });
|
|
22
|
+
return () => document.removeEventListener("scroll", listener, { capture: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const { SortableList, Board } = makeDragAndDrop({
|
|
26
|
+
View: ReactNative.View,
|
|
27
|
+
Text: ReactNative.Text,
|
|
28
|
+
ScrollView: ReactNative.ScrollView,
|
|
29
|
+
Animated: ReactNative.Animated,
|
|
30
|
+
PanResponder: ReactNative.PanResponder,
|
|
31
|
+
Platform: ReactNative.Platform,
|
|
32
|
+
AccessibilityInfo: ReactNative.AccessibilityInfo,
|
|
33
|
+
List,
|
|
34
|
+
ListRow,
|
|
35
|
+
Card,
|
|
36
|
+
Menu,
|
|
37
|
+
handleSurface,
|
|
38
|
+
onOutsideScroll,
|
|
39
|
+
});
|