@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,316 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { useMemo, useState } from "@uniflowed/react";
|
|
5
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
6
|
+
import { Temporal } from "@uniflowed/core/temporal";
|
|
7
|
+
import { useControlled } from "./controlled-state.js";
|
|
8
|
+
import { composeHandlers, withProps } from "./merge-props.js";
|
|
9
|
+
import type { RenderProp, Rest } from "./merge-props.js";
|
|
10
|
+
import { directionOf } from "./roving-focus.js";
|
|
11
|
+
import { useLocale } from "../i18n-provider.js";
|
|
12
|
+
|
|
13
|
+
type Segment = "year" | "month" | "day" | "hour" | "minute" | "second" | "dayPeriod";
|
|
14
|
+
type Fields = { [string]: string };
|
|
15
|
+
export type DateFieldProps = {
|
|
16
|
+
value?: string | null,
|
|
17
|
+
defaultValue?: string | null,
|
|
18
|
+
onValueChange?: (value: string | null) => void,
|
|
19
|
+
minValue?: string,
|
|
20
|
+
isDateUnavailable?: (value: string) => boolean,
|
|
21
|
+
maxValue?: string,
|
|
22
|
+
disabled?: boolean,
|
|
23
|
+
readOnly?: boolean,
|
|
24
|
+
required?: boolean,
|
|
25
|
+
granularity?: "minute" | "second",
|
|
26
|
+
hourCycle?: "h12" | "h23",
|
|
27
|
+
segmentLabels?: { [string]: string },
|
|
28
|
+
onValidationChange?: (invalid: boolean) => void,
|
|
29
|
+
render?: RenderProp,
|
|
30
|
+
readonly key?: empty,
|
|
31
|
+
readonly [string]: mixed,
|
|
32
|
+
};
|
|
33
|
+
function names(time: boolean, seconds: boolean): Array<Segment> {
|
|
34
|
+
return time
|
|
35
|
+
? seconds
|
|
36
|
+
? ["hour", "minute", "second"]
|
|
37
|
+
: ["hour", "minute"]
|
|
38
|
+
: ["year", "month", "day"];
|
|
39
|
+
}
|
|
40
|
+
function fieldsFor(value: string | null, time: boolean, seconds: boolean): Fields {
|
|
41
|
+
if (value == null) return {};
|
|
42
|
+
const parsed = time ? Temporal.PlainTime.from(value) : Temporal.PlainDate.from(value);
|
|
43
|
+
const fields: Fields = {};
|
|
44
|
+
for (const name of names(time, seconds)) fields[name] = String((parsed as $FlowFixMe)[name]);
|
|
45
|
+
return fields;
|
|
46
|
+
}
|
|
47
|
+
function serialize(fields: Fields, time: boolean, seconds: boolean): string | null {
|
|
48
|
+
const parts = names(time, seconds);
|
|
49
|
+
if (parts.some((part) => !/^\d+$/.test(fields[part] ?? ""))) return null;
|
|
50
|
+
try {
|
|
51
|
+
if (time) {
|
|
52
|
+
const value = `${fields.hour.padStart(2, "0")}:${fields.minute.padStart(2, "0")}:${seconds ? fields.second.padStart(2, "0") : "00"}`;
|
|
53
|
+
const parsed = Temporal.PlainTime.from(value);
|
|
54
|
+
return parsed.toString().slice(0, seconds ? 8 : 5);
|
|
55
|
+
}
|
|
56
|
+
const year = Number(fields.year),
|
|
57
|
+
month = Number(fields.month),
|
|
58
|
+
day = Number(fields.day);
|
|
59
|
+
if (year < 1 || year > 9999) return null;
|
|
60
|
+
const parsed = Temporal.PlainDate.from({ year, month, day });
|
|
61
|
+
if (parsed.year !== year || parsed.month !== month || parsed.day !== day) return null;
|
|
62
|
+
return parsed.toString();
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function maximum(name: Segment, fields: Fields): number {
|
|
68
|
+
if (name === "year") return 9999;
|
|
69
|
+
if (name === "month") return 12;
|
|
70
|
+
if (name === "hour") return 23;
|
|
71
|
+
if (name !== "day") return 59;
|
|
72
|
+
try {
|
|
73
|
+
return Temporal.PlainDate.from({
|
|
74
|
+
year: Number(fields.year) || 2000,
|
|
75
|
+
month: Number(fields.month) || 1,
|
|
76
|
+
day: 1,
|
|
77
|
+
}).daysInMonth;
|
|
78
|
+
} catch {
|
|
79
|
+
return 31;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Segment order, digits and labels come from the locale; the stored value is ISO. */
|
|
84
|
+
export component SegmentedField(time: boolean, options: DateFieldProps) {
|
|
85
|
+
const {
|
|
86
|
+
value,
|
|
87
|
+
defaultValue = null,
|
|
88
|
+
onValueChange,
|
|
89
|
+
minValue,
|
|
90
|
+
isDateUnavailable,
|
|
91
|
+
maxValue,
|
|
92
|
+
disabled = false,
|
|
93
|
+
readOnly = false,
|
|
94
|
+
required = false,
|
|
95
|
+
granularity = "minute",
|
|
96
|
+
hourCycle,
|
|
97
|
+
segmentLabels,
|
|
98
|
+
onValidationChange,
|
|
99
|
+
render,
|
|
100
|
+
...rest
|
|
101
|
+
} = options;
|
|
102
|
+
const { locale } = useLocale();
|
|
103
|
+
const seconds = granularity === "second";
|
|
104
|
+
const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
|
|
105
|
+
const [draft, setDraft] = useState<Fields | null>(null);
|
|
106
|
+
const [announcement, announce] = useState("");
|
|
107
|
+
const fields = draft ?? fieldsFor(current, time, seconds);
|
|
108
|
+
const empty = names(time, seconds).every((part) => !fields[part]);
|
|
109
|
+
const serialized = serialize(fields, time, seconds);
|
|
110
|
+
const minimum =
|
|
111
|
+
minValue == null ? null : serialize(fieldsFor(minValue, time, seconds), time, seconds);
|
|
112
|
+
const maximumValue =
|
|
113
|
+
maxValue == null ? null : serialize(fieldsFor(maxValue, time, seconds), time, seconds);
|
|
114
|
+
if (minimum != null && maximumValue != null && minimum > maximumValue)
|
|
115
|
+
throw new RangeError("DateField minimum exceeds maximum");
|
|
116
|
+
const invalid =
|
|
117
|
+
(empty ? required : serialized == null) ||
|
|
118
|
+
(serialized != null &&
|
|
119
|
+
((minimum != null && serialized < minimum) ||
|
|
120
|
+
(maximumValue != null && serialized > maximumValue) ||
|
|
121
|
+
isDateUnavailable?.(serialized) === true));
|
|
122
|
+
const digits = useMemo(() => new Intl.NumberFormat(locale, { useGrouping: false }), [locale]);
|
|
123
|
+
const labels: $FlowFixMe = useMemo(
|
|
124
|
+
() => new (Intl as $FlowFixMe).DisplayNames(locale, { type: "dateTimeField" }),
|
|
125
|
+
[locale],
|
|
126
|
+
);
|
|
127
|
+
const parts: Array<{ type: string, value: string }> = useMemo(() => {
|
|
128
|
+
const formatter: $FlowFixMe = new Intl.DateTimeFormat(
|
|
129
|
+
locale,
|
|
130
|
+
time
|
|
131
|
+
? {
|
|
132
|
+
hour: "2-digit",
|
|
133
|
+
minute: "2-digit",
|
|
134
|
+
second: seconds ? "2-digit" : undefined,
|
|
135
|
+
hourCycle,
|
|
136
|
+
timeZone: "UTC",
|
|
137
|
+
}
|
|
138
|
+
: {
|
|
139
|
+
year: "numeric",
|
|
140
|
+
month: "2-digit",
|
|
141
|
+
day: "2-digit",
|
|
142
|
+
calendar: "gregory",
|
|
143
|
+
timeZone: "UTC",
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
return formatter.formatToParts(new Date("2000-01-02T12:34:56Z"));
|
|
147
|
+
}, [locale, time, seconds, hourCycle]);
|
|
148
|
+
const hour12 = time && parts.some((part) => part.type === "dayPeriod");
|
|
149
|
+
const periodNames: Array<string> = useMemo(() => {
|
|
150
|
+
const formatter: $FlowFixMe = new Intl.DateTimeFormat(locale, {
|
|
151
|
+
hour: "numeric",
|
|
152
|
+
hour12: true,
|
|
153
|
+
timeZone: "UTC",
|
|
154
|
+
});
|
|
155
|
+
return [0, 12].map(
|
|
156
|
+
(hour) =>
|
|
157
|
+
formatter
|
|
158
|
+
.formatToParts(new Date(Date.UTC(2000, 0, 1, hour)))
|
|
159
|
+
.find((part) => part.type === "dayPeriod")?.value ?? (hour === 0 ? "AM" : "PM"),
|
|
160
|
+
);
|
|
161
|
+
}, [locale]);
|
|
162
|
+
const encode = (text: string): string =>
|
|
163
|
+
Array.from(text)
|
|
164
|
+
.map((digit) => digits.format(Number(digit)))
|
|
165
|
+
.join("");
|
|
166
|
+
const decode = (text: string): string => {
|
|
167
|
+
let result = text;
|
|
168
|
+
for (let digit = 0; digit < 10; digit += 1)
|
|
169
|
+
result = result.split(digits.format(digit)).join(String(digit));
|
|
170
|
+
return result.replace(/[^0-9]/g, "");
|
|
171
|
+
};
|
|
172
|
+
const commit = useStableCallback(() => {
|
|
173
|
+
if (disabled || readOnly) return;
|
|
174
|
+
onValidationChange?.(invalid);
|
|
175
|
+
if (invalid) {
|
|
176
|
+
announce("Enter a valid value within the allowed range");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setCurrent(empty ? null : serialized);
|
|
180
|
+
setDraft(null);
|
|
181
|
+
announce(serialized ?? "Cleared");
|
|
182
|
+
});
|
|
183
|
+
const edit = useStableCallback((part: Segment, text: string) => {
|
|
184
|
+
if (!disabled && !readOnly) setDraft({ ...fields, [part]: text });
|
|
185
|
+
});
|
|
186
|
+
const keyboard = useStableCallback((event: $FlowFixMe, part: Segment) => {
|
|
187
|
+
if (disabled || readOnly) return;
|
|
188
|
+
const key = event.key;
|
|
189
|
+
if (key === "Enter") {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
commit();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (key === "ArrowLeft" || key === "ArrowRight") {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
const root = event.currentTarget.closest("[data-segmented]");
|
|
197
|
+
const segments = Array.from(root.querySelectorAll("[data-segment]"));
|
|
198
|
+
const at = segments.indexOf(event.currentTarget);
|
|
199
|
+
const forward =
|
|
200
|
+
key === (directionOf(event.currentTarget) === "rtl" ? "ArrowLeft" : "ArrowRight");
|
|
201
|
+
(segments[at + (forward ? 1 : -1)] as $FlowFixMe)?.focus();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(key)) return;
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
const min = time && !(part === "hour" && hour12) ? 0 : 1,
|
|
207
|
+
max = part === "hour" && hour12 ? 12 : maximum(part, fields);
|
|
208
|
+
const raw = Number(fields[part] || min);
|
|
209
|
+
const current = part === "hour" && hour12 ? ((raw + 11) % 12) + 1 : raw;
|
|
210
|
+
const next =
|
|
211
|
+
key === "Home"
|
|
212
|
+
? min
|
|
213
|
+
: key === "End"
|
|
214
|
+
? max
|
|
215
|
+
: current +
|
|
216
|
+
(key === "ArrowUp" ? 1 : key === "ArrowDown" ? -1 : key === "PageUp" ? 10 : -10);
|
|
217
|
+
const bounded = next > max ? min : next < min ? max : next;
|
|
218
|
+
const adjusted =
|
|
219
|
+
part === "hour" && hour12 ? (bounded % 12) + (Number(fields.hour) >= 12 ? 12 : 0) : bounded;
|
|
220
|
+
const nextFields = { ...fields, [part]: String(adjusted) };
|
|
221
|
+
if (!time && (part === "month" || part === "year") && nextFields.day)
|
|
222
|
+
nextFields.day = String(Math.min(Number(nextFields.day), maximum("day", nextFields)));
|
|
223
|
+
setDraft(nextFields);
|
|
224
|
+
});
|
|
225
|
+
const segments = parts.map((part, index) => {
|
|
226
|
+
if (part.type === "dayPeriod") {
|
|
227
|
+
const pm = Number(fields.hour) >= 12;
|
|
228
|
+
const toggle = () => edit("hour", String((Number(fields.hour) || 0) + (pm ? -12 : 12)));
|
|
229
|
+
return (
|
|
230
|
+
<span
|
|
231
|
+
key="period"
|
|
232
|
+
role="spinbutton"
|
|
233
|
+
tabIndex={disabled ? -1 : 0}
|
|
234
|
+
data-segment="dayPeriod"
|
|
235
|
+
aria-label={segmentLabels?.dayPeriod ?? labels.of("dayPeriod")}
|
|
236
|
+
aria-valuemin={0}
|
|
237
|
+
aria-valuemax={1}
|
|
238
|
+
aria-valuenow={pm ? 1 : 0}
|
|
239
|
+
aria-valuetext={periodNames[pm ? 1 : 0]}
|
|
240
|
+
aria-disabled={disabled || undefined}
|
|
241
|
+
aria-readonly={readOnly || undefined}
|
|
242
|
+
onClick={toggle}
|
|
243
|
+
onKeyDown={(event) => {
|
|
244
|
+
if (disabled || readOnly) return;
|
|
245
|
+
if (["ArrowUp", "ArrowDown", "Home", "End", " "].includes(event.key)) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
if ((event.key !== "Home" || pm) && (event.key !== "End" || !pm)) toggle();
|
|
248
|
+
} else if (["ArrowLeft", "ArrowRight", "Enter"].includes(event.key))
|
|
249
|
+
keyboard(event, "dayPeriod");
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
{periodNames[pm ? 1 : 0]}
|
|
253
|
+
</span>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!names(time, seconds).includes(part.type as $FlowFixMe))
|
|
258
|
+
return (
|
|
259
|
+
<span key={index} aria-hidden="true">
|
|
260
|
+
{part.value}
|
|
261
|
+
</span>
|
|
262
|
+
);
|
|
263
|
+
const name: Segment = part.type as $FlowFixMe;
|
|
264
|
+
const raw = fields[name] ?? "";
|
|
265
|
+
const text =
|
|
266
|
+
name === "hour" && hour12 && raw !== "" ? String(((Number(raw) + 11) % 12) + 1) : raw;
|
|
267
|
+
return (
|
|
268
|
+
<input
|
|
269
|
+
key={name}
|
|
270
|
+
type="text"
|
|
271
|
+
role="spinbutton"
|
|
272
|
+
inputMode="numeric"
|
|
273
|
+
data-segment={name}
|
|
274
|
+
aria-label={segmentLabels?.[name] ?? labels.of(name)}
|
|
275
|
+
aria-valuemin={time && !(name === "hour" && hour12) ? 0 : 1}
|
|
276
|
+
aria-valuemax={name === "hour" && hour12 ? 12 : maximum(name, fields)}
|
|
277
|
+
aria-valuenow={text ? Number(text) : undefined}
|
|
278
|
+
aria-valuetext={text ? encode(text) : undefined}
|
|
279
|
+
aria-invalid={invalid || undefined}
|
|
280
|
+
disabled={disabled}
|
|
281
|
+
readOnly={readOnly}
|
|
282
|
+
required={required}
|
|
283
|
+
maxLength={name === "year" ? 4 : 2}
|
|
284
|
+
placeholder={name === "year" ? "yyyy" : "--"}
|
|
285
|
+
value={text ? encode(text) : ""}
|
|
286
|
+
onChange={(event) => {
|
|
287
|
+
const text = decode(event.currentTarget.value);
|
|
288
|
+
edit(
|
|
289
|
+
name,
|
|
290
|
+
name === "hour" && hour12 && text && Number(text) <= 12
|
|
291
|
+
? String((Number(text) % 12) + (Number(fields.hour) >= 12 ? 12 : 0))
|
|
292
|
+
: text,
|
|
293
|
+
);
|
|
294
|
+
}}
|
|
295
|
+
onKeyDown={(event) => keyboard(event, name)}
|
|
296
|
+
/>
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
const props = withProps(rest, {
|
|
300
|
+
role: "group",
|
|
301
|
+
"data-segmented": time ? "time" : "date",
|
|
302
|
+
"aria-invalid": invalid || rest["aria-invalid"],
|
|
303
|
+
onBlur: composeHandlers(rest.onBlur, (event: $FlowFixMe) => {
|
|
304
|
+
if (!event.currentTarget.contains(event.relatedTarget)) commit();
|
|
305
|
+
}),
|
|
306
|
+
children: (
|
|
307
|
+
<>
|
|
308
|
+
{segments}
|
|
309
|
+
<span role="status" aria-live="polite">
|
|
310
|
+
{announcement}
|
|
311
|
+
</span>
|
|
312
|
+
</>
|
|
313
|
+
),
|
|
314
|
+
});
|
|
315
|
+
return render != null ? render(props) : <div {...props} />;
|
|
316
|
+
}
|
package/list-box.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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 ListBox(...props: CollectionProps) {
|
|
7
|
+
return <CollectionRoot options={props} kind="listbox" />;
|
|
8
|
+
}
|
|
9
|
+
export type {
|
|
10
|
+
CollectionItem,
|
|
11
|
+
CollectionItemState,
|
|
12
|
+
CollectionProps,
|
|
13
|
+
} from "./internal/collection.js";
|