@uniflowed/ui 0.0.0-alpha.9 → 0.2.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 (65) 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 +587 -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 +243 -178
  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 +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -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/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -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 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -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 +100 -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 +48 -55
  64. package/tree.js +8 -0
  65. package/visually-hidden.js +259 -0
package/date-picker.js ADDED
@@ -0,0 +1,357 @@
1
+ // @flow
2
+ //
3
+ // A field somebody types a date into, and a calendar for the times they would
4
+ // rather point at one.
5
+ //
6
+ // # Why the field comes first
7
+ //
8
+ // A date picker whose only input is the grid is slower for everybody who
9
+ // already knows the date — nine keystrokes to arrow to a day they could have
10
+ // typed in six — and it is unusable for anybody who cannot operate a grid at
11
+ // all. So the text field is the control, the calendar is the second way in, and
12
+ // the composition is written down here rather than left to each application to
13
+ // assemble differently.
14
+ //
15
+ // # It is a composition, and the parts are the ones that already exist
16
+ //
17
+ // `Popover` for the overlay and its dismissal, `Calendar` for the grid. Nothing
18
+ // about anchoring, outside presses, `Escape` or focus restoration is
19
+ // reimplemented here, which is the point: ubugeeei-prod/uf#256's complaint was
20
+ // five components each carrying a corner of the same behaviour, and a sixth
21
+ // carrying its own corner would be the same mistake with a different name.
22
+ //
23
+ // What this module does own is the three joins between them:
24
+ //
25
+ // * the field's text and the chosen date, which are not the same value and
26
+ // must not be kept in step by an effect that fights the reader's typing;
27
+ // * `Escape` and a chosen date both returning focus to the *field* rather than
28
+ // to the button, because the field is the primary control;
29
+ // * the calendar opening with focus on a date rather than on the button that
30
+ // steps back a month, which is `Popover.Body`'s `initialFocus` and
31
+ // `Calendar.Root`'s `focusedDayRef` meeting.
32
+ //
33
+ // # Parsing is `@uniflowed/temporal`'s, not this module's
34
+ //
35
+ // `format` and `parse` default to ISO 8601, because that is the format
36
+ // `Temporal.PlainDate` reads and writes and the only one this package can claim
37
+ // to handle. `12/03/26` is the third of December or the twelfth of March
38
+ // depending on where the reader is, and answering that needs the locale's date
39
+ // patterns — CLDR data, which is what `@uniflowed/temporal`'s calendar surface
40
+ // waits on the native runtime for. A UI package that shipped its own guess at it
41
+ // would be a wrong date in production rather than a missing feature, so the two
42
+ // props are the seam: an application with a locale format passes both, and gets
43
+ // its own round trip rather than this module's approximation of one.
44
+
45
+ "use client";
46
+
47
+ import * as React from "@uniflowed/react";
48
+ import { createContext, useContext, useMemo, useRef, useState } from "@uniflowed/react";
49
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
50
+ import type { PlainDate } from "@uniflowed/core/temporal";
51
+ import { Temporal } from "@uniflowed/core/temporal";
52
+
53
+ import type { DateValue } from "./calendar.js";
54
+ import { CalendarRoot } from "./calendar.js";
55
+ import { useControlled } from "./internal/controlled-state.js";
56
+ import type { Align, LogicalSide } from "./internal/anchor.js";
57
+ import type { Rest } from "./internal/merge-props.js";
58
+ import {
59
+ composeHandlers,
60
+ composeRefs,
61
+ forwarded,
62
+ withoutComposed,
63
+ } from "./internal/merge-props.js";
64
+ import { PopoverBody, PopoverRoot, PopoverTrigger } from "./popover.js";
65
+
66
+ type DatePickerState = {|
67
+ /** The text in the field, which is the draft while one is being typed. */
68
+ readonly text: string,
69
+ readonly setDraft: (text: string | null) => void,
70
+ /** Whether the last thing typed could not be read as a date. */
71
+ readonly invalid: boolean,
72
+ readonly commit: (text: string) => void,
73
+ readonly choose: (date: PlainDate) => void,
74
+ readonly value: PlainDate | null,
75
+ readonly fieldRef: { current: HTMLElement | null },
76
+ readonly focusField: () => void,
77
+ |};
78
+
79
+ const DatePickerContext: React.Context<DatePickerState | null> = createContext(null);
80
+
81
+ hook useDatePicker(part: string): DatePickerState {
82
+ const state = useContext(DatePickerContext);
83
+ if (state == null) {
84
+ throw new Error(`${part} must be rendered inside a DatePicker.Root`);
85
+ }
86
+ return state;
87
+ }
88
+
89
+ /** ISO 8601, which is what `PlainDate.toString` produces and `from` parses. */
90
+ function isoFormat(date: PlainDate): string {
91
+ return date.toString();
92
+ }
93
+
94
+ /**
95
+ * ISO 8601 or nothing.
96
+ *
97
+ * `PlainDate.from` throws a `RangeError` on anything it cannot read, and a
98
+ * reader half way through typing a date is in that state on almost every
99
+ * keystroke — so the failure is a value here rather than an exception, and the
100
+ * field decides what to do about it.
101
+ */
102
+ function isoParse(text: string): PlainDate | null {
103
+ const trimmed = text.trim();
104
+ if (trimmed === "") {
105
+ return null;
106
+ }
107
+ try {
108
+ return Temporal.PlainDate.from(trimmed);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The value, the popover around the calendar, and the joins between them.
116
+ *
117
+ * Renders no element of its own: the field, the button and the calendar are
118
+ * siblings in whatever layout the caller wrote, and a wrapper would put a
119
+ * `<div>` between them that they then have to style around.
120
+ */
121
+ export component DatePickerRoot(
122
+ children: React.Node,
123
+ defaultOpen?: boolean = false,
124
+ defaultValue?: DateValue | null = null,
125
+ /** How a chosen date is written into the field. ISO 8601 unless told otherwise. */
126
+ format?: (date: PlainDate) => string = isoFormat,
127
+ isDateDisabled?: (date: PlainDate) => boolean,
128
+ locale?: string,
129
+ onOpenChange?: (open: boolean) => void,
130
+ onValueChange?: (value: PlainDate | null) => mixed,
131
+ open?: boolean,
132
+ /** How typed text becomes a date, or null when it is not one yet. */
133
+ parse?: (text: string) => PlainDate | null = isoParse,
134
+ today?: DateValue,
135
+ value?: DateValue | null,
136
+ weekStartsOn?: number,
137
+ ) {
138
+ const fieldRef = useRef<HTMLElement | null>(null);
139
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
140
+
141
+ const controlled = useMemo(
142
+ () =>
143
+ value === undefined ? undefined : value === null ? null : Temporal.PlainDate.from(value),
144
+ [value],
145
+ );
146
+ const initial = useMemo(
147
+ () => (defaultValue == null ? null : Temporal.PlainDate.from(defaultValue)),
148
+ [defaultValue],
149
+ );
150
+ const report = useStableCallback((next: PlainDate | null) => {
151
+ onValueChange?.(next);
152
+ });
153
+ const [chosen, setChosen] = useControlled<PlainDate | null>(controlled, initial, report);
154
+
155
+ // The field's text is the *draft* while there is one, and the formatted value
156
+ // otherwise. Two pieces of state kept in step by an effect is the arrangement
157
+ // this avoids: an effect that copies the value into the field overwrites what
158
+ // the reader is halfway through typing, and one that does not runs stale the
159
+ // moment the caller sets a value from outside.
160
+ const [draft, setDraft] = useState<string | null>(null);
161
+ const [invalid, setInvalid] = useState(false);
162
+ const text = draft ?? (chosen == null ? "" : format(chosen));
163
+
164
+ const focusField = useStableCallback(() => {
165
+ fieldRef.current?.focus?.();
166
+ });
167
+
168
+ const commit = useStableCallback((typed: string) => {
169
+ if (typed.trim() === "") {
170
+ setChosen(null);
171
+ setDraft(null);
172
+ setInvalid(false);
173
+ return;
174
+ }
175
+ const parsed = parse(typed);
176
+ if (parsed == null) {
177
+ // The text stays. Clearing it would throw away what the reader typed and
178
+ // leave them nothing to correct.
179
+ setInvalid(true);
180
+ return;
181
+ }
182
+ setChosen(parsed);
183
+ setDraft(null);
184
+ setInvalid(false);
185
+ });
186
+
187
+ const choose = useStableCallback((date: PlainDate) => {
188
+ setChosen(date);
189
+ setDraft(null);
190
+ setInvalid(false);
191
+ // Before the popover closes, and that order is load-bearing: `Popover.Body`
192
+ // restores focus to its trigger only when focus would otherwise be lost, so
193
+ // moving it to the field first is what makes the field - and not the button
194
+ // - where the reader ends up.
195
+ focusField();
196
+ setOpen(false);
197
+ });
198
+
199
+ const dateSettings = useMemo(
200
+ () => ({ isDateDisabled, locale, today, weekStartsOn }),
201
+ [isDateDisabled, locale, today, weekStartsOn],
202
+ );
203
+
204
+ const state = useMemo(
205
+ () => ({
206
+ choose,
207
+ commit,
208
+ fieldRef,
209
+ focusField,
210
+ invalid,
211
+ setDraft,
212
+ text,
213
+ value: chosen,
214
+ }),
215
+ [choose, chosen, commit, focusField, invalid, text],
216
+ );
217
+
218
+ return (
219
+ <DatePickerContext.Provider value={state}>
220
+ <CalendarSettings.Provider value={dateSettings}>
221
+ <PopoverRoot onOpenChange={setOpen} open={isOpen}>
222
+ {children}
223
+ </PopoverRoot>
224
+ </CalendarSettings.Provider>
225
+ </DatePickerContext.Provider>
226
+ );
227
+ }
228
+
229
+ /** What `DatePicker.Root` was told about dates, for the calendar it renders. */
230
+ type CalendarSettingsValue = {|
231
+ readonly isDateDisabled: ((date: PlainDate) => boolean) | void,
232
+ readonly locale: string | void,
233
+ readonly today: DateValue | void,
234
+ readonly weekStartsOn: number | void,
235
+ |};
236
+
237
+ const CalendarSettings: React.Context<CalendarSettingsValue> = createContext({
238
+ isDateDisabled: undefined,
239
+ locale: undefined,
240
+ today: undefined,
241
+ weekStartsOn: undefined,
242
+ });
243
+
244
+ /**
245
+ * The text field, which is the control.
246
+ *
247
+ * An ordinary `<input type="text">` rather than `type="date"`: the native one is
248
+ * a different widget with its own popup, its own format and no way to be told
249
+ * which dates are unavailable, and wrapping it would leave two calendars in one
250
+ * control. It carries no `role`, no `aria-haspopup` and no `aria-expanded` — it
251
+ * does not open the popover, the button beside it does, and telling a reader the
252
+ * field expands something would be a promise the field does not keep.
253
+ */
254
+ export component DatePickerInput(...rest: Rest) {
255
+ const picker = useDatePicker("DatePicker.Input");
256
+ // `rest` filtering is render-time props work; ref objects are only passed through later.
257
+ // uf-lint-disable-next-line react-compiler/refs
258
+ const passed = withoutComposed(rest, ["onBlur", "onChange", "onKeyDown", "ref"]);
259
+
260
+ return (
261
+ <input
262
+ {...passed}
263
+ aria-invalid={picker.invalid ? "true" : undefined}
264
+ // Input events commit typed text while focus stays on the field ref.
265
+ // uf-lint-disable-next-line react-compiler/refs
266
+ onBlur={composeHandlers(rest.onBlur, (event) => {
267
+ picker.commit((event.currentTarget: $FlowFixMe).value);
268
+ })}
269
+ // Input events commit typed text while focus stays on the field ref.
270
+ // uf-lint-disable-next-line react-compiler/refs
271
+ onChange={composeHandlers(rest.onChange, (event) => {
272
+ picker.setDraft((event.currentTarget: $FlowFixMe).value);
273
+ })}
274
+ // Key events commit typed text while focus stays on the field ref.
275
+ // uf-lint-disable-next-line react-compiler/refs
276
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
277
+ if (event.key !== "Enter") {
278
+ return;
279
+ }
280
+ // Claimed, so a date picker inside a form is not a control where
281
+ // pressing Enter to confirm what you typed submits the page instead.
282
+ event.preventDefault();
283
+ picker.commit((event.currentTarget: $FlowFixMe).value);
284
+ })}
285
+ // React calls callback refs during commit; the calendar and picker read the field later.
286
+ // uf-lint-disable-next-line react-compiler/refs
287
+ ref={composeRefs(rest.ref, (element) => {
288
+ // uf-lint-disable-next-line react-compiler/immutability
289
+ picker.fieldRef.current = element;
290
+ })}
291
+ type="text"
292
+ value={picker.text}
293
+ />
294
+ );
295
+ }
296
+
297
+ /** The button that opens the calendar. */
298
+ export component DatePickerTrigger(children: React.Node, ...rest: Rest) {
299
+ // `forwarded`, because this part renders another part rather than an
300
+ // intrinsic; `internal/merge-props.js` says what that costs and why.
301
+ return <PopoverTrigger {...forwarded(rest)}>{children}</PopoverTrigger>;
302
+ }
303
+
304
+ /**
305
+ * The calendar, in the popover, wired to the field.
306
+ *
307
+ * `children` is the calendar's own layout — the month buttons and
308
+ * `Calendar.Month` — because where those sit is a design decision and there is
309
+ * no arrangement of them this module could impose that would suit every one.
310
+ */
311
+ export component DatePickerCalendar(
312
+ children: React.Node,
313
+ align?: Align = "start",
314
+ side?: LogicalSide = "bottom",
315
+ sideOffset?: number = 0,
316
+ ...rest: Rest
317
+ ) {
318
+ const picker = useDatePicker("DatePicker.Calendar");
319
+ const settings = useContext(CalendarSettings);
320
+ const dayRef = useRef<HTMLElement | null>(null);
321
+ const passed = withoutComposed(rest, ["onKeyDown"]);
322
+
323
+ return (
324
+ <PopoverBody
325
+ {...forwarded(passed)}
326
+ align={align}
327
+ // The date, not the first button in the popover. The APG's date picker
328
+ // dialog puts focus on the grid for the same reason.
329
+ initialFocus={dayRef}
330
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
331
+ if (event.key !== "Escape") {
332
+ return;
333
+ }
334
+ // Not prevented and not stopped: `Popover.Body`'s own handler is what
335
+ // closes it, and this only decides where focus lands afterwards. Moving
336
+ // it to the field first is what makes the popover's restore stand down;
337
+ // `choose` says why that is the order.
338
+ picker.focusField();
339
+ })}
340
+ side={side}
341
+ sideOffset={sideOffset}
342
+ >
343
+ <CalendarRoot
344
+ defaultFocused={picker.value ?? undefined}
345
+ focusedDayRef={dayRef}
346
+ isDateDisabled={settings.isDateDisabled}
347
+ locale={settings.locale}
348
+ onValueChange={picker.choose}
349
+ today={settings.today}
350
+ value={picker.value}
351
+ weekStartsOn={settings.weekStartsOn}
352
+ >
353
+ {children}
354
+ </CalendarRoot>
355
+ </PopoverBody>
356
+ );
357
+ }
@@ -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
+ }