@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
package/hover-card.js ADDED
@@ -0,0 +1,334 @@
1
+ // @flow
2
+ //
3
+ // A hover card: the preview a name expands into, taken seriously.
4
+ //
5
+ // It is a tooltip's sibling and it is not a tooltip, and the difference is what
6
+ // is inside. A tooltip holds a phrase and must never be focusable; a hover card
7
+ // holds an avatar, a paragraph and two links, and every one of those has to be
8
+ // reachable — so the pointer has to be able to get there and so does `Tab`.
9
+ //
10
+ // That makes SC 1.4.13's hoverable clause the whole component rather than a
11
+ // detail of it: the gap between a name and the card that describes it takes a
12
+ // moment to cross with a mouse, longer with a trackpad, and much longer for a
13
+ // reader magnifying the screen. Closing on `pointerleave` snatches it away
14
+ // mid-journey. `internal/hover-intent.js` is the delay that stops that, shared
15
+ // with `tooltip.js` so the two cannot drift.
16
+ //
17
+ // # What it is not
18
+ //
19
+ // **Not a dialog.** It carries no `role="dialog"` and no `aria-modal`: nothing
20
+ // about it is modal, focus is not moved into it when it opens, and announcing a
21
+ // dialog a reader never asked for is worse than announcing nothing. Its content
22
+ // is in the document immediately after its trigger, so the reading order
23
+ // carries it and `Tab` reaches it — which is the whole of what a keyboard
24
+ // reader needs from it.
25
+ //
26
+ // **Not described by `aria-describedby`.** A card of links flattened into one
27
+ // description string is a sentence nobody can act on: the links stop being
28
+ // links. `tooltip.js` describes its trigger because a tooltip is a phrase; this
29
+ // does not, because this is not.
30
+ //
31
+ // # Touch
32
+ //
33
+ // It does not open on touch, for the reason `tooltip.js` gives at more length:
34
+ // there is no hover on a touch screen, and the only gesture left is the tap the
35
+ // trigger itself needs. A hover card is therefore an *enrichment* — the link
36
+ // under it must go somewhere useful on its own, because a reader on a phone
37
+ // will only ever get the link.
38
+
39
+ "use client";
40
+
41
+ import * as React from "@uniflowed/react";
42
+ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
43
+ import { useEventListener } from "@uniflowed/hooks/dom";
44
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
45
+
46
+ import type { Align, LogicalSide } from "./internal/anchor.js";
47
+ import type { HoverIntent } from "./internal/hover-intent.js";
48
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
49
+ import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
50
+ import {
51
+ DEFAULT_CLOSE_DELAY,
52
+ DEFAULT_OPEN_DELAY,
53
+ useDismissOnEscape,
54
+ useFocusableTrigger,
55
+ useHoverIntent,
56
+ } from "./internal/hover-intent.js";
57
+ import { useAnchor } from "./internal/anchor.js";
58
+ import { useControlled } from "./internal/controlled-state.js";
59
+
60
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
61
+
62
+ type HoverCardState = {|
63
+ readonly base: string,
64
+ readonly open: boolean,
65
+ readonly setOpen: (open: boolean) => void,
66
+ readonly triggerRef: { current: HTMLElement | null },
67
+ readonly intent: HoverIntent,
68
+ readonly openDelay: number,
69
+ readonly closeDelay: number,
70
+ /** Whether `Escape` has dismissed it; see `tooltip.js`, which shares the rule. */
71
+ readonly dismissedRef: { current: boolean },
72
+ |};
73
+
74
+ const HoverCardContext: React.Context<HoverCardState | null> = createContext(null);
75
+
76
+ hook useHoverCard(part: string): HoverCardState {
77
+ const state = useContext(HoverCardContext);
78
+ if (state == null) {
79
+ throw new Error(`${part} must be rendered inside a HoverCard.Root`);
80
+ }
81
+ return state;
82
+ }
83
+
84
+ /**
85
+ * A hover card and the thing it previews.
86
+ *
87
+ * `closeDelay` is longer than a tooltip's would need to be on purpose: it is
88
+ * the time the reader has to reach the card, and a card holding links is a card
89
+ * they are reaching for.
90
+ */
91
+ export component HoverCardRoot(
92
+ children: React.Node,
93
+ closeDelay?: number = DEFAULT_CLOSE_DELAY,
94
+ defaultOpen?: boolean = false,
95
+ onOpenChange?: (open: boolean) => void,
96
+ open?: boolean,
97
+ openDelay?: number = DEFAULT_OPEN_DELAY,
98
+ ) {
99
+ const base = useId();
100
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
101
+ const triggerRef = useRef<HTMLElement | null>(null);
102
+ const dismissedRef = useRef(false);
103
+ const intent = useHoverIntent(setOpen);
104
+
105
+ const state = useMemo(
106
+ () => ({
107
+ base,
108
+ closeDelay,
109
+ dismissedRef,
110
+ intent,
111
+ open: isOpen,
112
+ openDelay,
113
+ setOpen,
114
+ triggerRef,
115
+ }),
116
+ [base, closeDelay, intent, isOpen, openDelay, setOpen],
117
+ );
118
+
119
+ return <HoverCardContext.Provider value={state}>{children}</HoverCardContext.Provider>;
120
+ }
121
+
122
+ /**
123
+ * What the card is about, which is usually a link.
124
+ *
125
+ * `render` is how it becomes one: `<HoverCard.Trigger render={(props) => <a
126
+ * href={profile} {...props}>@ada</a>} />`. Whatever it ends up as has to be
127
+ * reachable by keyboard, and `useFocusableTrigger` refuses anything else —
128
+ * a hover card on a `<span>` is one a keyboard reader can never see.
129
+ */
130
+ export component HoverCardTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
131
+ const card = useHoverCard("HoverCard.Trigger");
132
+ const { closeDelay, dismissedRef, intent, openDelay, triggerRef } = card;
133
+ useFocusableTrigger(triggerRef, "HoverCard.Trigger");
134
+
135
+ // A press focuses the trigger, and a card opening under the reader's own
136
+ // click would cover what they just went to. See `tooltip.js`.
137
+ const pressedRef = useRef(false);
138
+
139
+ useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
140
+ if (event.pointerType === "touch" || dismissedRef.current) {
141
+ return;
142
+ }
143
+ intent.openAfter(openDelay);
144
+ });
145
+ useEventListener(triggerRef, "pointerleave", () => {
146
+ dismissedRef.current = false;
147
+ intent.closeAfter(closeDelay);
148
+ });
149
+ useEventListener(triggerRef, "pointerdown", () => {
150
+ pressedRef.current = true;
151
+ intent.cancel();
152
+ });
153
+ useEventListener(triggerRef, "focusin", () => {
154
+ if (pressedRef.current) {
155
+ pressedRef.current = false;
156
+ return;
157
+ }
158
+ // The focus a dismissed card hands *back* to this trigger must not reopen
159
+ // it, which is the whole reason the flag exists: without it `Escape` closes
160
+ // the card, focus returns here, and the card comes straight back.
161
+ if (dismissedRef.current) {
162
+ return;
163
+ }
164
+ // A reader who tabbed here has said what they want; only the pointer is
165
+ // guessed at, so only the pointer waits.
166
+ intent.openAfter(0);
167
+ });
168
+ useEventListener(triggerRef, "focusout", () => {
169
+ pressedRef.current = false;
170
+ dismissedRef.current = false;
171
+ // `closeAfter` rather than a close, and this is where the delay earns its
172
+ // keep a second time: `Tab` from the trigger *into* the card is a leave
173
+ // followed immediately by an arrival, and the card's own `focusin` calls
174
+ // this off before it runs. A `closeDelay` of nought would close the card
175
+ // in the instant the reader reached it, which is why the default is not
176
+ // nought and why a caller who sets one should not set that.
177
+ intent.closeAfter(closeDelay);
178
+ });
179
+
180
+ // Annotated because this one is not written inside a `ref={...}`, and there
181
+ // is nothing else here for Flow to infer the element's type from.
182
+ // React calls callback refs during commit; pointer and focus handlers read it later.
183
+ // uf-lint-disable-next-line react-compiler/refs
184
+ const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
185
+ triggerRef.current = element;
186
+ });
187
+
188
+ const props = withProps(withoutComposed(rest, ["ref"]), { children, ref: attach });
189
+
190
+ if (render != null) {
191
+ return render(props);
192
+ }
193
+ return <button {...props} type="button" />;
194
+ }
195
+
196
+ /**
197
+ * The card.
198
+ *
199
+ * Stays while the pointer is over it and while focus is inside it, which are
200
+ * the same rule applied to the two ways a reader can be in it. `Escape` closes
201
+ * it from either — and when focus was inside, focus goes back to the trigger,
202
+ * because a card that took its own links away and left focus on `<body>` would
203
+ * send the reader back to the top of the page.
204
+ */
205
+ export component HoverCardBody(
206
+ children: React.Node,
207
+ align?: Align = "center",
208
+ alignOffset?: number = 0,
209
+ avoidCollisions?: boolean = true,
210
+ collisionPadding?: number = 0,
211
+ render?: RenderProp,
212
+ side?: LogicalSide = "bottom",
213
+ sideOffset?: number = 0,
214
+ ...rest: Rest
215
+ ) {
216
+ const card = useHoverCard("HoverCard.Body");
217
+ const { closeDelay, dismissedRef, intent, open, triggerRef } = card;
218
+ const bodyRef = useRef<HTMLElement | null>(null);
219
+ // Whether the reader is *in* the card, as opposed to over it. It decides one
220
+ // thing and it cannot be asked afterwards: a card closed while it held focus
221
+ // has to hand focus back, and by the time the effect below is cleaned up the
222
+ // element is gone from the document and `activeElement` has already fallen to
223
+ // `<body>` — so the answer is kept while it is still true.
224
+ const heldRef = useRef(false);
225
+ const close = useStableCallback(() => {
226
+ dismissedRef.current = true;
227
+ intent.cancel();
228
+ card.setOpen(false);
229
+ });
230
+
231
+ const anchored = useAnchor({
232
+ align,
233
+ alignOffset,
234
+ anchorRef: triggerRef,
235
+ avoidCollisions,
236
+ collisionPadding,
237
+ open,
238
+ overlayRef: bodyRef,
239
+ side,
240
+ sideOffset,
241
+ });
242
+
243
+ useDismissOnEscape(open, bodyRef, close);
244
+
245
+ // Keyed on `open`, because the element does not exist until then; see
246
+ // `tooltip.js` for why `useEventListener` cannot be used here.
247
+ useEffect(() => {
248
+ const body = bodyRef.current;
249
+ if (!open || body == null) {
250
+ return;
251
+ }
252
+ const stay = () => intent.cancel();
253
+ const go = () => intent.closeAfter(closeDelay);
254
+ const arrived = () => {
255
+ heldRef.current = true;
256
+ stay();
257
+ };
258
+ const gone = () => {
259
+ heldRef.current = false;
260
+ go();
261
+ };
262
+ body.addEventListener("pointerenter", stay);
263
+ body.addEventListener("pointerleave", go);
264
+ // `focusin` and `focusout` rather than `focus` and `blur`: the pair that
265
+ // bubbles is the one that hears a reader moving between two links *inside*
266
+ // the card, where the leave is immediately followed by an arrival and the
267
+ // scheduled close is called off before it runs.
268
+ body.addEventListener("focusin", arrived);
269
+ body.addEventListener("focusout", gone);
270
+
271
+ return () => {
272
+ body.removeEventListener("pointerenter", stay);
273
+ body.removeEventListener("pointerleave", go);
274
+ body.removeEventListener("focusin", arrived);
275
+ body.removeEventListener("focusout", gone);
276
+ };
277
+ }, [open, intent, closeDelay, triggerRef]);
278
+
279
+ // Focus goes back to the trigger when the card *closes* under the reader's
280
+ // focus, which is what `Escape` does: focus was on a link that no longer
281
+ // exists, and leaving it on `<body>` sends the reader back to the top of the
282
+ // page. A card that closed because the pointer left, with focus somewhere
283
+ // else entirely, has no business moving it.
284
+ //
285
+ // Its own effect, keyed on `open` alone. It used to live in the cleanup of
286
+ // the listener effect above, which runs whenever any of that effect's
287
+ // dependencies change — and `closeDelay` is a caller's prop. A caller
288
+ // changing it while the card was open with focus inside pulled focus off the
289
+ // link the reader was on, and the effect then re-attached with `held` reset.
290
+ useEffect(() => {
291
+ if (open) {
292
+ return;
293
+ }
294
+ if (heldRef.current) {
295
+ heldRef.current = false;
296
+ triggerRef.current?.focus?.();
297
+ }
298
+ }, [open, triggerRef]);
299
+
300
+ // And on unmount, which the effect above cannot see: a card removed while
301
+ // the reader is inside it leaves focus on a node that is gone.
302
+ useEffect(
303
+ () => () => {
304
+ if (heldRef.current) {
305
+ heldRef.current = false;
306
+ triggerRef.current?.focus?.();
307
+ }
308
+ },
309
+ [triggerRef],
310
+ );
311
+
312
+ if (!open) {
313
+ return null;
314
+ }
315
+
316
+ const props = withProps(withoutComposed(rest, ["ref"]), {
317
+ children,
318
+ "data-align": anchored.align,
319
+ "data-side": anchored.side,
320
+ "data-state": "open",
321
+ id: `${card.base}-body`,
322
+ // React calls callback refs during commit; placement effects read it later.
323
+ // uf-lint-disable-next-line react-compiler/refs
324
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
325
+ bodyRef.current = element;
326
+ }),
327
+ });
328
+
329
+ if (render != null) {
330
+ return render(props);
331
+ }
332
+
333
+ return <div {...props} />;
334
+ }
@@ -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
+ }