@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.
Files changed (64) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +560 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +235 -198
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +334 -0
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1254 -32
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +565 -0
  27. package/internal/collection.js +395 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/focus.js +64 -0
  32. package/internal/hover-intent.js +259 -0
  33. package/internal/menu-tree.js +228 -0
  34. package/internal/merge-props.js +117 -1
  35. package/internal/roving-focus.js +15 -4
  36. package/internal/segmented-field.js +316 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -25
  42. package/pagination.js +34 -22
  43. package/popover.js +367 -0
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +78 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +112 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +404 -0
  64. 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";