@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/collapsible.js CHANGED
@@ -23,25 +23,43 @@
23
23
  // `internal/disclosure.js` explains what is done instead, and why React needs a
24
24
  // hook to say it.
25
25
  //
26
- // # No height, yet
26
+ // # The height, for a stylesheet that animates it
27
27
  //
28
- // A collapsible that animates open needs the height its content *would* have,
29
- // which a stylesheet cannot compute — the obvious `useElementSize` from
30
- // `@uniflowed/hooks/dom` measures the element while it is hidden and reports
31
- // zero, which is exactly the moment the number is wanted. Getting it right
32
- // means a measuring pass with the panel briefly laid out and not painted, and
33
- // that is a piece of work of its own rather than a line to be added here — #330.
34
- // This component ships without it rather than with a custom property that reads
35
- // `0px`.
28
+ // `measure` puts the height the content *would* have on
29
+ // `Collapsible.Content` as `--uf-collapsible-height`, correct while the panel
30
+ // is still closed — which is the only moment it is any use, because
31
+ // `height: 0 → var(--uf-collapsible-height)` is a transition that has to know
32
+ // its destination before it starts. `internal/disclosure.js` holds the
33
+ // measuring pass and says why the obvious ways of asking all answer zero, and
34
+ // why the prop is opt-in rather than always on.
35
+ //
36
+ // [data-collapsible-content] {
37
+ // overflow: hidden;
38
+ // transition: height 150ms;
39
+ // height: 0;
40
+ // }
41
+ // [data-collapsible-content]:not([hidden]) {
42
+ // height: var(--uf-collapsible-height);
43
+ // }
44
+ //
45
+ // The selector is the caller's — a class, a `data-*` of their own, whatever
46
+ // they already style with. This package emits the number and no styles at all,
47
+ // which is the same division `internal/anchor.js` keeps for a popover: a
48
+ // stylesheet can say *how* to move, and only the component can say how far.
36
49
 
37
50
  "use client";
38
51
 
39
52
  import * as React from "@uniflowed/react";
40
53
  import { createContext, useContext, useId, useMemo, useRef, useState } from "@uniflowed/react";
41
54
 
42
- import type { Rest } from "./internal/merge-props.js";
43
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
44
- import { usePresence, useUntilFound } from "./internal/disclosure.js";
55
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
56
+ import {
57
+ composeHandlers,
58
+ composeRefs,
59
+ withProps,
60
+ withoutComposed,
61
+ } from "./internal/merge-props.js";
62
+ import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
45
63
  import { useControlled } from "./internal/controlled-state.js";
46
64
 
47
65
  type CollapsibleState = {|
@@ -51,6 +69,8 @@ type CollapsibleState = {|
51
69
  /** Whether a `Collapsible.Content` is rendered, so the trigger names one that exists. */
52
70
  readonly present: boolean,
53
71
  readonly registerContent: (present: boolean) => void,
72
+ /** Whether the content carries its measured height; see the module header. */
73
+ readonly measure: boolean,
54
74
  |};
55
75
 
56
76
  const CollapsibleContext: React.Context<CollapsibleState | null> = createContext(null);
@@ -76,47 +96,47 @@ export component CollapsibleRoot(
76
96
  defaultOpen?: boolean = false,
77
97
  open?: boolean,
78
98
  onOpenChange?: (open: boolean) => void,
99
+ measure?: boolean = false,
79
100
  ) {
80
101
  const contentId = `${useId()}-content`;
81
102
  const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
82
103
  const [present, setPresent] = useState(false);
83
104
 
84
105
  const state = useMemo(
85
- () => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent }),
86
- [contentId, isOpen, setOpen, present],
106
+ () => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent, measure }),
107
+ [contentId, isOpen, setOpen, present, measure],
87
108
  );
88
109
 
89
110
  return <CollapsibleContext.Provider value={state}>{children}</CollapsibleContext.Provider>;
90
111
  }
91
112
 
92
- /** The button that shows and hides the content. */
113
+ /** The control that shows and hides the content. */
93
114
  export component CollapsibleTrigger(
94
115
  children: React.Node,
95
116
  disabled?: boolean = false,
117
+ render?: RenderProp,
96
118
  ...rest: Rest
97
119
  ) {
98
120
  const collapsible = useCollapsible("Collapsible.Trigger");
99
- const passed = withoutComposed(rest, ["onClick"]);
121
+ const props = withProps(withoutComposed(rest, ["onClick"]), {
122
+ // Named only while the content is in the document. A caller who renders
123
+ // the content conditionally — or not at all until data arrives — would
124
+ // otherwise have this trigger pointing at nothing.
125
+ "aria-controls": collapsible.present ? collapsible.contentId : undefined,
126
+ "aria-expanded": collapsible.open ? "true" : "false",
127
+ children,
128
+ disabled,
129
+ onClick: composeHandlers(rest.onClick, () => {
130
+ if (!disabled) {
131
+ collapsible.setOpen(!collapsible.open);
132
+ }
133
+ }),
134
+ });
100
135
 
101
- return (
102
- <button
103
- {...passed}
104
- // Named only while the content is in the document. A caller who renders
105
- // the content conditionally — or not at all until data arrives — would
106
- // otherwise have this button pointing at nothing.
107
- aria-controls={collapsible.present ? collapsible.contentId : undefined}
108
- aria-expanded={collapsible.open ? "true" : "false"}
109
- disabled={disabled}
110
- onClick={composeHandlers(rest.onClick, () => {
111
- if (!disabled) {
112
- collapsible.setOpen(!collapsible.open);
113
- }
114
- })}
115
- type="button"
116
- >
117
- {children}
118
- </button>
119
- );
136
+ if (render != null) {
137
+ return render(props);
138
+ }
139
+ return <button {...props} type="button" />;
120
140
  }
121
141
 
122
142
  /**
@@ -126,22 +146,26 @@ export component CollapsibleTrigger(
126
146
  * the module header, and `internal/disclosure.js` for what `hidden` is upgraded
127
147
  * to and why that takes an effect.
128
148
  */
129
- export component CollapsibleContent(children: React.Node, ...rest: Rest) {
149
+ export component CollapsibleContent(children: React.Node, render?: RenderProp, ...rest: Rest) {
130
150
  const collapsible = useCollapsible("Collapsible.Content");
131
151
  const contentRef = useRef<HTMLElement | null>(null);
132
152
  usePresence(collapsible.registerContent);
133
153
  useUntilFound(contentRef, collapsible.open);
154
+ useMeasuredHeight(contentRef, collapsible.measure);
134
155
 
135
- return (
136
- <div
137
- {...withoutComposed(rest, ["ref"])}
138
- hidden={!collapsible.open}
139
- id={collapsible.contentId}
140
- ref={composeRefs(rest.ref, (element) => {
141
- contentRef.current = element;
142
- })}
143
- >
144
- {children}
145
- </div>
146
- );
156
+ const props = withProps(withoutComposed(rest, ["ref"]), {
157
+ children,
158
+ hidden: !collapsible.open,
159
+ id: collapsible.contentId,
160
+ // React calls callback refs during commit; this node is only read by effects.
161
+ // uf-lint-disable-next-line react-compiler/refs
162
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
163
+ contentRef.current = element;
164
+ }),
165
+ });
166
+
167
+ if (render != null) {
168
+ return render(props);
169
+ }
170
+ return <div {...props} />;
147
171
  }
@@ -0,0 +1,172 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { createContext, useContext, useState } from "@uniflowed/react";
5
+ import { useControlled } from "./internal/controlled-state.js";
6
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
7
+ import { composeHandlers, withProps } from "./internal/merge-props.js";
8
+ import { directionOf } from "./internal/roving-focus.js";
9
+
10
+ /** Canonical #rrggbb or #rrggbbaa, with CSS's short hex forms accepted at the boundary. */
11
+ export function parseColor(value: string): string | null {
12
+ let hex = value.trim().toLowerCase();
13
+ if (!/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(hex)) return null;
14
+ if (hex.length <= 5)
15
+ hex =
16
+ "#" +
17
+ Array.from(hex.slice(1))
18
+ .map((char) => char + char)
19
+ .join("");
20
+ return hex;
21
+ }
22
+ type ColorState = { value: string, disabled: boolean, set: (value: string) => void };
23
+ const ColorContext: React.Context<ColorState | null> = createContext(null);
24
+ hook useColor(): ColorState {
25
+ const color = useContext(ColorContext);
26
+ if (color == null) throw new Error("ColorPicker parts must be inside ColorPicker.Root");
27
+ return color;
28
+ }
29
+ export component ColorPickerRoot(
30
+ children: React.Node,
31
+ value?: string,
32
+ defaultValue?: string = "#000000",
33
+ onValueChange?: (value: string) => void,
34
+ disabled?: boolean = false,
35
+ render?: RenderProp,
36
+ ...rest: Rest
37
+ ) {
38
+ const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
39
+ const normalized = parseColor(current);
40
+ if (normalized == null) throw new RangeError("ColorPicker value must be a CSS hex color");
41
+ const state = {
42
+ value: normalized,
43
+ disabled,
44
+ set: (next: string) => {
45
+ const color = parseColor(next);
46
+ if (!disabled && color != null) setCurrent(color);
47
+ },
48
+ };
49
+ const props = withProps(rest, { children });
50
+ return (
51
+ <ColorContext.Provider value={state}>
52
+ {render != null ? render(props) : <div {...props} />}
53
+ </ColorContext.Provider>
54
+ );
55
+ }
56
+ export component ColorPickerInput(render?: RenderProp, ...rest: Rest) {
57
+ const color = useColor();
58
+ const props = withProps(rest, {
59
+ type: "color",
60
+ value: color.value.slice(0, 7),
61
+ disabled: color.disabled,
62
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
63
+ color.set(event.currentTarget.value + color.value.slice(7)),
64
+ ),
65
+ });
66
+ return render != null ? render(props) : <input {...props} />;
67
+ }
68
+ export component ColorPickerField(render?: RenderProp, ...rest: Rest) {
69
+ const color = useColor();
70
+ const [draft, setDraft] = useState<string | null>(null);
71
+ const text = draft ?? color.value;
72
+ const invalid = parseColor(text) == null;
73
+ const commit = () => {
74
+ if (!invalid) {
75
+ color.set(text);
76
+ setDraft(null);
77
+ }
78
+ };
79
+ const props = withProps(rest, {
80
+ type: "text",
81
+ value: text,
82
+ disabled: color.disabled,
83
+ "aria-invalid": invalid ? "true" : rest["aria-invalid"],
84
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
85
+ setDraft(event.currentTarget.value),
86
+ ),
87
+ onBlur: composeHandlers(rest.onBlur, commit),
88
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
89
+ if (event.key === "Enter") {
90
+ event.preventDefault();
91
+ commit();
92
+ }
93
+ }),
94
+ });
95
+ return render != null ? render(props) : <input {...props} />;
96
+ }
97
+ export component ColorPickerChannel(
98
+ channel: "red" | "green" | "blue" | "alpha",
99
+ render?: RenderProp,
100
+ ...rest: Rest
101
+ ) {
102
+ const color = useColor();
103
+ const at = ["red", "green", "blue", "alpha"].indexOf(channel);
104
+ const value = parseInt(
105
+ (color.value + (color.value.length === 7 ? "ff" : "")).slice(1 + at * 2, 3 + at * 2),
106
+ 16,
107
+ );
108
+ const change = (next: number) => {
109
+ const hex = color.value.slice(1).padEnd(8, "f");
110
+ const byte = Math.max(0, Math.min(255, Math.round(next)))
111
+ .toString(16)
112
+ .padStart(2, "0");
113
+ const full = hex.slice(0, at * 2) + byte + hex.slice(at * 2 + 2);
114
+ color.set("#" + (at === 3 || color.value.length === 9 ? full : full.slice(0, 6)));
115
+ };
116
+ const props = withProps(rest, {
117
+ type: "range",
118
+ min: 0,
119
+ max: 255,
120
+ step: 1,
121
+ value,
122
+ disabled: color.disabled,
123
+ "aria-label": rest["aria-label"] ?? channel,
124
+ "aria-valuetext": channel === "alpha" ? `${Math.round((value / 255) * 100)}%` : String(value),
125
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
126
+ change(Number(event.currentTarget.value)),
127
+ ),
128
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
129
+ if (color.disabled) return;
130
+ const key = event.key;
131
+ if (
132
+ ![
133
+ "ArrowLeft",
134
+ "ArrowRight",
135
+ "ArrowUp",
136
+ "ArrowDown",
137
+ "PageUp",
138
+ "PageDown",
139
+ "Home",
140
+ "End",
141
+ ].includes(key)
142
+ )
143
+ return;
144
+ event.preventDefault();
145
+ const rtl = directionOf(event.currentTarget) === "rtl";
146
+ change(
147
+ key === "Home"
148
+ ? 0
149
+ : key === "End"
150
+ ? 255
151
+ : value +
152
+ (key === "PageUp"
153
+ ? 10
154
+ : key === "PageDown"
155
+ ? -10
156
+ : key === "ArrowUp" || key === (rtl ? "ArrowLeft" : "ArrowRight")
157
+ ? 1
158
+ : -1),
159
+ );
160
+ }),
161
+ });
162
+ return render != null ? render(props) : <input {...props} />;
163
+ }
164
+ export component ColorPickerSwatch(render?: RenderProp, ...rest: Rest) {
165
+ const color = useColor();
166
+ const props = withProps(rest, {
167
+ "aria-hidden": "true",
168
+ "data-color": color.value,
169
+ style: { ...(rest.style as $FlowFixMe), backgroundColor: color.value },
170
+ });
171
+ return render != null ? render(props) : <span {...props} />;
172
+ }