@uniflowed/ui 0.0.0-alpha.8 → 0.1.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/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +560 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +235 -198
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +334 -0
- package/i18n-provider.js +89 -0
- package/index.js +1254 -32
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +565 -0
- package/internal/collection.js +395 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/focus.js +64 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +117 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +316 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -25
- package/pagination.js +34 -22
- package/popover.js +367 -0
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +78 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +112 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +404 -0
- package/tree.js +8 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import * as React from "@uniflowed/react";
|
|
5
|
+
import { useId, useRef, useState } from "@uniflowed/react";
|
|
6
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
7
|
+
import { useDragAndDrop } from "../drag-drop.js";
|
|
8
|
+
import { useControlled } from "./controlled-state.js";
|
|
9
|
+
import { composeHandlers, composeRefs, withProps } from "./merge-props.js";
|
|
10
|
+
import type { RenderProp, Rest } from "./merge-props.js";
|
|
11
|
+
import { directionOf } from "./roving-focus.js";
|
|
12
|
+
import { startsWithLocale, useLocale } from "../i18n-provider.js";
|
|
13
|
+
|
|
14
|
+
export type CollectionItem = {
|
|
15
|
+
readonly key: string,
|
|
16
|
+
readonly textValue: string,
|
|
17
|
+
readonly disabled?: boolean,
|
|
18
|
+
readonly children?: $ReadOnlyArray<CollectionItem>,
|
|
19
|
+
};
|
|
20
|
+
export type CollectionItemState = {
|
|
21
|
+
readonly selected: boolean,
|
|
22
|
+
readonly active: boolean,
|
|
23
|
+
readonly disabled: boolean,
|
|
24
|
+
readonly level: number,
|
|
25
|
+
};
|
|
26
|
+
type Entry = {
|
|
27
|
+
item: CollectionItem,
|
|
28
|
+
level: number,
|
|
29
|
+
parent: string | null,
|
|
30
|
+
position: number,
|
|
31
|
+
count: number,
|
|
32
|
+
};
|
|
33
|
+
type Kind = "listbox" | "grid" | "tree" | "tags";
|
|
34
|
+
|
|
35
|
+
function entries(
|
|
36
|
+
items: $ReadOnlyArray<CollectionItem>,
|
|
37
|
+
expanded: $ReadOnlyArray<string>,
|
|
38
|
+
tree: boolean,
|
|
39
|
+
): Array<Entry> {
|
|
40
|
+
const result = [];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const visit = (
|
|
43
|
+
siblings: $ReadOnlyArray<CollectionItem>,
|
|
44
|
+
level: number,
|
|
45
|
+
parent: string | null,
|
|
46
|
+
): void => {
|
|
47
|
+
siblings.forEach((item, index) => {
|
|
48
|
+
if (seen.has(item.key)) throw new Error(`Duplicate collection key: ${item.key}`);
|
|
49
|
+
seen.add(item.key);
|
|
50
|
+
result.push({ item, level, parent, position: index + 1, count: siblings.length });
|
|
51
|
+
const children = item.children;
|
|
52
|
+
if (tree && children != null && expanded.includes(item.key))
|
|
53
|
+
visit(children, level + 1, item.key);
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
visit(items, 1, null);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Data owns order and identity. Only visible tree descendants and virtual rows enter the DOM. */
|
|
61
|
+
export component CollectionRoot(kind: Kind, options: CollectionProps) {
|
|
62
|
+
const {
|
|
63
|
+
items,
|
|
64
|
+
children,
|
|
65
|
+
selectionMode: requestedSelectionMode = "single",
|
|
66
|
+
selectedKeys,
|
|
67
|
+
defaultSelectedKeys = [],
|
|
68
|
+
onSelectionChange,
|
|
69
|
+
disabledKeys = [],
|
|
70
|
+
expandedKeys,
|
|
71
|
+
defaultExpandedKeys = [],
|
|
72
|
+
onExpandedChange,
|
|
73
|
+
onRemove,
|
|
74
|
+
onReorder,
|
|
75
|
+
loading = false,
|
|
76
|
+
onLoadMore,
|
|
77
|
+
virtualized = false,
|
|
78
|
+
height = 300,
|
|
79
|
+
rowHeight = 30,
|
|
80
|
+
render,
|
|
81
|
+
...rest
|
|
82
|
+
} = options;
|
|
83
|
+
const selectionMode = kind === "tags" ? "none" : requestedSelectionMode;
|
|
84
|
+
if (height <= 0 || rowHeight <= 0 || !Number.isFinite(height) || !Number.isFinite(rowHeight))
|
|
85
|
+
throw new RangeError("Collection dimensions must be positive finite numbers");
|
|
86
|
+
const { locale } = useLocale();
|
|
87
|
+
const id = useId();
|
|
88
|
+
const root = useRef<HTMLElement | null>(null);
|
|
89
|
+
const anchor = useRef<string | null>(null);
|
|
90
|
+
const buffer = useRef({ text: "", time: 0 });
|
|
91
|
+
const [selected, setSelected] = useControlled(
|
|
92
|
+
selectedKeys,
|
|
93
|
+
defaultSelectedKeys,
|
|
94
|
+
onSelectionChange,
|
|
95
|
+
);
|
|
96
|
+
const [expanded, setExpanded] = useControlled(
|
|
97
|
+
expandedKeys,
|
|
98
|
+
defaultExpandedKeys,
|
|
99
|
+
onExpandedChange,
|
|
100
|
+
);
|
|
101
|
+
const [active, setActive] = useState<string | null>(null);
|
|
102
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
103
|
+
const [announcement, announce] = useState("");
|
|
104
|
+
const drag = useDragAndDrop({
|
|
105
|
+
disabled: onReorder == null,
|
|
106
|
+
onDrop: ({ keys, target }) => {
|
|
107
|
+
const moving = keys[0];
|
|
108
|
+
if (
|
|
109
|
+
[moving, target].some(
|
|
110
|
+
(key) => disabledKeys.includes(key) || items.find((item) => item.key === key)?.disabled,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return;
|
|
114
|
+
if (
|
|
115
|
+
moving === target ||
|
|
116
|
+
!items.some((item) => item.key === moving) ||
|
|
117
|
+
!items.some((item) => item.key === target)
|
|
118
|
+
)
|
|
119
|
+
return;
|
|
120
|
+
const ordered = items.filter((item) => item.key !== moving).map((item) => item.key);
|
|
121
|
+
ordered.splice(ordered.indexOf(target), 0, moving);
|
|
122
|
+
onReorder?.(ordered);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const rows = entries(items, expanded, kind === "tree");
|
|
126
|
+
const disabled = (item: CollectionItem) =>
|
|
127
|
+
item.disabled === true || disabledKeys.includes(item.key);
|
|
128
|
+
const enabled = rows.filter((row) => !disabled(row.item));
|
|
129
|
+
const focused =
|
|
130
|
+
enabled.find((row) => row.item.key === active) ??
|
|
131
|
+
enabled.find((row) => selected.includes(row.item.key)) ??
|
|
132
|
+
enabled[0];
|
|
133
|
+
const activeKey = focused?.item.key;
|
|
134
|
+
const choose = (key: string, range: boolean, toggle: boolean) => {
|
|
135
|
+
const item = rows.find((row) => row.item.key === key)?.item;
|
|
136
|
+
if (item == null || disabled(item) || selectionMode === "none") return;
|
|
137
|
+
let next;
|
|
138
|
+
if (selectionMode === "single") next = [key];
|
|
139
|
+
else if (range && anchor.current != null) {
|
|
140
|
+
const from = enabled.findIndex((row) => row.item.key === anchor.current);
|
|
141
|
+
const to = enabled.findIndex((row) => row.item.key === key);
|
|
142
|
+
next = enabled
|
|
143
|
+
.slice(Math.min(Math.max(from, 0), to), Math.max(from, to) + 1)
|
|
144
|
+
.map((row) => row.item.key);
|
|
145
|
+
} else {
|
|
146
|
+
next = toggle
|
|
147
|
+
? selected.includes(key)
|
|
148
|
+
? selected.filter((each) => each !== key)
|
|
149
|
+
: [...selected, key]
|
|
150
|
+
: [key];
|
|
151
|
+
anchor.current = key;
|
|
152
|
+
}
|
|
153
|
+
setSelected(next);
|
|
154
|
+
announce(`${next.length} selected`);
|
|
155
|
+
};
|
|
156
|
+
const land = (row: Entry, shift: boolean) => {
|
|
157
|
+
setActive(row.item.key);
|
|
158
|
+
const container = root.current;
|
|
159
|
+
if (virtualized && container != null) {
|
|
160
|
+
const top = rows.indexOf(row) * rowHeight;
|
|
161
|
+
if (top < container.scrollTop || top + rowHeight > container.scrollTop + height) {
|
|
162
|
+
container.scrollTop = top;
|
|
163
|
+
setScrollTop(top);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (shift) choose(row.item.key, true, false);
|
|
167
|
+
};
|
|
168
|
+
const remove = (key: string) => {
|
|
169
|
+
const at = enabled.findIndex((row) => row.item.key === key);
|
|
170
|
+
if (at < 0 || onRemove == null) return;
|
|
171
|
+
onRemove(key);
|
|
172
|
+
setActive(enabled[at + 1]?.item.key ?? enabled[at - 1]?.item.key ?? null);
|
|
173
|
+
announce(`Removed ${enabled[at].item.textValue}`);
|
|
174
|
+
};
|
|
175
|
+
const keydown = useStableCallback((event: $FlowFixMe) => {
|
|
176
|
+
if (
|
|
177
|
+
event.target !== event.currentTarget &&
|
|
178
|
+
event.target.closest("button,input,textarea,select")
|
|
179
|
+
)
|
|
180
|
+
return;
|
|
181
|
+
if (drag.dragging && event.key === "Escape") {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
drag.cancel();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (drag.dragging && event.key === "Enter" && activeKey != null) {
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
drag.drop(activeKey, undefined, focused?.item.textValue);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
onReorder != null &&
|
|
193
|
+
event.key === " " &&
|
|
194
|
+
(event.ctrlKey || event.metaKey) &&
|
|
195
|
+
focused != null
|
|
196
|
+
) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
drag.start(focused.item.key, focused.item.textValue);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const at = enabled.findIndex((row) => row.item.key === activeKey);
|
|
202
|
+
let next = null;
|
|
203
|
+
if (event.key === "ArrowDown") next = enabled[Math.min(at + 1, enabled.length - 1)];
|
|
204
|
+
else if (event.key === "ArrowUp") next = enabled[Math.max(at - 1, 0)];
|
|
205
|
+
else if (event.key === "Home") next = enabled[0];
|
|
206
|
+
else if (event.key === "End") {
|
|
207
|
+
next = enabled[enabled.length - 1];
|
|
208
|
+
if (!loading) onLoadMore?.();
|
|
209
|
+
} else if (
|
|
210
|
+
kind === "tree" &&
|
|
211
|
+
focused != null &&
|
|
212
|
+
["ArrowLeft", "ArrowRight"].includes(event.key)
|
|
213
|
+
) {
|
|
214
|
+
const open =
|
|
215
|
+
event.key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
|
|
216
|
+
const key = focused.item.key;
|
|
217
|
+
if (open && focused.item.children?.length) {
|
|
218
|
+
if (!expanded.includes(key)) setExpanded([...expanded, key]);
|
|
219
|
+
else next = enabled[at + 1];
|
|
220
|
+
} else if (!open && expanded.includes(key))
|
|
221
|
+
setExpanded(expanded.filter((each) => each !== key));
|
|
222
|
+
else if (!open) next = enabled.find((row) => row.item.key === focused.parent);
|
|
223
|
+
} else if (
|
|
224
|
+
(event.ctrlKey || event.metaKey) &&
|
|
225
|
+
event.key === "a" &&
|
|
226
|
+
selectionMode === "multiple"
|
|
227
|
+
) {
|
|
228
|
+
setSelected(enabled.map((row) => row.item.key));
|
|
229
|
+
announce(`${enabled.length} selected`);
|
|
230
|
+
} else if ((event.key === " " || event.key === "Enter") && activeKey != null)
|
|
231
|
+
choose(activeKey, event.shiftKey, true);
|
|
232
|
+
else if (
|
|
233
|
+
(event.key === "Delete" || event.key === "Backspace") &&
|
|
234
|
+
kind === "tags" &&
|
|
235
|
+
activeKey != null
|
|
236
|
+
)
|
|
237
|
+
remove(activeKey);
|
|
238
|
+
else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
239
|
+
const time = Date.now();
|
|
240
|
+
const text = time - buffer.current.time > 500 ? event.key : buffer.current.text + event.key;
|
|
241
|
+
buffer.current = { text, time };
|
|
242
|
+
const repeated = Array.from(text).every((char) => char === event.key);
|
|
243
|
+
const query = repeated ? event.key : text;
|
|
244
|
+
const start = repeated || text.length === 1 ? at + 1 : Math.max(at, 0);
|
|
245
|
+
for (let offset = 0; offset < enabled.length; offset += 1) {
|
|
246
|
+
const candidate = enabled[(start + offset) % enabled.length];
|
|
247
|
+
if (startsWithLocale(candidate.item.textValue, query, locale)) {
|
|
248
|
+
next = candidate;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} else return;
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
if (next != null) land(next, event.shiftKey);
|
|
255
|
+
});
|
|
256
|
+
const start = virtualized ? Math.max(0, Math.floor(scrollTop / rowHeight) - 2) : 0;
|
|
257
|
+
const end = virtualized
|
|
258
|
+
? Math.min(rows.length, start + Math.ceil(height / rowHeight) + 4)
|
|
259
|
+
: rows.length;
|
|
260
|
+
// aria-activedescendant must always name a mounted row, even after a pointer scroll.
|
|
261
|
+
const indices = new Set(rows.slice(start, end).map((_, index) => start + index));
|
|
262
|
+
const activeIndex = rows.findIndex((row) => row.item.key === activeKey);
|
|
263
|
+
if (activeIndex >= 0) indices.add(activeIndex);
|
|
264
|
+
const rowNodes = Array.from(indices)
|
|
265
|
+
.sort((a, b) => a - b)
|
|
266
|
+
.map((index) => {
|
|
267
|
+
const row = rows[index];
|
|
268
|
+
const { item } = row;
|
|
269
|
+
const chosen = selected.includes(item.key);
|
|
270
|
+
const grid = kind === "grid" || kind === "tags";
|
|
271
|
+
const content =
|
|
272
|
+
children?.(item, {
|
|
273
|
+
selected: chosen,
|
|
274
|
+
active: activeKey === item.key,
|
|
275
|
+
disabled: disabled(item),
|
|
276
|
+
level: row.level,
|
|
277
|
+
}) ?? item.textValue;
|
|
278
|
+
const props = {
|
|
279
|
+
...drag.getDragProps(item.key, item.textValue),
|
|
280
|
+
...drag.getDropProps(item.key, item.textValue),
|
|
281
|
+
onKeyDown: undefined,
|
|
282
|
+
draggable: onReorder != null && !disabled(item),
|
|
283
|
+
id: `${id}-${index}`,
|
|
284
|
+
role: grid ? "row" : kind === "tree" ? "treeitem" : "option",
|
|
285
|
+
"aria-selected": selectionMode === "none" ? undefined : chosen,
|
|
286
|
+
"aria-disabled": disabled(item) || undefined,
|
|
287
|
+
"aria-expanded":
|
|
288
|
+
kind === "tree" && item.children?.length ? expanded.includes(item.key) : undefined,
|
|
289
|
+
"aria-level": kind === "tree" ? row.level : undefined,
|
|
290
|
+
"aria-posinset": grid ? undefined : kind === "tree" ? row.position : index + 1,
|
|
291
|
+
"aria-setsize": grid ? undefined : kind === "tree" ? row.count : rows.length,
|
|
292
|
+
"aria-rowindex": grid ? index + 1 : undefined,
|
|
293
|
+
"data-key": item.key,
|
|
294
|
+
"data-active": activeKey === item.key || undefined,
|
|
295
|
+
style: virtualized
|
|
296
|
+
? { position: "absolute", top: index * rowHeight, height: rowHeight, width: "100%" }
|
|
297
|
+
: undefined,
|
|
298
|
+
onClick: (event: $FlowFixMe) => {
|
|
299
|
+
if (disabled(item)) return;
|
|
300
|
+
root.current?.focus();
|
|
301
|
+
setActive(item.key);
|
|
302
|
+
choose(item.key, event.shiftKey, selectionMode === "multiple");
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
return (
|
|
306
|
+
<div key={item.key} {...props}>
|
|
307
|
+
{grid ? (
|
|
308
|
+
<div role="gridcell">
|
|
309
|
+
{content}
|
|
310
|
+
{kind === "tags" && onRemove != null ? (
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
tabIndex={-1}
|
|
314
|
+
disabled={disabled(item)}
|
|
315
|
+
aria-label={`Remove ${item.textValue}`}
|
|
316
|
+
onClick={(event) => {
|
|
317
|
+
event.stopPropagation();
|
|
318
|
+
root.current?.focus();
|
|
319
|
+
remove(item.key);
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
×
|
|
323
|
+
</button>
|
|
324
|
+
) : null}
|
|
325
|
+
</div>
|
|
326
|
+
) : (
|
|
327
|
+
content
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
const setRef = useStableCallback((element: HTMLElement | null) => {
|
|
333
|
+
root.current = element;
|
|
334
|
+
});
|
|
335
|
+
const props = withProps(rest, {
|
|
336
|
+
ref: composeRefs(rest.ref, setRef),
|
|
337
|
+
role: kind === "tags" ? "grid" : kind,
|
|
338
|
+
tabIndex: 0,
|
|
339
|
+
"aria-activedescendant": activeIndex < 0 ? undefined : `${id}-${activeIndex}`,
|
|
340
|
+
"aria-multiselectable": selectionMode === "multiple" || undefined,
|
|
341
|
+
"aria-busy": loading || undefined,
|
|
342
|
+
"aria-rowcount": kind === "grid" || kind === "tags" ? rows.length : undefined,
|
|
343
|
+
onKeyDown: composeHandlers(rest.onKeyDown, keydown),
|
|
344
|
+
onScroll: composeHandlers(rest.onScroll, (event: $FlowFixMe) => {
|
|
345
|
+
if (virtualized) setScrollTop(event.currentTarget.scrollTop);
|
|
346
|
+
if (
|
|
347
|
+
!loading &&
|
|
348
|
+
event.currentTarget.scrollTop + event.currentTarget.clientHeight >=
|
|
349
|
+
event.currentTarget.scrollHeight
|
|
350
|
+
)
|
|
351
|
+
onLoadMore?.();
|
|
352
|
+
}),
|
|
353
|
+
style: virtualized
|
|
354
|
+
? { ...(rest.style as $FlowFixMe), height, overflow: "auto", position: "relative" }
|
|
355
|
+
: rest.style,
|
|
356
|
+
children: virtualized ? (
|
|
357
|
+
<div role="presentation" style={{ height: rows.length * rowHeight, position: "relative" }}>
|
|
358
|
+
{rowNodes}
|
|
359
|
+
</div>
|
|
360
|
+
) : (
|
|
361
|
+
rowNodes
|
|
362
|
+
),
|
|
363
|
+
});
|
|
364
|
+
return (
|
|
365
|
+
<>
|
|
366
|
+
{render != null ? render(props) : <div {...props} />}
|
|
367
|
+
<span role="status" aria-live="polite">
|
|
368
|
+
{drag.announcement || announcement}
|
|
369
|
+
</span>
|
|
370
|
+
</>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export type CollectionProps = {
|
|
375
|
+
items: $ReadOnlyArray<CollectionItem>,
|
|
376
|
+
children?: (item: CollectionItem, state: CollectionItemState) => React.Node,
|
|
377
|
+
selectionMode?: "single" | "multiple" | "none",
|
|
378
|
+
selectedKeys?: $ReadOnlyArray<string>,
|
|
379
|
+
defaultSelectedKeys?: $ReadOnlyArray<string>,
|
|
380
|
+
onSelectionChange?: (keys: $ReadOnlyArray<string>) => void,
|
|
381
|
+
disabledKeys?: $ReadOnlyArray<string>,
|
|
382
|
+
expandedKeys?: $ReadOnlyArray<string>,
|
|
383
|
+
defaultExpandedKeys?: $ReadOnlyArray<string>,
|
|
384
|
+
onExpandedChange?: (keys: $ReadOnlyArray<string>) => void,
|
|
385
|
+
onRemove?: (key: string) => void,
|
|
386
|
+
onReorder?: (keys: $ReadOnlyArray<string>) => void,
|
|
387
|
+
loading?: boolean,
|
|
388
|
+
onLoadMore?: () => void,
|
|
389
|
+
virtualized?: boolean,
|
|
390
|
+
height?: number,
|
|
391
|
+
rowHeight?: number,
|
|
392
|
+
render?: RenderProp,
|
|
393
|
+
readonly key?: empty,
|
|
394
|
+
readonly [string]: mixed,
|
|
395
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The month a calendar shows, and the keyboard that walks it.
|
|
4
|
+
//
|
|
5
|
+
// # Why this is not `roving-focus.js`
|
|
6
|
+
//
|
|
7
|
+
// A date grid is a roving tab stop — one stop in the page's tab order, arrow
|
|
8
|
+
// keys inside it — so most of `internal/roving-focus.js` applies and the
|
|
9
|
+
// calendar uses it: `directionOf` for a right-to-left week, `isEnabled` nowhere,
|
|
10
|
+
// and the same rule about reading the document rather than a registry. What does
|
|
11
|
+
// not fit is the part that does the moving, and it does not fit for four
|
|
12
|
+
// reasons rather than one:
|
|
13
|
+
//
|
|
14
|
+
// * **The movement is arithmetic on a date, not an index in a NodeList.**
|
|
15
|
+
// `moveTo` walks the items it was handed. `ArrowDown` in a calendar is "a
|
|
16
|
+
// week later", and a week later is often a cell that is not in the grid at
|
|
17
|
+
// all yet — so the answer cannot be found among the elements, and a
|
|
18
|
+
// `movementFor` grown to two axes would still return "next" for a key whose
|
|
19
|
+
// real meaning is `+7 days`.
|
|
20
|
+
// * **Running off the end changes what is rendered.** `ArrowRight` on the 31st
|
|
21
|
+
// shows the next month *and* leaves focus on the 1st, which is a cell that
|
|
22
|
+
// did not exist when the key was pressed. That is a `pendingFocus` problem,
|
|
23
|
+
// the same shape `Menu.Body` solves for a menu opening onto its last item,
|
|
24
|
+
// and it is why the movement is computed as a value the component can act on
|
|
25
|
+
// over two renders rather than as a `.focus()` inside a helper.
|
|
26
|
+
// * **An unavailable date stays focusable.** `moveTo` skips anything
|
|
27
|
+
// `isEnabled` rejects, which is right for a menu item and wrong here: a
|
|
28
|
+
// reader arrowing through October has to be able to pass over the days that
|
|
29
|
+
// cannot be booked, each `aria-disabled="true"` and each still reachable. A
|
|
30
|
+
// grid that skipped them would present a month with holes in it and no way
|
|
31
|
+
// to find out what is in the holes.
|
|
32
|
+
// * **There is no wrap, and no ends.** A list has a first and a last item.
|
|
33
|
+
// A calendar has neither: every direction leads to another month.
|
|
34
|
+
//
|
|
35
|
+
// So the two primitives stay apart, and the boundary is that one owns *focus
|
|
36
|
+
// among elements that exist* and this one owns *which date the keyboard means*.
|
|
37
|
+
// Everything below is a pure function over `PlainDate` values: no element, no
|
|
38
|
+
// React, no document. That is deliberate for the same reason
|
|
39
|
+
// `internal/anchor.js` keeps `placeOverlay` pure — the month-boundary cases are
|
|
40
|
+
// the ones worth testing exhaustively, and testing them through a rendered grid
|
|
41
|
+
// would test the renderer instead.
|
|
42
|
+
//
|
|
43
|
+
// # Dates come from Temporal, and from `@uniflowed/core/temporal` in particular
|
|
44
|
+
//
|
|
45
|
+
// Not `Date`. A package that ships a temporal library and then computes a month
|
|
46
|
+
// length with `new Date(y, m + 1, 0)` is the opposite of what "build uf with uf"
|
|
47
|
+
// asks for, and `Date`'s month-is-zero-based, mutates-in-place, local-timezone
|
|
48
|
+
// arithmetic is where calendar bugs come from in the first place.
|
|
49
|
+
//
|
|
50
|
+
// The specifier is `@uniflowed/core/temporal` rather than `@uniflowed/temporal`,
|
|
51
|
+
// which is the same object — `packages/temporal/index.js` is a re-export and
|
|
52
|
+
// says so. It has to be that one: `@uniflowed/ui` is published to npm,
|
|
53
|
+
// `@uniflowed/temporal` is not yet (its calendar surface waits on the native
|
|
54
|
+
// runtime), and `tools/ci/publishable.sh` refuses a published package that
|
|
55
|
+
// depends on an unpublished one because `npm install` would answer `ETARGET`.
|
|
56
|
+
// When the name is published this import can move, and nothing else changes.
|
|
57
|
+
|
|
58
|
+
import type { PlainDate } from "@uniflowed/core/temporal";
|
|
59
|
+
import { Temporal } from "@uniflowed/core/temporal";
|
|
60
|
+
|
|
61
|
+
import type { Direction } from "./roving-focus.js";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ISO 8601's first day of the week, which is Monday.
|
|
65
|
+
*
|
|
66
|
+
* The fallback when nothing better is known, and deliberately not Sunday: ISO is
|
|
67
|
+
* the standard the rest of this package's date handling follows, and a default
|
|
68
|
+
* that matched one large locale would be a guess dressed as a convention.
|
|
69
|
+
*/
|
|
70
|
+
export const ISO_WEEK_START: number = 1;
|
|
71
|
+
|
|
72
|
+
/** Days in a week, which is the width of every grid here. */
|
|
73
|
+
export const DAYS_IN_WEEK: number = 7;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a key asks the focused date to become.
|
|
77
|
+
*
|
|
78
|
+
* A value rather than a new date, because the component that acts on it has to
|
|
79
|
+
* do two things with the answer — move the focus and possibly re-render a
|
|
80
|
+
* different month — and because "PageDown means a month" is the part worth
|
|
81
|
+
* asserting on its own.
|
|
82
|
+
*/
|
|
83
|
+
export type DateMovement =
|
|
84
|
+
| {| readonly kind: "days", readonly by: number |}
|
|
85
|
+
| {| readonly kind: "months", readonly by: number |}
|
|
86
|
+
| {| readonly kind: "years", readonly by: number |}
|
|
87
|
+
| {| readonly kind: "week-edge", readonly to: "start" | "end" |};
|
|
88
|
+
|
|
89
|
+
/** The part of a key event a grid reads. */
|
|
90
|
+
export type DateKeyPress = {
|
|
91
|
+
readonly key: string,
|
|
92
|
+
readonly shiftKey?: boolean,
|
|
93
|
+
...
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The movement a key asks for, or null when the key is not the grid's.
|
|
98
|
+
*
|
|
99
|
+
* The unhandled keys matter as much as the handled ones, for the reason
|
|
100
|
+
* `movementFor` gives: `Tab` belongs to the page and `Escape` belongs to
|
|
101
|
+
* whatever the calendar is inside, and a grid that swallowed either would be a
|
|
102
|
+
* place a reader could not leave.
|
|
103
|
+
*
|
|
104
|
+
* `direction` mirrors the horizontal pair and nothing else. `ArrowDown` is a
|
|
105
|
+
* week later in an Arabic calendar exactly as in an English one — a
|
|
106
|
+
* right-to-left page still runs top to bottom — and `Home` and `End` name the
|
|
107
|
+
* first and last day of the week in *reading* order, which is what
|
|
108
|
+
* `weekEdge` walks.
|
|
109
|
+
*
|
|
110
|
+
* `Shift` turns the two page keys into years, which is the one keyboard
|
|
111
|
+
* convention here that a reader cannot discover by trying: it is in the
|
|
112
|
+
* WAI-ARIA date-picker pattern, every native date field has it, and a year is
|
|
113
|
+
* otherwise twelve `PageDown` presses.
|
|
114
|
+
*/
|
|
115
|
+
export function movementForDateKey(event: DateKeyPress, direction: Direction): DateMovement | null {
|
|
116
|
+
const forward = direction === "rtl" ? -1 : 1;
|
|
117
|
+
const pages = event.shiftKey === true ? 12 : 1;
|
|
118
|
+
return match (event.key) {
|
|
119
|
+
"ArrowRight" => { kind: "days", by: forward },
|
|
120
|
+
"ArrowLeft" => { kind: "days", by: -forward },
|
|
121
|
+
"ArrowDown" => { kind: "days", by: DAYS_IN_WEEK },
|
|
122
|
+
"ArrowUp" => { kind: "days", by: -DAYS_IN_WEEK },
|
|
123
|
+
"Home" => { kind: "week-edge", to: "start" },
|
|
124
|
+
"End" => { kind: "week-edge", to: "end" },
|
|
125
|
+
// A year is twelve months rather than `{ years: 1 }`, so that the day is
|
|
126
|
+
// clamped once by the same rule the month keys use: the 29th of February
|
|
127
|
+
// plus a year is the 28th, and adding a year to a month-clamped date and
|
|
128
|
+
// adding twelve months have to agree or `Shift+PageDown` twice would not
|
|
129
|
+
// equal `PageDown` twenty-four times.
|
|
130
|
+
"PageUp" => { kind: "months", by: -pages },
|
|
131
|
+
"PageDown" => { kind: "months", by: pages },
|
|
132
|
+
_ => null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How far into its week `date` sits, counting from `weekStartsOn`.
|
|
138
|
+
*
|
|
139
|
+
* Zero for the first column, six for the last, in whichever order the week is
|
|
140
|
+
* laid out. Both `weekEdge` and the grid's leading blanks are this number.
|
|
141
|
+
*/
|
|
142
|
+
export function columnOf(date: PlainDate, weekStartsOn: number): number {
|
|
143
|
+
return (date.dayOfWeek - weekStartsOn + DAYS_IN_WEEK) % DAYS_IN_WEEK;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The first or the last day of `date`'s week, as this locale lays a week out. */
|
|
147
|
+
export function weekEdge(date: PlainDate, weekStartsOn: number, to: "start" | "end"): PlainDate {
|
|
148
|
+
const into = columnOf(date, weekStartsOn);
|
|
149
|
+
return to === "start"
|
|
150
|
+
? date.subtract({ days: into })
|
|
151
|
+
: date.add({ days: DAYS_IN_WEEK - 1 - into });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The date `movement` reaches from `from`.
|
|
156
|
+
*
|
|
157
|
+
* Nothing here refuses: a movement onto an unavailable date, into a month with
|
|
158
|
+
* nothing selectable in it, or past whatever range the caller allows still
|
|
159
|
+
* returns that date. Refusing is the component's decision and it makes a
|
|
160
|
+
* different one — the module header says why a reader has to be able to walk
|
|
161
|
+
* over an unavailable day rather than around it.
|
|
162
|
+
*
|
|
163
|
+
* The month and year movements clamp the day, because `PlainDate.add` does: the
|
|
164
|
+
* 31st of January plus a month is the 28th of February rather than the 3rd of
|
|
165
|
+
* March, which is Temporal's `constrain` overflow and what a person means by
|
|
166
|
+
* "next month".
|
|
167
|
+
*/
|
|
168
|
+
export function moveDate(from: PlainDate, movement: DateMovement, weekStartsOn: number): PlainDate {
|
|
169
|
+
return match (movement) {
|
|
170
|
+
{kind: "days", by: const by} => from.add({ days: by }),
|
|
171
|
+
{kind: "months", by: const by} => from.add({ months: by }),
|
|
172
|
+
{kind: "years", by: const by} => from.add({ years: by }),
|
|
173
|
+
{kind: "week-edge", to: const to} => weekEdge(from, weekStartsOn, to),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The rows of one month, seven cells wide, with `null` where no day falls.
|
|
179
|
+
*
|
|
180
|
+
* The blanks are blanks rather than the neighbouring months' days, and that is
|
|
181
|
+
* the decision the rest of the keyboard behaviour rests on. A grid that showed
|
|
182
|
+
* the 1st of November inside October's would have two cells that mean the same
|
|
183
|
+
* date whenever a reader moved between them, and the WAI-ARIA pattern's
|
|
184
|
+
* promise — that `ArrowRight` off the end of the month *changes the month* — has
|
|
185
|
+
* nowhere to happen. Every blank is a `<td>` with no `gridcell` role, so the
|
|
186
|
+
* rows stay rectangular for a screen reader counting columns.
|
|
187
|
+
*/
|
|
188
|
+
export function weeksOf(
|
|
189
|
+
year: number,
|
|
190
|
+
month: number,
|
|
191
|
+
weekStartsOn: number,
|
|
192
|
+
): $ReadOnlyArray<$ReadOnlyArray<PlainDate | null>> {
|
|
193
|
+
const first = Temporal.PlainDate.from({ day: 1, month, year });
|
|
194
|
+
const blanks = columnOf(first, weekStartsOn);
|
|
195
|
+
const days = first.daysInMonth;
|
|
196
|
+
const rows = Math.ceil((blanks + days) / DAYS_IN_WEEK);
|
|
197
|
+
|
|
198
|
+
const weeks = [];
|
|
199
|
+
for (let row = 0; row < rows; row += 1) {
|
|
200
|
+
const week = [];
|
|
201
|
+
for (let column = 0; column < DAYS_IN_WEEK; column += 1) {
|
|
202
|
+
const day = row * DAYS_IN_WEEK + column - blanks + 1;
|
|
203
|
+
week.push(day >= 1 && day <= days ? first.add({ days: day - 1 }) : null);
|
|
204
|
+
}
|
|
205
|
+
weeks.push(week);
|
|
206
|
+
}
|
|
207
|
+
return weeks;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Seven dates, one per column, for naming the columns.
|
|
212
|
+
*
|
|
213
|
+
* Dates rather than strings, because the names are the locale's and
|
|
214
|
+
* `PlainDate.toLocaleString` is where a locale's day names live. Any week would
|
|
215
|
+
* do; this one starts on a Monday so that `weekStartsOn` indexes it directly.
|
|
216
|
+
*/
|
|
217
|
+
export function weekdaysFrom(weekStartsOn: number): $ReadOnlyArray<PlainDate> {
|
|
218
|
+
const monday = Temporal.PlainDate.from("2024-01-01");
|
|
219
|
+
const days = [];
|
|
220
|
+
for (let column = 0; column < DAYS_IN_WEEK; column += 1) {
|
|
221
|
+
days.push(monday.add({ days: weekStartsOn - ISO_WEEK_START + column }));
|
|
222
|
+
}
|
|
223
|
+
return days;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether two dates are in the same month of the same year. */
|
|
227
|
+
export function sameMonth(a: PlainDate, b: PlainDate): boolean {
|
|
228
|
+
return a.year === b.year && a.month === b.month;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The day `locale` starts its weeks on, in ISO numbering.
|
|
233
|
+
*
|
|
234
|
+
* `Intl.Locale.prototype.getWeekInfo` is the answer the platform has, and its
|
|
235
|
+
* `firstDay` is already ISO-numbered, so no translation is needed. Everything
|
|
236
|
+
* around the call is defence rather than logic: Flow's vendored `intl.js`
|
|
237
|
+
* declares no `Locale` at all, the method is newer than the class on some hosts,
|
|
238
|
+
* and a locale tag that is merely *malformed* throws where an unknown one does
|
|
239
|
+
* not. Each of those ends at ISO Monday, which is a defensible week rather than
|
|
240
|
+
* a broken one.
|
|
241
|
+
*
|
|
242
|
+
* The cast is the narrowest available: one property read off `Intl`, for a class
|
|
243
|
+
* the checker has never heard of. Widening it to the return value would hide
|
|
244
|
+
* whether `firstDay` was a number at all, which is why that is asked separately.
|
|
245
|
+
*/
|
|
246
|
+
export function firstDayOfWeekFor(locale: string | void): number {
|
|
247
|
+
const factory = (Intl as $FlowFixMe).Locale;
|
|
248
|
+
if (typeof factory !== "function") {
|
|
249
|
+
return ISO_WEEK_START;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const info = new factory(locale ?? "und").getWeekInfo?.();
|
|
253
|
+
const first = info?.firstDay;
|
|
254
|
+
return typeof first === "number" && first >= 1 && first <= DAYS_IN_WEEK
|
|
255
|
+
? first
|
|
256
|
+
: ISO_WEEK_START;
|
|
257
|
+
} catch {
|
|
258
|
+
return ISO_WEEK_START;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import type { PlainDate } from "@uniflowed/core/temporal";
|
|
3
|
+
import { Temporal } from "@uniflowed/core/temporal";
|
|
4
|
+
export type DateRange = {| readonly start: string, readonly end: string |};
|
|
5
|
+
export function validateRange(range: DateRange | null): void {
|
|
6
|
+
if (range == null) return;
|
|
7
|
+
const start = Temporal.PlainDate.from(range.start).toString();
|
|
8
|
+
const end = Temporal.PlainDate.from(range.end).toString();
|
|
9
|
+
if (start > end) throw new RangeError("A date range must start on or before its end");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function unavailableInRange(
|
|
13
|
+
start: string,
|
|
14
|
+
end: string,
|
|
15
|
+
unavailable: ((date: PlainDate) => boolean) | void,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (unavailable == null) return false;
|
|
18
|
+
for (
|
|
19
|
+
let date = Temporal.PlainDate.from(start);
|
|
20
|
+
date.toString() <= end;
|
|
21
|
+
date = date.add({ days: 1 })
|
|
22
|
+
) {
|
|
23
|
+
if (unavailable(date)) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|