@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40

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 (52) 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 +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +169 -0
  10. package/combobox.js +209 -40
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +229 -197
  14. package/drawer.js +490 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +330 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2323 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +205 -11
  30. package/menu.js +521 -336
  31. package/menubar.js +288 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +344 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +302 -0
  38. package/resizable.js +447 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +888 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +313 -0
  44. package/skeleton.js +159 -0
  45. package/slider.js +405 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +592 -0
  50. package/toggle-group.js +282 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +400 -0
package/resizable.js ADDED
@@ -0,0 +1,447 @@
1
+ // @flow
2
+ //
3
+ // Two panes and the handle between them — a slider wearing a different role.
4
+ //
5
+ // The APG calls this a **window splitter**, and it defines it as a separator
6
+ // that behaves like a slider: `aria-valuenow`, `aria-valuemin` and
7
+ // `aria-valuemax` say how much of the space the primary pane has, and the
8
+ // arrow keys change it. That is why it is next to `slider.js` and shares
9
+ // `internal/range.js` with it rather than living with the layout components.
10
+ //
11
+ // Almost every resizable panel on the web is pointer-only, which is a WCAG
12
+ // 2.1.1 failure — no keyboard operation at all — and a 2.5.7 one on top of it.
13
+ // If uf ships one the keyboard is the feature, so the keyboard was written
14
+ // first and shipped on its own: a keyboard-only splitter is a working
15
+ // splitter, where a pointer-only one is not.
16
+ //
17
+ // It was not the finished control. A bar between two panes is a bar that
18
+ // approximately everybody takes hold of first, and this one could not be
19
+ // moved that way at all. Both halves are here now, and the key map is exactly
20
+ // what it was — a drag that had quietly replaced it would be the first failure
21
+ // in the other direction.
22
+ //
23
+ // # The drag, and the step it does not use
24
+ //
25
+ // It is `slider.js`'s `Slider.Track` arithmetic measured against the *group's*
26
+ // box rather than a track's. `pointerdown` captures the pointer, so a drag
27
+ // that wanders off a bar four pixels wide — which every drag does — keeps
28
+ // arriving at the handle instead of being lost to whatever it wandered over;
29
+ // `pointermove` turns the position into a percentage from
30
+ // `getBoundingClientRect`; and the percentage goes through `internal/range.js`
31
+ // like every other value here, so a drag cannot leave the pane anywhere an
32
+ // arrow key could not put it, and `aria-valuenow` is true about it while it
33
+ // moves.
34
+ //
35
+ // Pressing the handle does not move it. A press on a *track* means "put the
36
+ // value here", which is what `Slider.Track` does with one and why it is half
37
+ // of WCAG 2.5.7 there; a press on a handle means "take hold of this". A
38
+ // splitter that also jumped by the distance between the pointer and its own
39
+ // centre would move a little every time it was clicked, which is the one thing
40
+ // a person who clicked it did not ask for.
41
+ //
42
+ // `step` stays the keyboard's, and the pointer has one of its own. `step`
43
+ // defaults to 10 because ten presses of an arrow key ought to cross the pane,
44
+ // and 10 is an absurd granularity for a bar being dragged under a pointer that
45
+ // moves smoothly. The pointer's is one percent, and it is a constant rather
46
+ // than a second prop because the value is already a percentage of the group:
47
+ // one is the smallest move that means anything, and a splitter announcing
48
+ // 47.382 would be reading out its arithmetic rather than its size.
49
+ //
50
+ // # The two separators in this package are not the same thing
51
+ //
52
+ // A reader who greps for `separator` finds this and `menu.js`, and they are
53
+ // unrelated:
54
+ //
55
+ // * `Menu.Separator` is a rule between groups of items. It is not focusable,
56
+ // it has no value, and it exists so a reader moving through a menu is told
57
+ // the group changed.
58
+ // * `Resizable.Handle` is a *window splitter*. It is focusable, it carries a
59
+ // value, and operating it changes the layout.
60
+ //
61
+ // ARIA gives both the same role because both are separators; only the second
62
+ // one is a control. The difference is `tabindex` and `aria-valuenow`, which is
63
+ // also how a screen reader tells them apart.
64
+ //
65
+ // # `aria-orientation` is the separator's, not the layout's
66
+ //
67
+ // Two panes side by side are divided by a **vertical** line, so a
68
+ // `PanelGroup` whose `orientation` is `"horizontal"` renders a handle whose
69
+ // `aria-orientation` is `"vertical"`. That inversion is easy to get backwards
70
+ // and worth stating: `aria-orientation` on a separator describes the separator,
71
+ // and ARIA's default for the role is `horizontal` — so a vertical splitter
72
+ // that says nothing is announced as a horizontal rule.
73
+ //
74
+ // The APG's own pattern page does not mention `aria-orientation` at all, which
75
+ // is why implementations differ. This follows the role's definition rather
76
+ // than the pattern's silence.
77
+ //
78
+ // # Two panes, which is the pattern and not a limitation
79
+ //
80
+ // The window splitter is defined between two panes: a primary one whose size
81
+ // is the value, and the rest. A group of five panels is a layout-constraint
82
+ // problem — every handle's range depends on every other panel's minimum — and
83
+ // it is a different component with a different core, not a bigger version of
84
+ // this one. What is here is the pattern, complete.
85
+ //
86
+ // # Drawing it
87
+ //
88
+ // Each pane carries its share as `--uf-resizable-size`, a percentage, and the
89
+ // caller's stylesheet decides whether that is a width, a height, a `flex-basis`
90
+ // or nothing at all. The group is measured rather than drawn — the drag reads
91
+ // its box and never writes to it — so a caller whose panes are flex children,
92
+ // grid tracks or absolutely positioned gets the same splitter.
93
+
94
+ "use client";
95
+
96
+ import * as React from "@uniflowed/react";
97
+ import {
98
+ createContext,
99
+ useContext,
100
+ useEffect,
101
+ useId,
102
+ useMemo,
103
+ useRef,
104
+ useState,
105
+ } from "@uniflowed/react";
106
+
107
+ import type { Rest } from "./internal/merge-props.js";
108
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
109
+ import type { Orientation } from "./internal/roving-focus.js";
110
+ import { clamp, isReversed, snap } from "./internal/range.js";
111
+ import { useControlled } from "./internal/controlled-state.js";
112
+
113
+ /**
114
+ * How finely a drag may move the splitter, in percentage points.
115
+ *
116
+ * Not `step`, which is the keyboard's and defaults to ten; the module header
117
+ * says why the two cannot be the same number. One is a constant rather than a
118
+ * prop because the value is already a percentage of the group, so one is the
119
+ * smallest move that means anything.
120
+ */
121
+ const POINTER_STEP = 1;
122
+
123
+ type ResizableState = {|
124
+ readonly base: string,
125
+ /** The primary pane's share of the group, as a percentage. */
126
+ readonly value: number,
127
+ readonly setValue: (value: number) => void,
128
+ readonly min: number,
129
+ readonly max: number,
130
+ readonly step: number,
131
+ /** How the panes are laid out; the handle's own orientation is the other one. */
132
+ readonly orientation: Orientation,
133
+ readonly disabled: boolean,
134
+ readonly hasPrimary: boolean,
135
+ readonly registerPrimary: (present: boolean) => void,
136
+ /**
137
+ * The element a drag is measured against.
138
+ *
139
+ * The group rather than the handle, because the value is the primary pane's
140
+ * share *of the group* — the handle is a few pixels wide and has no idea how
141
+ * much space there is to divide.
142
+ */
143
+ readonly groupRef: { current: HTMLElement | null },
144
+ |};
145
+
146
+ const ResizableContext: React.Context<ResizableState | null> = createContext(null);
147
+
148
+ hook useResizable(part: string): ResizableState {
149
+ const state = useContext(ResizableContext);
150
+ if (state == null) {
151
+ throw new Error(`${part} must be rendered inside a Resizable.PanelGroup`);
152
+ }
153
+ return state;
154
+ }
155
+
156
+ /**
157
+ * The two panes and their handle.
158
+ *
159
+ * `value` is the primary pane's percentage of the group, which is what the
160
+ * handle announces — the APG's "a decimal value representing the current
161
+ * position of the separator", where 0 is collapsed and 100 is as large as it
162
+ * is allowed to be.
163
+ *
164
+ * `min` is the size the primary pane collapses to. It is 0 by default, so
165
+ * `Enter` collapses the pane entirely; a group whose primary pane should never
166
+ * disappear gives it a floor.
167
+ */
168
+ export component ResizablePanelGroup(
169
+ children: React.Node,
170
+ value?: number,
171
+ defaultValue?: number = 50,
172
+ onValueChange?: (value: number) => void,
173
+ min?: number = 0,
174
+ max?: number = 100,
175
+ step?: number = 10,
176
+ orientation?: Orientation = "horizontal",
177
+ disabled?: boolean = false,
178
+ ...rest: Rest
179
+ ) {
180
+ const base = useId();
181
+ const [share, setShare] = useControlled(value, defaultValue, onValueChange);
182
+ const [hasPrimary, setHasPrimary] = useState(false);
183
+ const groupRef = useRef<HTMLElement | null>(null);
184
+
185
+ const state = useMemo(
186
+ () => ({
187
+ base,
188
+ value: clamp(share, min, max),
189
+ setValue: setShare,
190
+ min,
191
+ max,
192
+ step,
193
+ orientation,
194
+ disabled,
195
+ hasPrimary,
196
+ registerPrimary: setHasPrimary,
197
+ groupRef,
198
+ }),
199
+ [base, share, setShare, min, max, step, orientation, disabled, hasPrimary],
200
+ );
201
+
202
+ const passed = withoutComposed(rest, ["ref"]);
203
+
204
+ return (
205
+ <ResizableContext.Provider value={state}>
206
+ <div
207
+ {...passed}
208
+ ref={composeRefs(rest.ref, (element) => {
209
+ groupRef.current = element;
210
+ })}
211
+ >
212
+ {children}
213
+ </div>
214
+ </ResizableContext.Provider>
215
+ );
216
+ }
217
+
218
+ /**
219
+ * One pane.
220
+ *
221
+ * `primary` marks the one whose size is the value, and the one the handle
222
+ * names with `aria-controls`. Exactly one pane in a group is primary; the
223
+ * other takes what is left. It is a prop rather than "the first one", because
224
+ * the first one in the document is not the first one to mount the moment a
225
+ * caller renders a pane conditionally, and a handle pointing at the wrong pane
226
+ * is a handle that announces someone else's size.
227
+ */
228
+ export component ResizablePanel(children: React.Node, primary?: boolean = false, ...rest: Rest) {
229
+ const group = useResizable("Resizable.Panel");
230
+ const passed = withoutComposed(rest, ["style"]);
231
+ const register = group.registerPrimary;
232
+
233
+ useEffect(() => {
234
+ if (!primary) {
235
+ return;
236
+ }
237
+ register(true);
238
+ return () => register(false);
239
+ }, [primary, register]);
240
+
241
+ return (
242
+ <div
243
+ {...passed}
244
+ id={primary ? `${group.base}-primary` : undefined}
245
+ style={{
246
+ ...(rest.style as $FlowFixMe),
247
+ "--uf-resizable-size": `${String(primary ? group.value : 100 - group.value)}%`,
248
+ }}
249
+ >
250
+ {children}
251
+ </div>
252
+ );
253
+ }
254
+
255
+ /**
256
+ * The splitter: a separator that behaves like a slider.
257
+ *
258
+ * `label` is its accessible name and has a default, because a splitter is a
259
+ * bare bar with no text in it every time — and a focusable separator with no
260
+ * name is announced as "separator", which tells a reader there is a control
261
+ * here and nothing about what it does.
262
+ */
263
+ export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
264
+ const group = useResizable("Resizable.Handle");
265
+ const passed = withoutComposed(rest, [
266
+ "onKeyDown",
267
+ "onPointerCancel",
268
+ "onPointerDown",
269
+ "onPointerMove",
270
+ "onPointerUp",
271
+ ]);
272
+ // Where the pane was before `Enter` collapsed it. A ref because nothing
273
+ // renders it: it is a fact about the last keystroke, not about the layout.
274
+ const restoreTo = useRef<number | null>(null);
275
+ // Whether the pointer is down on this handle. Also a ref, and for the same
276
+ // reason: it changes between renders and no render depends on it.
277
+ const dragging = useRef(false);
278
+
279
+ const moveBy = (amount: number) => {
280
+ restoreTo.current = null;
281
+ group.setValue(clamp(group.value + amount, group.min, group.max));
282
+ };
283
+
284
+ /** The primary pane's share at the pointer, or null with no box to read. */
285
+ const shareAt = (event: $FlowFixMe): number | null => {
286
+ const element = group.groupRef.current;
287
+ if (element == null) {
288
+ return null;
289
+ }
290
+ const box = element.getBoundingClientRect();
291
+ const vertical = group.orientation === "vertical";
292
+ const size = vertical ? box.height : box.width;
293
+ if (size <= 0) {
294
+ // A group with no box has no percentages in it, and dividing by its
295
+ // width would put `Infinity` into `aria-valuenow`.
296
+ return null;
297
+ }
298
+ // From the top for stacked panes, where `Slider.Track` reads from the
299
+ // bottom: a slider's minimum is at the bottom of its track, and a group's
300
+ // primary pane is the one *before* the handle, which is the top one.
301
+ const along = vertical ? event.clientY - box.top : event.clientX - box.left;
302
+ const part = clamp(along / size, 0, 1);
303
+ // In a right-to-left page the pane before the handle is the one on the
304
+ // right, so the reading runs the other way. `isReversed` mirrors nothing on
305
+ // a stacked group, because writing direction does not flip the vertical
306
+ // axis.
307
+ const forward = isReversed(element, group.orientation) ? 1 - part : part;
308
+ // The value *is* the percentage, so the position becomes one directly
309
+ // rather than being mapped across `min`–`max` the way a slider's is: those
310
+ // two are bounds on how far the pane may be dragged, not the ends of a
311
+ // scale. Mapping them would put the handle somewhere the pointer is not.
312
+ return snap(forward * 100, group.min, group.max, POINTER_STEP);
313
+ };
314
+
315
+ const endDrag = (event: $FlowFixMe) => {
316
+ dragging.current = false;
317
+ event.currentTarget?.releasePointerCapture?.(event.pointerId);
318
+ };
319
+
320
+ return (
321
+ <div
322
+ {...passed}
323
+ aria-label={label}
324
+ // Only while a primary pane is in the document, for the reason every
325
+ // part of this package repeats: an `aria-controls` naming an id nothing
326
+ // has is worse than saying nothing at all.
327
+ aria-controls={group.hasPrimary ? `${group.base}-primary` : undefined}
328
+ aria-disabled={group.disabled ? "true" : undefined}
329
+ // The separator's own orientation, which is the other axis from the one
330
+ // the panes are laid out along. See the module header.
331
+ aria-orientation={group.orientation === "horizontal" ? "vertical" : "horizontal"}
332
+ aria-valuemax={group.max}
333
+ aria-valuemin={group.min}
334
+ aria-valuenow={group.value}
335
+ onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
336
+ if (group.disabled) {
337
+ return;
338
+ }
339
+ if (event.key === "Enter") {
340
+ event.preventDefault();
341
+ // Collapse, or put it back where it was. The APG gives `Enter` both
342
+ // jobs, and a collapse with no way back is a pane a keyboard reader
343
+ // has thrown away.
344
+ const previous = restoreTo.current;
345
+ if (previous != null) {
346
+ restoreTo.current = null;
347
+ group.setValue(previous);
348
+ return;
349
+ }
350
+ if (group.value === group.min) {
351
+ return;
352
+ }
353
+ restoreTo.current = group.value;
354
+ group.setValue(group.min);
355
+ return;
356
+ }
357
+
358
+ if (event.key === "Home" || event.key === "End") {
359
+ event.preventDefault();
360
+ restoreTo.current = null;
361
+ group.setValue(event.key === "Home" ? group.min : group.max);
362
+ return;
363
+ }
364
+
365
+ const amount = stepFor(
366
+ event.key,
367
+ group.step,
368
+ group.orientation,
369
+ isReversed(event.currentTarget, group.orientation),
370
+ );
371
+ if (amount != null) {
372
+ event.preventDefault();
373
+ moveBy(amount);
374
+ }
375
+ })}
376
+ onPointerCancel={composeHandlers(rest.onPointerCancel, endDrag)}
377
+ onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
378
+ if (group.disabled) {
379
+ return;
380
+ }
381
+ // Otherwise the press selects the text in the panes either side on the
382
+ // way past, so a drag paints half the page blue.
383
+ event.preventDefault();
384
+ // Which also means the browser will not focus this element, and a
385
+ // reader who has just dragged the splitter is the reader most likely to
386
+ // want an arrow key next.
387
+ event.currentTarget?.focus?.();
388
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
389
+ dragging.current = true;
390
+ // A drag is a move, so the pane `Enter` would put back is no longer
391
+ // where it was. Leaving it would make the next `Enter` restore a size
392
+ // from before the drag.
393
+ restoreTo.current = null;
394
+ // Deliberately no value change: taking hold of the handle is not asking
395
+ // it to move. The module header says what a press does on a track
396
+ // instead, and why the two are not the same gesture.
397
+ })}
398
+ onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
399
+ if (!dragging.current) {
400
+ return;
401
+ }
402
+ const share = shareAt(event);
403
+ if (share != null) {
404
+ group.setValue(share);
405
+ }
406
+ })}
407
+ onPointerUp={composeHandlers(rest.onPointerUp, endDrag)}
408
+ role="separator"
409
+ // A separator that is not in the tab sequence is the WCAG 2.1.1 failure
410
+ // this module exists to avoid.
411
+ tabIndex={group.disabled ? -1 : 0}
412
+ />
413
+ );
414
+ }
415
+
416
+ /**
417
+ * How far a key moves the splitter, or nothing when the key is not ours.
418
+ *
419
+ * Only the keys along the axis the panes are laid out on: `ArrowUp` in a group
420
+ * of side-by-side panes is the page's, and swallowing it takes a scroll key
421
+ * away from every reader who uses one.
422
+ *
423
+ * The primary pane is the one before the handle, so moving the handle towards
424
+ * the end of the axis makes it larger — and in a right-to-left page the end of
425
+ * a horizontal axis is on the left, so the arrows mirror.
426
+ */
427
+ function stepFor(
428
+ key: string,
429
+ step: number,
430
+ orientation: Orientation,
431
+ reversed: boolean,
432
+ ): number | null {
433
+ const move = step <= 0 ? 1 : step;
434
+ if (orientation === "vertical") {
435
+ return match (key) {
436
+ "ArrowDown" => move,
437
+ "ArrowUp" => -move,
438
+ _ => null,
439
+ };
440
+ }
441
+ const forward = reversed ? -1 : 1;
442
+ return match (key) {
443
+ "ArrowRight" => move * forward,
444
+ "ArrowLeft" => -move * forward,
445
+ _ => null,
446
+ };
447
+ }