@uniflowed/ui 0.0.0-alpha.43 → 0.0.0-alpha.45
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/calendar.js +20 -7
- package/color-picker.js +172 -0
- package/date-field.js +9 -0
- package/date-range-picker.js +120 -0
- package/drag-drop.js +125 -0
- package/grid-list.js +8 -0
- package/i18n-provider.js +89 -0
- package/index.js +91 -0
- package/internal/collection.js +395 -0
- package/internal/date-range.js +26 -0
- package/internal/roving-focus.js +13 -4
- package/internal/segmented-field.js +316 -0
- package/list-box.js +13 -0
- package/number-field.js +263 -0
- package/package.json +5 -5
- package/range-calendar.js +78 -0
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/tree.js +8 -0
package/calendar.js
CHANGED
|
@@ -67,6 +67,7 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
|
67
67
|
import type { DateTimeFormatOptions, PlainDate } from "@uniflowed/core/temporal";
|
|
68
68
|
import { Temporal } from "@uniflowed/core/temporal";
|
|
69
69
|
|
|
70
|
+
import { useLocale } from "./i18n-provider.js";
|
|
70
71
|
import { useControlled } from "./internal/controlled-state.js";
|
|
71
72
|
import type { Rest } from "./internal/merge-props.js";
|
|
72
73
|
import {
|
|
@@ -127,6 +128,7 @@ type CalendarState = {|
|
|
|
127
128
|
*/
|
|
128
129
|
readonly focusedDayRef: { current: HTMLElement | null },
|
|
129
130
|
readonly isDisabled: (date: PlainDate) => boolean,
|
|
131
|
+
readonly isDateSelected: ((date: PlainDate) => boolean) | void,
|
|
130
132
|
readonly locale: string | void,
|
|
131
133
|
readonly moveFocus: (date: PlainDate, viaKeyboard: boolean) => void,
|
|
132
134
|
/**
|
|
@@ -180,6 +182,7 @@ export component CalendarRoot(
|
|
|
180
182
|
focusedDayRef?: { current: HTMLElement | null },
|
|
181
183
|
/** Whether a date may be chosen. A rejected date stays reachable; see the header. */
|
|
182
184
|
isDateDisabled?: (date: PlainDate) => boolean,
|
|
185
|
+
isDateSelected?: (date: PlainDate) => boolean,
|
|
183
186
|
locale?: string,
|
|
184
187
|
onMonthChange?: (firstOfMonth: PlainDate) => mixed,
|
|
185
188
|
onValueChange?: (value: PlainDate) => mixed,
|
|
@@ -189,6 +192,8 @@ export component CalendarRoot(
|
|
|
189
192
|
weekStartsOn?: number,
|
|
190
193
|
...rest: Rest
|
|
191
194
|
) {
|
|
195
|
+
const inherited = useLocale();
|
|
196
|
+
const resolvedLocale = locale ?? inherited.locale;
|
|
192
197
|
const base = useId();
|
|
193
198
|
const pendingFocusRef = useRef<string | null>(null);
|
|
194
199
|
// One is always allocated, because a hook may not be called conditionally;
|
|
@@ -228,8 +233,8 @@ export component CalendarRoot(
|
|
|
228
233
|
});
|
|
229
234
|
|
|
230
235
|
const weekStart = useMemo(
|
|
231
|
-
() => (weekStartsOn == null ? firstDayOfWeekFor(
|
|
232
|
-
[
|
|
236
|
+
() => (weekStartsOn == null ? firstDayOfWeekFor(resolvedLocale) : weekStartsOn),
|
|
237
|
+
[resolvedLocale, weekStartsOn],
|
|
233
238
|
);
|
|
234
239
|
|
|
235
240
|
const isDisabled = useStableCallback((date: PlainDate) => isDateDisabled?.(date) === true);
|
|
@@ -256,7 +261,10 @@ export component CalendarRoot(
|
|
|
256
261
|
setFocused((current) => (current.equals(date) ? current : date));
|
|
257
262
|
});
|
|
258
263
|
|
|
259
|
-
const caption = useMemo(
|
|
264
|
+
const caption = useMemo(
|
|
265
|
+
() => focused.toLocaleString(resolvedLocale, CAPTION_FORMAT),
|
|
266
|
+
[focused, resolvedLocale],
|
|
267
|
+
);
|
|
260
268
|
|
|
261
269
|
const [announcement, setAnnouncement] = useState("");
|
|
262
270
|
const shown = useRef(`${focused.year}-${focused.month}`);
|
|
@@ -284,7 +292,8 @@ export component CalendarRoot(
|
|
|
284
292
|
focused,
|
|
285
293
|
focusedDayRef: dayRef,
|
|
286
294
|
isDisabled,
|
|
287
|
-
|
|
295
|
+
isDateSelected,
|
|
296
|
+
locale: resolvedLocale,
|
|
288
297
|
moveFocus,
|
|
289
298
|
pendingFocusRef,
|
|
290
299
|
select,
|
|
@@ -299,7 +308,8 @@ export component CalendarRoot(
|
|
|
299
308
|
dayRef,
|
|
300
309
|
focused,
|
|
301
310
|
isDisabled,
|
|
302
|
-
|
|
311
|
+
isDateSelected,
|
|
312
|
+
resolvedLocale,
|
|
303
313
|
moveFocus,
|
|
304
314
|
select,
|
|
305
315
|
selected,
|
|
@@ -354,7 +364,7 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
|
|
|
354
364
|
return;
|
|
355
365
|
}
|
|
356
366
|
const at = focused.toString();
|
|
357
|
-
const cell: HTMLElement | null =
|
|
367
|
+
const cell: HTMLElement | null = grid.querySelector(`[data-date="${at}"]`) as $FlowFixMe;
|
|
358
368
|
focusedDayRef.current = cell;
|
|
359
369
|
// Only when a key asked for it, and only once the render it asked for has
|
|
360
370
|
// happened: `ArrowRight` off the end of October sets this to the 1st of
|
|
@@ -379,6 +389,7 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
|
|
|
379
389
|
// attribute points at the caption that is there either way, so the two
|
|
380
390
|
// answers cannot disagree.
|
|
381
391
|
aria-labelledby={`${calendar.base}-caption`}
|
|
392
|
+
aria-multiselectable={calendar.isDateSelected != null || undefined}
|
|
382
393
|
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
383
394
|
const grid: $FlowFixMe = event.currentTarget;
|
|
384
395
|
const movement = movementForDateKey(event, directionOf(grid));
|
|
@@ -452,7 +463,9 @@ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarD
|
|
|
452
463
|
export component CalendarDay(date: PlainDate, children?: React.Node, ...rest: Rest) {
|
|
453
464
|
const calendar = useCalendar("Calendar.Day");
|
|
454
465
|
const disabled = calendar.isDisabled(date);
|
|
455
|
-
const chosen =
|
|
466
|
+
const chosen =
|
|
467
|
+
calendar.isDateSelected?.(date) ??
|
|
468
|
+
(calendar.selected != null && calendar.selected.equals(date));
|
|
456
469
|
const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
|
|
457
470
|
|
|
458
471
|
return (
|
package/color-picker.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { createContext, useContext, useState } from "@uniflowed/react";
|
|
5
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
6
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
7
|
+
import { composeHandlers, withProps } from "./internal/merge-props.js";
|
|
8
|
+
import { directionOf } from "./internal/roving-focus.js";
|
|
9
|
+
|
|
10
|
+
/** Canonical #rrggbb or #rrggbbaa, with CSS's short hex forms accepted at the boundary. */
|
|
11
|
+
export function parseColor(value: string): string | null {
|
|
12
|
+
let hex = value.trim().toLowerCase();
|
|
13
|
+
if (!/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(hex)) return null;
|
|
14
|
+
if (hex.length <= 5)
|
|
15
|
+
hex =
|
|
16
|
+
"#" +
|
|
17
|
+
Array.from(hex.slice(1))
|
|
18
|
+
.map((char) => char + char)
|
|
19
|
+
.join("");
|
|
20
|
+
return hex;
|
|
21
|
+
}
|
|
22
|
+
type ColorState = { value: string, disabled: boolean, set: (value: string) => void };
|
|
23
|
+
const ColorContext: React.Context<ColorState | null> = createContext(null);
|
|
24
|
+
hook useColor(): ColorState {
|
|
25
|
+
const color = useContext(ColorContext);
|
|
26
|
+
if (color == null) throw new Error("ColorPicker parts must be inside ColorPicker.Root");
|
|
27
|
+
return color;
|
|
28
|
+
}
|
|
29
|
+
export component ColorPickerRoot(
|
|
30
|
+
children: React.Node,
|
|
31
|
+
value?: string,
|
|
32
|
+
defaultValue?: string = "#000000",
|
|
33
|
+
onValueChange?: (value: string) => void,
|
|
34
|
+
disabled?: boolean = false,
|
|
35
|
+
render?: RenderProp,
|
|
36
|
+
...rest: Rest
|
|
37
|
+
) {
|
|
38
|
+
const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
|
|
39
|
+
const normalized = parseColor(current);
|
|
40
|
+
if (normalized == null) throw new RangeError("ColorPicker value must be a CSS hex color");
|
|
41
|
+
const state = {
|
|
42
|
+
value: normalized,
|
|
43
|
+
disabled,
|
|
44
|
+
set: (next: string) => {
|
|
45
|
+
const color = parseColor(next);
|
|
46
|
+
if (!disabled && color != null) setCurrent(color);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const props = withProps(rest, { children });
|
|
50
|
+
return (
|
|
51
|
+
<ColorContext.Provider value={state}>
|
|
52
|
+
{render != null ? render(props) : <div {...props} />}
|
|
53
|
+
</ColorContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
export component ColorPickerInput(render?: RenderProp, ...rest: Rest) {
|
|
57
|
+
const color = useColor();
|
|
58
|
+
const props = withProps(rest, {
|
|
59
|
+
type: "color",
|
|
60
|
+
value: color.value.slice(0, 7),
|
|
61
|
+
disabled: color.disabled,
|
|
62
|
+
onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
|
|
63
|
+
color.set(event.currentTarget.value + color.value.slice(7)),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
return render != null ? render(props) : <input {...props} />;
|
|
67
|
+
}
|
|
68
|
+
export component ColorPickerField(render?: RenderProp, ...rest: Rest) {
|
|
69
|
+
const color = useColor();
|
|
70
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
71
|
+
const text = draft ?? color.value;
|
|
72
|
+
const invalid = parseColor(text) == null;
|
|
73
|
+
const commit = () => {
|
|
74
|
+
if (!invalid) {
|
|
75
|
+
color.set(text);
|
|
76
|
+
setDraft(null);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const props = withProps(rest, {
|
|
80
|
+
type: "text",
|
|
81
|
+
value: text,
|
|
82
|
+
disabled: color.disabled,
|
|
83
|
+
"aria-invalid": invalid ? "true" : rest["aria-invalid"],
|
|
84
|
+
onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
|
|
85
|
+
setDraft(event.currentTarget.value),
|
|
86
|
+
),
|
|
87
|
+
onBlur: composeHandlers(rest.onBlur, commit),
|
|
88
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
|
|
89
|
+
if (event.key === "Enter") {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
commit();
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
return render != null ? render(props) : <input {...props} />;
|
|
96
|
+
}
|
|
97
|
+
export component ColorPickerChannel(
|
|
98
|
+
channel: "red" | "green" | "blue" | "alpha",
|
|
99
|
+
render?: RenderProp,
|
|
100
|
+
...rest: Rest
|
|
101
|
+
) {
|
|
102
|
+
const color = useColor();
|
|
103
|
+
const at = ["red", "green", "blue", "alpha"].indexOf(channel);
|
|
104
|
+
const value = parseInt(
|
|
105
|
+
(color.value + (color.value.length === 7 ? "ff" : "")).slice(1 + at * 2, 3 + at * 2),
|
|
106
|
+
16,
|
|
107
|
+
);
|
|
108
|
+
const change = (next: number) => {
|
|
109
|
+
const hex = color.value.slice(1).padEnd(8, "f");
|
|
110
|
+
const byte = Math.max(0, Math.min(255, Math.round(next)))
|
|
111
|
+
.toString(16)
|
|
112
|
+
.padStart(2, "0");
|
|
113
|
+
const full = hex.slice(0, at * 2) + byte + hex.slice(at * 2 + 2);
|
|
114
|
+
color.set("#" + (at === 3 || color.value.length === 9 ? full : full.slice(0, 6)));
|
|
115
|
+
};
|
|
116
|
+
const props = withProps(rest, {
|
|
117
|
+
type: "range",
|
|
118
|
+
min: 0,
|
|
119
|
+
max: 255,
|
|
120
|
+
step: 1,
|
|
121
|
+
value,
|
|
122
|
+
disabled: color.disabled,
|
|
123
|
+
"aria-label": rest["aria-label"] ?? channel,
|
|
124
|
+
"aria-valuetext": channel === "alpha" ? `${Math.round((value / 255) * 100)}%` : String(value),
|
|
125
|
+
onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
|
|
126
|
+
change(Number(event.currentTarget.value)),
|
|
127
|
+
),
|
|
128
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
|
|
129
|
+
if (color.disabled) return;
|
|
130
|
+
const key = event.key;
|
|
131
|
+
if (
|
|
132
|
+
![
|
|
133
|
+
"ArrowLeft",
|
|
134
|
+
"ArrowRight",
|
|
135
|
+
"ArrowUp",
|
|
136
|
+
"ArrowDown",
|
|
137
|
+
"PageUp",
|
|
138
|
+
"PageDown",
|
|
139
|
+
"Home",
|
|
140
|
+
"End",
|
|
141
|
+
].includes(key)
|
|
142
|
+
)
|
|
143
|
+
return;
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
const rtl = directionOf(event.currentTarget) === "rtl";
|
|
146
|
+
change(
|
|
147
|
+
key === "Home"
|
|
148
|
+
? 0
|
|
149
|
+
: key === "End"
|
|
150
|
+
? 255
|
|
151
|
+
: value +
|
|
152
|
+
(key === "PageUp"
|
|
153
|
+
? 10
|
|
154
|
+
: key === "PageDown"
|
|
155
|
+
? -10
|
|
156
|
+
: key === "ArrowUp" || key === (rtl ? "ArrowLeft" : "ArrowRight")
|
|
157
|
+
? 1
|
|
158
|
+
: -1),
|
|
159
|
+
);
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
return render != null ? render(props) : <input {...props} />;
|
|
163
|
+
}
|
|
164
|
+
export component ColorPickerSwatch(render?: RenderProp, ...rest: Rest) {
|
|
165
|
+
const color = useColor();
|
|
166
|
+
const props = withProps(rest, {
|
|
167
|
+
"aria-hidden": "true",
|
|
168
|
+
"data-color": color.value,
|
|
169
|
+
style: { ...(rest.style as $FlowFixMe), backgroundColor: color.value },
|
|
170
|
+
});
|
|
171
|
+
return render != null ? render(props) : <span {...props} />;
|
|
172
|
+
}
|
package/date-field.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { SegmentedField } from "./internal/segmented-field.js";
|
|
5
|
+
import type { DateFieldProps } from "./internal/segmented-field.js";
|
|
6
|
+
export component DateField(...props: DateFieldProps) {
|
|
7
|
+
return <SegmentedField options={props} time={false} />;
|
|
8
|
+
}
|
|
9
|
+
export type { DateFieldProps } from "./internal/segmented-field.js";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { createContext, useContext, useRef } from "@uniflowed/react";
|
|
5
|
+
import type { PlainDate } from "@uniflowed/core/temporal";
|
|
6
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
7
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
8
|
+
import { forwarded } from "./internal/merge-props.js";
|
|
9
|
+
import type { DateRange } from "./internal/date-range.js";
|
|
10
|
+
import { validateRange, unavailableInRange } from "./internal/date-range.js";
|
|
11
|
+
import { CalendarMonth } from "./calendar.js";
|
|
12
|
+
import { RangeCalendarRoot } from "./range-calendar.js";
|
|
13
|
+
import { DateField } from "./date-field.js";
|
|
14
|
+
import { PopoverRoot, PopoverBody, PopoverTrigger } from "./popover.js";
|
|
15
|
+
|
|
16
|
+
type Picker = {
|
|
17
|
+
range: DateRange | null,
|
|
18
|
+
set: (range: DateRange | null) => void,
|
|
19
|
+
close: () => void,
|
|
20
|
+
minValue: string | void,
|
|
21
|
+
maxValue: string | void,
|
|
22
|
+
isDateDisabled: ((date: PlainDate) => boolean) | void,
|
|
23
|
+
};
|
|
24
|
+
const PickerContext: React.Context<Picker | null> = createContext(null);
|
|
25
|
+
hook usePicker(): Picker {
|
|
26
|
+
const picker = useContext(PickerContext);
|
|
27
|
+
if (picker == null) throw new Error("DateRangePicker parts must be inside DateRangePicker.Root");
|
|
28
|
+
return picker;
|
|
29
|
+
}
|
|
30
|
+
export component DateRangePickerRoot(
|
|
31
|
+
children: React.Node,
|
|
32
|
+
value?: DateRange | null,
|
|
33
|
+
defaultValue?: DateRange | null = null,
|
|
34
|
+
onValueChange?: (range: DateRange | null) => void,
|
|
35
|
+
open?: boolean,
|
|
36
|
+
defaultOpen?: boolean = false,
|
|
37
|
+
onOpenChange?: (open: boolean) => void,
|
|
38
|
+
minValue?: string,
|
|
39
|
+
maxValue?: string,
|
|
40
|
+
isDateDisabled?: (date: PlainDate) => boolean,
|
|
41
|
+
) {
|
|
42
|
+
const [range, setRange] = useControlled(value, defaultValue, onValueChange);
|
|
43
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
44
|
+
validateRange(range);
|
|
45
|
+
const state = {
|
|
46
|
+
range,
|
|
47
|
+
set: setRange,
|
|
48
|
+
close: () => setOpen(false),
|
|
49
|
+
minValue,
|
|
50
|
+
maxValue,
|
|
51
|
+
isDateDisabled,
|
|
52
|
+
};
|
|
53
|
+
return (
|
|
54
|
+
<PickerContext.Provider value={state}>
|
|
55
|
+
<PopoverRoot open={isOpen} onOpenChange={setOpen}>
|
|
56
|
+
{children}
|
|
57
|
+
</PopoverRoot>
|
|
58
|
+
</PickerContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
export component DateRangePickerStartField(...rest: Rest) {
|
|
62
|
+
const picker = usePicker();
|
|
63
|
+
return (
|
|
64
|
+
<DateField
|
|
65
|
+
{...forwarded(rest)}
|
|
66
|
+
value={picker.range?.start ?? null}
|
|
67
|
+
minValue={picker.minValue}
|
|
68
|
+
maxValue={picker.range?.end ?? picker.maxValue}
|
|
69
|
+
isDateUnavailable={(start) =>
|
|
70
|
+
unavailableInRange(start, picker.range?.end ?? start, picker.isDateDisabled)
|
|
71
|
+
}
|
|
72
|
+
onValueChange={(start) => {
|
|
73
|
+
if (start == null) picker.set(null);
|
|
74
|
+
else picker.set({ start, end: picker.range?.end ?? start });
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
export component DateRangePickerEndField(...rest: Rest) {
|
|
80
|
+
const picker = usePicker();
|
|
81
|
+
return (
|
|
82
|
+
<DateField
|
|
83
|
+
{...forwarded(rest)}
|
|
84
|
+
value={picker.range?.end ?? null}
|
|
85
|
+
minValue={picker.range?.start ?? picker.minValue}
|
|
86
|
+
maxValue={picker.maxValue}
|
|
87
|
+
isDateUnavailable={(end) =>
|
|
88
|
+
unavailableInRange(picker.range?.start ?? end, end, picker.isDateDisabled)
|
|
89
|
+
}
|
|
90
|
+
onValueChange={(end) => {
|
|
91
|
+
if (end == null) picker.set(null);
|
|
92
|
+
else picker.set({ start: picker.range?.start ?? end, end });
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
export component DateRangePickerTrigger(children: React.Node, ...rest: Rest) {
|
|
98
|
+
return <PopoverTrigger {...forwarded(rest)}>{children}</PopoverTrigger>;
|
|
99
|
+
}
|
|
100
|
+
export component DateRangePickerCalendar(children?: React.Node = <CalendarMonth />, ...rest: Rest) {
|
|
101
|
+
const picker = usePicker();
|
|
102
|
+
const dayRef = useRef<HTMLElement | null>(null);
|
|
103
|
+
return (
|
|
104
|
+
<PopoverBody {...forwarded(rest)} initialFocus={dayRef}>
|
|
105
|
+
<RangeCalendarRoot
|
|
106
|
+
value={picker.range}
|
|
107
|
+
minValue={picker.minValue}
|
|
108
|
+
maxValue={picker.maxValue}
|
|
109
|
+
isDateDisabled={picker.isDateDisabled}
|
|
110
|
+
focusedDayRef={dayRef}
|
|
111
|
+
onValueChange={(range) => {
|
|
112
|
+
picker.set(range);
|
|
113
|
+
picker.close();
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{children}
|
|
117
|
+
</RangeCalendarRoot>
|
|
118
|
+
</PopoverBody>
|
|
119
|
+
);
|
|
120
|
+
}
|
package/drag-drop.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import { useState } from "@uniflowed/react";
|
|
4
|
+
|
|
5
|
+
export type Drop = {| readonly keys: $ReadOnlyArray<string>, readonly target: string |};
|
|
6
|
+
export type DragAndDrop = {
|
|
7
|
+
dragging: boolean,
|
|
8
|
+
announcement: string,
|
|
9
|
+
start: (key: string, label: string) => void,
|
|
10
|
+
drop: (target: string, incoming?: $ReadOnlyArray<string>, label?: string) => void,
|
|
11
|
+
cancel: () => void,
|
|
12
|
+
getDragProps: (
|
|
13
|
+
key: string,
|
|
14
|
+
label: string,
|
|
15
|
+
) => {
|
|
16
|
+
draggable: boolean,
|
|
17
|
+
onDragStart: (event: $FlowFixMe) => void,
|
|
18
|
+
onDragEnd: () => void,
|
|
19
|
+
onKeyDown: (event: $FlowFixMe) => void,
|
|
20
|
+
},
|
|
21
|
+
getDropProps: (
|
|
22
|
+
target: string,
|
|
23
|
+
label?: string,
|
|
24
|
+
) => {
|
|
25
|
+
onDragOver: (event: $FlowFixMe) => void,
|
|
26
|
+
onDrop: (event: $FlowFixMe) => void,
|
|
27
|
+
onKeyDown: (event: $FlowFixMe) => void,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const MIME = "application/x-uf-collection";
|
|
31
|
+
|
|
32
|
+
/** Pointer drag data and keyboard lift/drop share one validated payload. */
|
|
33
|
+
export hook useDragAndDrop(options: {
|
|
34
|
+
onDrop: (drop: Drop) => void,
|
|
35
|
+
disabled?: boolean,
|
|
36
|
+
}): DragAndDrop {
|
|
37
|
+
const [keys, setKeys] = useState<$ReadOnlyArray<string>>([]);
|
|
38
|
+
const [announcement, announce] = useState("");
|
|
39
|
+
const cancel = () => {
|
|
40
|
+
setKeys([]);
|
|
41
|
+
announce("Drag cancelled");
|
|
42
|
+
};
|
|
43
|
+
const start = (key: string, label: string) => {
|
|
44
|
+
if (options.disabled) return;
|
|
45
|
+
setKeys([key]);
|
|
46
|
+
announce(`Picked up ${label}. Move to a drop target and press Enter. Escape cancels.`);
|
|
47
|
+
};
|
|
48
|
+
const drop = (
|
|
49
|
+
target: string,
|
|
50
|
+
incoming: $ReadOnlyArray<string> = keys,
|
|
51
|
+
label: string = target,
|
|
52
|
+
) => {
|
|
53
|
+
if (options.disabled || incoming.length === 0) return;
|
|
54
|
+
options.onDrop({ keys: incoming, target });
|
|
55
|
+
setKeys([]);
|
|
56
|
+
announce(`Dropped on ${label}`);
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
dragging: keys.length > 0,
|
|
60
|
+
announcement,
|
|
61
|
+
start,
|
|
62
|
+
drop,
|
|
63
|
+
cancel,
|
|
64
|
+
getDragProps: (key: string, label: string) => ({
|
|
65
|
+
draggable: !options.disabled,
|
|
66
|
+
onDragStart: (event: $FlowFixMe) => {
|
|
67
|
+
if (options.disabled) {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
start(key, label);
|
|
72
|
+
event.dataTransfer.setData(MIME, JSON.stringify([key]));
|
|
73
|
+
event.dataTransfer.setData("text/plain", label);
|
|
74
|
+
event.dataTransfer.effectAllowed = "move";
|
|
75
|
+
},
|
|
76
|
+
onDragEnd: () => setKeys([]),
|
|
77
|
+
onKeyDown: (event: $FlowFixMe) => {
|
|
78
|
+
if (event.key === "Escape" && keys.length > 0) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
cancel();
|
|
81
|
+
} else if (event.key === " " && !keys.length) {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
start(key, label);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
getDropProps: (target: string, label: string = target) => ({
|
|
88
|
+
onDragOver: (event: $FlowFixMe) => {
|
|
89
|
+
if (!options.disabled && Array.from(event.dataTransfer.types).includes(MIME)) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
event.dataTransfer.dropEffect = "move";
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
onDrop: (event: $FlowFixMe) => {
|
|
95
|
+
if (options.disabled) return;
|
|
96
|
+
const raw = event.dataTransfer.getData(MIME);
|
|
97
|
+
if (raw.length > 64000) return;
|
|
98
|
+
let incoming;
|
|
99
|
+
try {
|
|
100
|
+
incoming = JSON.parse(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
!Array.isArray(incoming) ||
|
|
106
|
+
incoming.length === 0 ||
|
|
107
|
+
!incoming.every((key) => typeof key === "string")
|
|
108
|
+
)
|
|
109
|
+
return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
drop(target, incoming, label);
|
|
112
|
+
},
|
|
113
|
+
onKeyDown: (event: $FlowFixMe) => {
|
|
114
|
+
if (keys.length === 0) return;
|
|
115
|
+
if (event.key === "Enter") {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
drop(target, keys, label);
|
|
118
|
+
} else if (event.key === "Escape") {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
cancel();
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
}
|
package/grid-list.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { CollectionRoot } from "./internal/collection.js";
|
|
5
|
+
import type { CollectionProps } from "./internal/collection.js";
|
|
6
|
+
export component GridList(...props: CollectionProps) {
|
|
7
|
+
return <CollectionRoot options={props} kind="grid" />;
|
|
8
|
+
}
|
package/i18n-provider.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import * as React from "@uniflowed/react";
|
|
5
|
+
import { createContext, useContext, useMemo } from "@uniflowed/react";
|
|
6
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
7
|
+
import { withProps } from "./internal/merge-props.js";
|
|
8
|
+
|
|
9
|
+
export type Locale = {| readonly locale: string, readonly direction: "ltr" | "rtl" |};
|
|
10
|
+
const LocaleContext: React.Context<Locale> = createContext({ locale: "en-US", direction: "ltr" });
|
|
11
|
+
|
|
12
|
+
/** An explicit locale keeps server and client output identical. Nested providers form islands. */
|
|
13
|
+
export component I18nProvider(
|
|
14
|
+
children: React.Node,
|
|
15
|
+
locale?: string,
|
|
16
|
+
direction?: "ltr" | "rtl",
|
|
17
|
+
render?: RenderProp,
|
|
18
|
+
...rest: Rest
|
|
19
|
+
) {
|
|
20
|
+
const parent = useLocale();
|
|
21
|
+
const state = useMemo((): Locale => {
|
|
22
|
+
const resolved = new Intl.Locale(locale ?? parent.locale);
|
|
23
|
+
const script = resolved.maximize().script;
|
|
24
|
+
const rtl = ["Arab", "Hebr", "Thaa", "Nkoo", "Adlm", "Rohg"].includes(script ?? "");
|
|
25
|
+
return {
|
|
26
|
+
locale: resolved.toString(),
|
|
27
|
+
direction: direction ?? (locale == null ? parent.direction : rtl ? "rtl" : "ltr"),
|
|
28
|
+
};
|
|
29
|
+
}, [locale, direction, parent]);
|
|
30
|
+
const props = withProps(rest, { children, lang: state.locale, dir: state.direction });
|
|
31
|
+
return (
|
|
32
|
+
<LocaleContext.Provider value={state}>
|
|
33
|
+
{render != null ? render(props) : <div {...props} />}
|
|
34
|
+
</LocaleContext.Provider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export hook useLocale(): Locale {
|
|
39
|
+
return useContext(LocaleContext);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Share Intl's language-specific ordering with caller-owned collections. */
|
|
43
|
+
export hook useCollator(options?: Intl$CollatorOptions): Intl$Collator {
|
|
44
|
+
const { locale } = useLocale();
|
|
45
|
+
return useMemo(() => new Intl.Collator(locale, options), [locale, options]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Bound the cache when a long-lived server serves many requested locales.
|
|
49
|
+
const searchCollators = new Map<string, Intl$Collator>();
|
|
50
|
+
function searchCollator(locale: string): Intl$Collator {
|
|
51
|
+
const cached = searchCollators.get(locale);
|
|
52
|
+
if (cached != null) return cached;
|
|
53
|
+
const collator = new Intl.Collator(locale, { usage: "search", sensitivity: "base" });
|
|
54
|
+
if (searchCollators.size >= 32) searchCollators.clear();
|
|
55
|
+
searchCollators.set(locale, collator);
|
|
56
|
+
return collator;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function startsWithLocale(text: string, query: string, locale: string): boolean {
|
|
60
|
+
const value = text.normalize("NFC");
|
|
61
|
+
const needle = query.normalize("NFC");
|
|
62
|
+
return (
|
|
63
|
+
searchCollator(locale).compare(
|
|
64
|
+
Array.from(value).slice(0, Array.from(needle).length).join(""),
|
|
65
|
+
needle,
|
|
66
|
+
) === 0
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Filtering uses the same collation as typeahead; consumers own the result list. */
|
|
71
|
+
export hook useFilter(): {
|
|
72
|
+
startsWith: (text: string, query: string) => boolean,
|
|
73
|
+
contains: (text: string, query: string) => boolean,
|
|
74
|
+
} {
|
|
75
|
+
const { locale } = useLocale();
|
|
76
|
+
return useMemo(
|
|
77
|
+
() => ({
|
|
78
|
+
startsWith: (text, query) => startsWithLocale(text, query, locale),
|
|
79
|
+
contains: (text, query) => {
|
|
80
|
+
const chars = Array.from(text.normalize("NFC"));
|
|
81
|
+
return (
|
|
82
|
+
query === "" ||
|
|
83
|
+
chars.some((_, index) => startsWithLocale(chars.slice(index).join(""), query, locale))
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
[locale],
|
|
88
|
+
);
|
|
89
|
+
}
|