@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.37

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 (58) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. package/internal/tabs.js +0 -270
package/drawer.js ADDED
@@ -0,0 +1,490 @@
1
+ // @flow
2
+ //
3
+ // A drawer: the sheet you can drag away, and the one with a specification
4
+ // attached.
5
+ //
6
+ // It is `sheet.js` — same edge, same modal promises, same `data-side` — plus a
7
+ // gesture. The gesture is the whole of what is new, and a gesture is the part
8
+ // of a component most likely to be inaccessible while looking polished:
9
+ //
10
+ // * **WCAG 2.2 SC 2.5.7, *Dragging Movements*.** Anything achievable by
11
+ // dragging must also be achievable with a single pointer and no drag. So
12
+ // drag-to-dismiss is an *addition to* a close button and never a
13
+ // replacement for one, and `Drawer.Body` raises when a `Drawer.Handle` is
14
+ // rendered without a `Drawer.Close` beside it. A drawer that can only be
15
+ // dismissed by dragging is inaccessible, and it is inaccessible in the way
16
+ // that gets shipped: it demonstrates beautifully.
17
+ // * **WCAG 2.1.1, *Keyboard*.** Every snap point the drag can reach, the
18
+ // arrow keys reach. `Drawer.Handle` is a `role="slider"` over the snap
19
+ // points, with `Home` and `End` at the ends — which is also why it has a
20
+ // `label`: a slider with no accessible name is announced as "slider".
21
+ // Pressing the closing key at the smallest snap point closes the drawer,
22
+ // because "drag it off the edge" has to be a key as well.
23
+ // * **`prefers-reduced-motion`.** A drawer that slides and springs is motion
24
+ // the reader may have asked their system not to make. `usePrefersReducedMotion`
25
+ // from `@uniflowed/hooks/browser` puts `data-reduced-motion="true"` on the
26
+ // body, and the stylesheet drops the transition. The drag itself still
27
+ // follows the finger: direct manipulation is not animation, and freezing it
28
+ // would make the drawer feel broken rather than calm.
29
+ //
30
+ // # Snap points are indices, and the type says so
31
+ //
32
+ // `snapPoints` is a list of fractions of the drawer's full size, ascending —
33
+ // `[0.4, 1]` is "peek, then full". The *state* is the index into that list
34
+ // rather than the fraction, because the arrow keys move by one snap point and
35
+ // a slider whose value is `0.4` has to be told what the next value is. The
36
+ // index is also what `aria-valuenow` can be: `aria-valuemin={0}` and
37
+ // `aria-valuemax={snapPoints.length - 1}` are true about a list, and
38
+ // `aria-valuetext` is what says "40%" to a reader.
39
+ //
40
+ // # Where the numbers go
41
+ //
42
+ // `--uf-drawer-snap` (the current fraction) and `--uf-drawer-drag` (how far the
43
+ // finger has moved, in pixels) are written straight onto the element rather
44
+ // than put in state, for the reason `internal/anchor.js` gives about a
45
+ // placement: the second of them changes on every pointer frame, and
46
+ // re-rendering the drawer and everything in it to move a box is the cost this
47
+ // package does not pay. React owns neither property.
48
+
49
+ "use client";
50
+
51
+ import * as React from "@uniflowed/react";
52
+ import { createContext, useContext, useEffect, useMemo, useRef, useState } from "@uniflowed/react";
53
+ import { usePrefersReducedMotion } from "@uniflowed/hooks/browser";
54
+
55
+ import type { Edge } from "./sheet.js";
56
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
57
+ import {
58
+ composeHandlers,
59
+ composeRefs,
60
+ forwarded,
61
+ withProps,
62
+ withoutComposed,
63
+ } from "./internal/merge-props.js";
64
+ import {
65
+ SheetBody,
66
+ SheetClose,
67
+ SheetDescription,
68
+ SheetFooter,
69
+ SheetHeader,
70
+ SheetOverlay,
71
+ SheetRoot,
72
+ SheetTitle,
73
+ SheetTrigger,
74
+ } from "./sheet.js";
75
+ import { useControlled } from "./internal/controlled-state.js";
76
+
77
+ export type { Edge } from "./sheet.js";
78
+
79
+ /**
80
+ * The whole drawer, and the only snap point a caller who asked for none gets.
81
+ *
82
+ * Frozen at module scope rather than defaulted inline, so the default is one
83
+ * array rather than a fresh one per render — which would make every memo keyed
84
+ * on `snapPoints` miss.
85
+ */
86
+ const FULLY_OPEN: $ReadOnlyArray<number> = Object.freeze([1]);
87
+
88
+ /** How far along its own size a drag has to travel to change the snap point. */
89
+ const DRAG_THRESHOLD = 0.25;
90
+
91
+ type DrawerState = {|
92
+ readonly side: Edge,
93
+ readonly snapPoints: $ReadOnlyArray<number>,
94
+ readonly snapIndex: number,
95
+ readonly setSnapIndex: (next: number) => void,
96
+ readonly close: () => void,
97
+ readonly bodyRef: { current: HTMLElement | null },
98
+ /**
99
+ * How many `Drawer.Close`es and `Drawer.Handle`s are in the document.
100
+ *
101
+ * Counted refs rather than state, for the reason `alert-dialog.js` gives: a
102
+ * child's effect runs before its parent's, so `Drawer.Body` can ask about
103
+ * both on the commit that mounted them, and nothing renders either number.
104
+ */
105
+ readonly closes: { current: number },
106
+ readonly handles: { current: number },
107
+ |};
108
+
109
+ const DrawerContext: React.Context<DrawerState | null> = createContext(null);
110
+
111
+ /**
112
+ * The drawer a part belongs to.
113
+ *
114
+ * Raising rather than returning null, for the reason `useDialog` gives: a
115
+ * `Drawer.Handle` outside a root would render a slider over no snap points.
116
+ */
117
+ hook useDrawer(part: string): DrawerState {
118
+ const state = useContext(DrawerContext);
119
+ if (state == null) {
120
+ throw new Error(`${part} must be rendered inside a Drawer.Root`);
121
+ }
122
+ return state;
123
+ }
124
+
125
+ /**
126
+ * The drawer, open or closed, at one of its snap points.
127
+ *
128
+ * It owns `open` rather than letting `Dialog.Root` own it — and hands it down
129
+ * as a controlled prop — because the drag has to be able to close the drawer
130
+ * from a pointer handler, and the dialog's own state is not reachable from
131
+ * outside its parts. Both arrangements still work for the caller: `open` and
132
+ * `onOpenChange` behave exactly as they do everywhere else in this package,
133
+ * because `internal/controlled-state.js` is what answers here too.
134
+ */
135
+ export component DrawerRoot(
136
+ children: React.Node,
137
+ defaultOpen?: boolean = false,
138
+ defaultSnapPoint?: number = 0,
139
+ onOpenChange?: (open: boolean) => void,
140
+ onSnapPointChange?: (index: number) => void,
141
+ open?: boolean,
142
+ side?: Edge = "bottom",
143
+ snapPoint?: number,
144
+ snapPoints?: $ReadOnlyArray<number> = FULLY_OPEN,
145
+ ) {
146
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
147
+ const [snapIndex, setSnapIndex] = useControlled(snapPoint, defaultSnapPoint, onSnapPointChange);
148
+ const bodyRef = useRef<HTMLElement | null>(null);
149
+ const closes = useRef(0);
150
+ const handles = useRef(0);
151
+
152
+ const state = useMemo(
153
+ () => ({
154
+ bodyRef,
155
+ close: () => setOpen(false),
156
+ closes,
157
+ handles,
158
+ setSnapIndex,
159
+ side,
160
+ snapIndex,
161
+ snapPoints,
162
+ }),
163
+ [setOpen, setSnapIndex, side, snapIndex, snapPoints],
164
+ );
165
+
166
+ return (
167
+ <DrawerContext.Provider value={state}>
168
+ <SheetRoot onOpenChange={setOpen} open={isOpen} side={side}>
169
+ {children}
170
+ </SheetRoot>
171
+ </DrawerContext.Provider>
172
+ );
173
+ }
174
+
175
+ /** What opens it, and what focus comes back to when it closes. */
176
+ export component DrawerTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
177
+ return (
178
+ <SheetTrigger {...forwarded(rest)} render={render}>
179
+ {children}
180
+ </SheetTrigger>
181
+ );
182
+ }
183
+
184
+ /** The backdrop. It carries the edge, the same as a sheet's. */
185
+ export component DrawerOverlay(render?: RenderProp, ...rest: Rest) {
186
+ return <SheetOverlay {...forwarded(rest)} render={render} />;
187
+ }
188
+
189
+ /**
190
+ * The drawer itself: a sheet, at a snap point, that can be dragged.
191
+ *
192
+ * The raise is the WCAG 2.5.7 clause, enforced rather than documented. It fires
193
+ * only when a `Drawer.Handle` is rendered, because a drawer with no handle has
194
+ * no drag to provide an alternative to — and a drawer with a handle and no
195
+ * `Drawer.Close` has a gesture that is the only way out.
196
+ */
197
+ export component DrawerBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
198
+ const drawer = useDrawer("Drawer.Body");
199
+ const { bodyRef, snapIndex, snapPoints } = drawer;
200
+ const reducedMotion = usePrefersReducedMotion();
201
+ const fraction = snapPoints[snapIndex] ?? 1;
202
+
203
+ // Written rather than rendered, for the reason the module header gives: this
204
+ // is the pair `--uf-drawer-drag` moves between, and putting either in a
205
+ // `style` prop would hand React a property the pointer handler also writes.
206
+ useEffect(() => {
207
+ const body = bodyRef.current;
208
+ if (body == null) {
209
+ return;
210
+ }
211
+ body.style.setProperty("--uf-drawer-snap", String(fraction));
212
+ body.style.setProperty("--uf-drawer-drag", "0px");
213
+ }, [bodyRef, fraction]);
214
+
215
+ return (
216
+ <SheetBody
217
+ {...forwarded(rest)}
218
+ data-reduced-motion={reducedMotion ? "true" : undefined}
219
+ data-snap-point={String(snapIndex)}
220
+ ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
221
+ bodyRef.current = element;
222
+ })}
223
+ render={render}
224
+ >
225
+ {children}
226
+ <RequireCloseForTheDrag />
227
+ </SheetBody>
228
+ );
229
+ }
230
+
231
+ /**
232
+ * WCAG 2.5.7, asked where it can be answered.
233
+ *
234
+ * Inside `Sheet.Body` and last, for the reason `alert-dialog.js`'s
235
+ * `RequireDescription` gives: a drawer that has not been opened has neither a
236
+ * handle nor a close button in the document, so the question is only meaningful
237
+ * once the body is showing, and every part above this has counted itself by the
238
+ * time this asks.
239
+ *
240
+ * A drawer with no handle has no drag, and a rule about dragging has nothing to
241
+ * say about it — which is why the raise is conditional on there being one
242
+ * rather than on there being a close button.
243
+ */
244
+ component RequireCloseForTheDrag() {
245
+ const drawer = useDrawer("Drawer.Body");
246
+ const { closes, handles } = drawer;
247
+
248
+ useEffect(() => {
249
+ if (handles.current > 0 && closes.current === 0) {
250
+ throw new Error(
251
+ "Drawer.Body has a Drawer.Handle and no Drawer.Close: WCAG 2.2 SC 2.5.7 " +
252
+ "requires anything achievable by dragging to be achievable without a " +
253
+ "drag, so drag-to-dismiss is an addition to a close button and never a " +
254
+ "replacement for one.",
255
+ );
256
+ }
257
+ }, [closes, handles]);
258
+
259
+ return null;
260
+ }
261
+
262
+ /** The top of the drawer, where the handle usually goes. */
263
+ export component DrawerHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
264
+ return (
265
+ <SheetHeader {...forwarded(rest)} render={render}>
266
+ {children}
267
+ </SheetHeader>
268
+ );
269
+ }
270
+
271
+ /** The bottom of the drawer, where the actions go. */
272
+ export component DrawerFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
273
+ return (
274
+ <SheetFooter {...forwarded(rest)} render={render}>
275
+ {children}
276
+ </SheetFooter>
277
+ );
278
+ }
279
+
280
+ /** The drawer's accessible name. */
281
+ export component DrawerTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
282
+ return (
283
+ <SheetTitle {...forwarded(rest)} render={render}>
284
+ {children}
285
+ </SheetTitle>
286
+ );
287
+ }
288
+
289
+ /** What the drawer is for, announced after its name. */
290
+ export component DrawerDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
291
+ return (
292
+ <SheetDescription {...forwarded(rest)} render={render}>
293
+ {children}
294
+ </SheetDescription>
295
+ );
296
+ }
297
+
298
+ /**
299
+ * A button that closes the drawer, and the single-pointer alternative to the
300
+ * drag.
301
+ *
302
+ * It registers itself so `Drawer.Body` can tell whether the gesture has one.
303
+ */
304
+ export component DrawerClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
305
+ const drawer = useDrawer("Drawer.Close");
306
+ const closes = drawer.closes;
307
+
308
+ useEffect(() => {
309
+ closes.current += 1;
310
+ return () => {
311
+ closes.current -= 1;
312
+ };
313
+ }, [closes]);
314
+
315
+ return (
316
+ <SheetClose {...forwarded(rest)} render={render}>
317
+ {children}
318
+ </SheetClose>
319
+ );
320
+ }
321
+
322
+ /**
323
+ * The grip: a slider over the snap points, and the thing the finger drags.
324
+ *
325
+ * Both halves are the same control on purpose. A drag handle that is not
326
+ * focusable is the WCAG 2.1.1 failure; a pair of arrow buttons beside a drag
327
+ * handle is two controls for one job, and a reader who found one has no way to
328
+ * know the other exists. `role="slider"` says what it does — the snap points
329
+ * are its values — and `Home` and `End` are the ends of the list.
330
+ *
331
+ * `label` because a slider with no accessible name is announced as "slider",
332
+ * which is the same failure `Resizable.Handle` names.
333
+ */
334
+ export component DrawerHandle(
335
+ label?: string = "Resize the drawer",
336
+ render?: RenderProp,
337
+ ...rest: Rest
338
+ ) {
339
+ const drawer = useDrawer("Drawer.Handle");
340
+ const { bodyRef, close, handles, setSnapIndex, side, snapIndex, snapPoints } = drawer;
341
+ const passed = withoutComposed(rest, [
342
+ "onKeyDown",
343
+ "onPointerDown",
344
+ "onPointerMove",
345
+ "onPointerUp",
346
+ ]);
347
+ // Where the finger went down, and along which axis. A ref because nothing
348
+ // renders it: it is a fact about a gesture in progress.
349
+ const dragFrom = useRef<number | null>(null);
350
+ const [dragging, setDragging] = useState(false);
351
+ const vertical = side === "top" || side === "bottom";
352
+ const last = snapPoints.length - 1;
353
+
354
+ // So `Drawer.Body` knows there is a drag to provide an alternative to. A
355
+ // drawer with no handle has no gesture, and requiring a close button of one
356
+ // would be this component inventing a rule WCAG did not write.
357
+ useEffect(() => {
358
+ handles.current += 1;
359
+ return () => {
360
+ handles.current -= 1;
361
+ };
362
+ }, [handles]);
363
+
364
+ /** Move by one snap point, or close when there is no smaller one. */
365
+ const step = (towardsOpen: boolean) => {
366
+ if (towardsOpen) {
367
+ setSnapIndex(Math.min(last, snapIndex + 1));
368
+ return;
369
+ }
370
+ if (snapIndex === 0) {
371
+ // "Drag it off the edge", as a key. Without this the smallest snap point
372
+ // is a floor the keyboard cannot get past and the gesture is the only
373
+ // way to dismiss it.
374
+ close();
375
+ return;
376
+ }
377
+ setSnapIndex(snapIndex - 1);
378
+ };
379
+
380
+ /** How far a pointer has travelled towards closing the drawer, in pixels. */
381
+ const travelled = (event: $FlowFixMe): number => {
382
+ const from = dragFrom.current;
383
+ if (from == null) {
384
+ return 0;
385
+ }
386
+ const now = vertical ? event.clientY : event.clientX;
387
+ // Closing is towards the edge the drawer is attached to, which is the
388
+ // negative direction for a `top` or `left` drawer and the positive one for
389
+ // the other two.
390
+ return side === "top" || side === "left" ? from - now : now - from;
391
+ };
392
+
393
+ const props = withProps(passed, {
394
+ "aria-label": label,
395
+ // The axis the drag runs along, which is the axis the snap points are
396
+ // measured on: a bottom sheet grows upwards, so its slider is vertical.
397
+ "aria-orientation": vertical ? "vertical" : "horizontal",
398
+ "aria-valuemax": last,
399
+ "aria-valuemin": 0,
400
+ "aria-valuenow": snapIndex,
401
+ // The number a reader can act on. `aria-valuenow` is an index into a list
402
+ // nobody outside this component has seen, and "2" says nothing.
403
+ "aria-valuetext": `${String(Math.round((snapPoints[snapIndex] ?? 1) * 100))}%`,
404
+ "data-dragging": dragging ? "true" : undefined,
405
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
406
+ if (event.key === "Home" || event.key === "End") {
407
+ event.preventDefault();
408
+ setSnapIndex(event.key === "Home" ? 0 : last);
409
+ return;
410
+ }
411
+ const towardsOpen = OPENS_WITH[side];
412
+ const towardsClosed = CLOSES_WITH[side];
413
+ if (event.key === towardsOpen) {
414
+ event.preventDefault();
415
+ step(true);
416
+ return;
417
+ }
418
+ if (event.key === towardsClosed) {
419
+ event.preventDefault();
420
+ step(false);
421
+ }
422
+ }),
423
+ onPointerDown: composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
424
+ dragFrom.current = vertical ? event.clientY : event.clientX;
425
+ setDragging(true);
426
+ // So the drag survives the pointer leaving the handle, which it does
427
+ // immediately: the handle moves with the drawer.
428
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
429
+ }),
430
+ onPointerMove: composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
431
+ const body = bodyRef.current;
432
+ if (dragFrom.current == null || body == null) {
433
+ return;
434
+ }
435
+ // Only away from the edge: dragging a drawer *past* fully open would
436
+ // otherwise lift it off the edge it is attached to.
437
+ body.style.setProperty("--uf-drawer-drag", `${String(Math.max(0, travelled(event)))}px`);
438
+ }),
439
+ onPointerUp: composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
440
+ const body = bodyRef.current;
441
+ const moved = travelled(event);
442
+ dragFrom.current = null;
443
+ setDragging(false);
444
+ body?.style.setProperty("--uf-drawer-drag", "0px");
445
+ if (body == null) {
446
+ return;
447
+ }
448
+ const box = body.getBoundingClientRect();
449
+ const size = vertical ? box.height : box.width;
450
+ // A zero-sized box — a document that computes no layout — must not turn
451
+ // every release into a dismissal.
452
+ if (size <= 0 || Math.abs(moved) < size * DRAG_THRESHOLD) {
453
+ return;
454
+ }
455
+ step(moved < 0);
456
+ }),
457
+ role: "slider",
458
+ // A drag handle that is not in the tab sequence is the WCAG 2.1.1
459
+ // failure this part exists to avoid.
460
+ tabIndex: 0,
461
+ });
462
+
463
+ if (render != null) {
464
+ return render(props);
465
+ }
466
+ return <div {...props} />;
467
+ }
468
+
469
+ /**
470
+ * The key that makes the drawer bigger, per edge.
471
+ *
472
+ * A bottom sheet grows upwards and a left drawer grows to the right, so the
473
+ * arrow that opens one closes another. Written as a table rather than a
474
+ * conditional because there are four of them and the mistake to avoid is
475
+ * getting one wrong.
476
+ */
477
+ const OPENS_WITH: { readonly [Edge]: string } = {
478
+ bottom: "ArrowUp",
479
+ left: "ArrowRight",
480
+ right: "ArrowLeft",
481
+ top: "ArrowDown",
482
+ };
483
+
484
+ /** The key that makes it smaller, and closes it at the smallest snap point. */
485
+ const CLOSES_WITH: { readonly [Edge]: string } = {
486
+ bottom: "ArrowDown",
487
+ left: "ArrowLeft",
488
+ right: "ArrowRight",
489
+ top: "ArrowUp",
490
+ };