@uniflowed/ui 0.0.0-alpha.9 → 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 (62) 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 -177
  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 +1159 -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 +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/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 +316 -0
  35. package/list-box.js +13 -0
  36. package/menu.js +553 -361
  37. package/menubar.js +295 -0
  38. package/number-field.js +263 -0
  39. package/package.json +8 -28
  40. package/pagination.js +34 -22
  41. package/popover.js +116 -75
  42. package/progress.js +21 -16
  43. package/radio-group.js +81 -75
  44. package/range-calendar.js +78 -0
  45. package/resizable.js +155 -9
  46. package/scroll-area.js +283 -0
  47. package/select.js +83 -37
  48. package/separator.js +97 -0
  49. package/sheet.js +189 -0
  50. package/sidebar.js +320 -0
  51. package/skeleton.js +163 -0
  52. package/slider.js +95 -89
  53. package/switch.js +42 -34
  54. package/table.js +112 -71
  55. package/tabs.js +100 -91
  56. package/tag-group.js +8 -0
  57. package/time-field.js +8 -0
  58. package/toast.js +36 -66
  59. package/toggle-group.js +53 -49
  60. package/toggle.js +41 -27
  61. package/tooltip.js +48 -55
  62. package/tree.js +8 -0
package/toggle.js CHANGED
@@ -28,8 +28,9 @@
28
28
  //
29
29
  // Because a toggle button is a button, and a button activates on both. That is
30
30
  // the same reasoning `switch.js` gives for `Enter`, and the opposite of
31
- // `checkbox.js`, which leaves `Enter` alone because a checkbox is something a
32
- // reader answers on their way to submitting a form.
31
+ // `checkbox.js`, where `Enter` submits the form rather than touching the
32
+ // control — because a checkbox is something a reader answers on their way to
33
+ // submitting a form, and that is what the native one does with the key.
33
34
  //
34
35
  // # It is `disabled`, not `aria-disabled`
35
36
  //
@@ -43,49 +44,62 @@
43
44
 
44
45
  import * as React from "@uniflowed/react";
45
46
 
46
- import type { Rest } from "./internal/merge-props.js";
47
- import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
47
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
48
+ import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
48
49
  import { useControlled } from "./internal/controlled-state.js";
49
50
 
50
- /** A button whose state stays applied: pressed or not. */
51
+ /**
52
+ * A button whose state stays applied: pressed or not.
53
+ *
54
+ * `render` is the escape hatch. A caller-rendered element gets `role="button"`
55
+ * because it may be a link or a `div`; the native button branch keeps the role
56
+ * implicit and only adds `type="button"`, which is true of the element rather
57
+ * than of the toggle behaviour.
58
+ */
51
59
  export component Toggle(
52
60
  pressed?: boolean,
53
61
  defaultPressed?: boolean = false,
54
62
  onPressedChange?: (pressed: boolean) => void,
55
63
  disabled?: boolean = false,
56
64
  children?: React.Node,
65
+ render?: RenderProp,
57
66
  ...rest: Rest
58
67
  ) {
59
68
  const [on, setOn] = useControlled(pressed, defaultPressed, onPressedChange);
60
69
  const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
70
+ const semantics = {
71
+ "aria-pressed": on ? "true" : "false",
72
+ children,
73
+ disabled,
74
+ onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
75
+ if (!disabled) {
76
+ setOn(!on);
77
+ }
78
+ }),
79
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
80
+ if (disabled || (event.key !== " " && event.key !== "Enter")) {
81
+ return;
82
+ }
83
+ // Preventing the default stops `Space` scrolling the page, and stops
84
+ // the browser's own click arriving after this handler and pressing the
85
+ // button a second time — back to where it started, which reads as the
86
+ // key having done nothing at all.
87
+ event.preventDefault();
88
+ setOn(!on);
89
+ }),
90
+ };
91
+
92
+ if (render != null) {
93
+ return render(withProps(passed, { ...semantics, role: "button" }));
94
+ }
61
95
 
62
96
  return (
63
97
  <button
64
- {...passed}
98
+ {...withProps(passed, semantics)}
65
99
  // No `role`: this *is* a button, and `aria-pressed` is what makes it a
66
100
  // toggle one. Adding `role="button"` to a `<button>` would be noise, and
67
101
  // adding any other role would be a lie about what pressing it does.
68
- aria-pressed={on ? "true" : "false"}
69
- disabled={disabled}
70
- onClick={composeHandlers(rest.onClick, () => {
71
- if (!disabled) {
72
- setOn(!on);
73
- }
74
- })}
75
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
76
- if (disabled || (event.key !== " " && event.key !== "Enter")) {
77
- return;
78
- }
79
- // Preventing the default stops `Space` scrolling the page, and stops
80
- // the browser's own click arriving after this handler and pressing the
81
- // button a second time — back to where it started, which reads as the
82
- // key having done nothing at all.
83
- event.preventDefault();
84
- setOn(!on);
85
- })}
86
102
  type="button"
87
- >
88
- {children}
89
- </button>
103
+ />
90
104
  );
91
105
  }
package/tooltip.js CHANGED
@@ -73,9 +73,9 @@ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@u
73
73
  import { useEventListener } from "@uniflowed/hooks/dom";
74
74
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
75
75
 
76
- import type { Align, Side } from "./internal/anchor.js";
76
+ import type { Align, LogicalSide } from "./internal/anchor.js";
77
77
  import type { DelayGroup, HoverIntent } from "./internal/hover-intent.js";
78
- import type { Rest } from "./internal/merge-props.js";
78
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
79
79
  import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
80
80
  import {
81
81
  DEFAULT_CLOSE_DELAY,
@@ -89,7 +89,7 @@ import {
89
89
  import { useAnchor } from "./internal/anchor.js";
90
90
  import { useControlled } from "./internal/controlled-state.js";
91
91
 
92
- export type { Align, Side } from "./internal/anchor.js";
92
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
93
93
 
94
94
  /** What a `Tooltip.Provider` shares with the tooltips inside it. */
95
95
  type TooltipScope = {|
@@ -126,7 +126,7 @@ type TooltipState = {|
126
126
  * cannot be closed. It is cleared when the reader leaves the trigger, so
127
127
  * coming back opens it again.
128
128
  */
129
- readonly dismissed: { current: boolean },
129
+ readonly dismissedRef: { current: boolean },
130
130
  |};
131
131
 
132
132
  const TooltipContext: React.Context<TooltipState | null> = createContext(null);
@@ -180,7 +180,7 @@ export component TooltipRoot(
180
180
  const scope = useContext(TooltipScopeContext);
181
181
  const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
182
182
  const triggerRef = useRef<HTMLElement | null>(null);
183
- const dismissed = useRef(false);
183
+ const dismissedRef = useRef(false);
184
184
  const intent = useHoverIntent(setOpen);
185
185
  const own = openDelay ?? scope?.delayDuration ?? DEFAULT_OPEN_DELAY;
186
186
  const group = scope?.group;
@@ -217,7 +217,7 @@ export component TooltipRoot(
217
217
  () => ({
218
218
  base,
219
219
  closeDelay,
220
- dismissed,
220
+ dismissedRef,
221
221
  intent,
222
222
  open: isOpen,
223
223
  openDelay: () => group?.delayFor(own) ?? own,
@@ -243,25 +243,21 @@ export component TooltipRoot(
243
243
  * `render` function that forgets to spread something still gets a working
244
244
  * tooltip; see the module header.
245
245
  */
246
- export component TooltipTrigger(
247
- children?: React.Node,
248
- render?: (props: Rest) => React.Node,
249
- ...rest: Rest
250
- ) {
246
+ export component TooltipTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
251
247
  const tooltip = useTooltip("Tooltip.Trigger");
252
- const { closeDelay, dismissed, intent, openDelay, setOpen, triggerRef } = tooltip;
248
+ const { closeDelay, dismissedRef, intent, openDelay, setOpen, triggerRef } = tooltip;
253
249
  useFocusableTrigger(triggerRef, "Tooltip.Trigger");
254
250
 
255
251
  // Whether the pointer put focus here. A click focuses the button, and
256
252
  // reopening the tooltip the click just dismissed would put it back over the
257
253
  // thing the reader pressed — so a focus that arrived with a press opens
258
254
  // nothing, and the next one, which is the keyboard's, does.
259
- const pressed = useRef(false);
255
+ const pressedRef = useRef(false);
260
256
 
261
257
  useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
262
258
  // A tap is not a hover. See the module header: opening here is what eats
263
259
  // the tap the control was there to receive.
264
- if (event.pointerType === "touch" || dismissed.current) {
260
+ if (event.pointerType === "touch" || dismissedRef.current) {
265
261
  return;
266
262
  }
267
263
  intent.openAfter(openDelay());
@@ -269,57 +265,50 @@ export component TooltipTrigger(
269
265
  useEventListener(triggerRef, "pointerleave", () => {
270
266
  // Leaving is what makes a dismissal stop applying: coming back is a fresh
271
267
  // gesture and deserves a fresh answer.
272
- dismissed.current = false;
268
+ dismissedRef.current = false;
273
269
  intent.closeAfter(closeDelay);
274
270
  });
275
271
  useEventListener(triggerRef, "pointerdown", () => {
276
- pressed.current = true;
272
+ pressedRef.current = true;
277
273
  intent.cancel();
278
274
  setOpen(false);
279
275
  });
280
276
  useEventListener(triggerRef, "focusin", () => {
281
- if (pressed.current) {
282
- pressed.current = false;
277
+ if (pressedRef.current) {
278
+ pressedRef.current = false;
283
279
  return;
284
280
  }
285
- if (dismissed.current) {
281
+ if (dismissedRef.current) {
286
282
  return;
287
283
  }
288
284
  // No delay: the reader has already said what they want by arriving here.
289
285
  intent.openAfter(0);
290
286
  });
291
287
  useEventListener(triggerRef, "focusout", () => {
292
- pressed.current = false;
293
- dismissed.current = false;
288
+ pressedRef.current = false;
289
+ dismissedRef.current = false;
294
290
  intent.closeAfter(0);
295
291
  });
296
292
 
297
293
  // Annotated because this one is not written inside a `ref={...}`, and there
298
294
  // is nothing else here for Flow to infer the element's type from.
295
+ // React calls callback refs during commit; pointer and focus handlers read it later.
296
+ // uf-lint-disable-next-line react-compiler/refs
299
297
  const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
300
298
  triggerRef.current = element;
301
299
  });
302
300
  // Only while it is there. `aria-describedby` pointing at an element that has
303
301
  // been removed is the dangling reference this package keeps coming back to.
304
- const ours = {
302
+ const props = withProps(withoutComposed(rest, ["ref"]), {
305
303
  "aria-describedby": tooltip.open ? `${tooltip.base}-body` : undefined,
304
+ children,
306
305
  ref: attach,
307
- };
306
+ });
308
307
 
309
308
  if (render != null) {
310
- return render(withProps(withoutComposed(rest, ["ref"]), ours));
309
+ return render(props);
311
310
  }
312
-
313
- return (
314
- <button
315
- {...withoutComposed(rest, ["ref"])}
316
- aria-describedby={ours["aria-describedby"]}
317
- ref={attach}
318
- type="button"
319
- >
320
- {children}
321
- </button>
322
- );
311
+ return <button {...props} type="button" />;
323
312
  }
324
313
 
325
314
  /**
@@ -337,19 +326,20 @@ export component TooltipBody(
337
326
  alignOffset?: number = 0,
338
327
  avoidCollisions?: boolean = true,
339
328
  collisionPadding?: number = 0,
340
- side?: Side = "top",
329
+ render?: RenderProp,
330
+ side?: LogicalSide = "top",
341
331
  sideOffset?: number = 0,
342
332
  ...rest: Rest
343
333
  ) {
344
334
  const tooltip = useTooltip("Tooltip.Body");
345
- const { closeDelay, intent, open, triggerRef } = tooltip;
335
+ const { closeDelay, dismissedRef, intent, open, triggerRef } = tooltip;
346
336
  const bodyRef = useRef<HTMLElement | null>(null);
347
337
  const close = useStableCallback(() => {
348
338
  // `Escape` dismisses it *and* keeps it dismissed while the reader is still
349
339
  // on the trigger. Without the flag the pointer that is still resting there
350
340
  // — or, for a hover card, the focus it hands back — reopens it at once,
351
341
  // and the key does nothing a reader can see.
352
- tooltip.dismissed.current = true;
342
+ dismissedRef.current = true;
353
343
  intent.cancel();
354
344
  tooltip.setOpen(false);
355
345
  });
@@ -391,21 +381,24 @@ export component TooltipBody(
391
381
  return null;
392
382
  }
393
383
 
394
- return (
395
- <div
396
- {...withoutComposed(rest, ["ref"])}
397
- data-align={anchored.align}
398
- data-side={anchored.side}
399
- data-state="open"
400
- id={`${tooltip.base}-body`}
401
- ref={composeRefs(rest.ref, (element) => {
402
- bodyRef.current = element;
403
- })}
404
- role="tooltip"
405
- // No `tabIndex`. A tooltip the keyboard can land in is a stop the reader
406
- // did not ask for and cannot leave the way they expect.
407
- >
408
- {children}
409
- </div>
410
- );
384
+ const props = withProps(withoutComposed(rest, ["ref"]), {
385
+ children,
386
+ "data-align": anchored.align,
387
+ "data-side": anchored.side,
388
+ "data-state": "open",
389
+ id: `${tooltip.base}-body`,
390
+ // React calls callback refs during commit; placement effects read it later.
391
+ // uf-lint-disable-next-line react-compiler/refs
392
+ ref: composeRefs(rest.ref, (element) => {
393
+ bodyRef.current = element;
394
+ }),
395
+ role: "tooltip",
396
+ // No `tabIndex`. A tooltip the keyboard can land in is a stop the reader
397
+ // did not ask for and cannot leave the way they expect.
398
+ });
399
+
400
+ if (render != null) {
401
+ return render(props);
402
+ }
403
+ return <div {...props} />;
411
404
  }
package/tree.js ADDED
@@ -0,0 +1,8 @@
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 Tree(...props: CollectionProps) {
7
+ return <CollectionRoot options={props} kind="tree" />;
8
+ }