@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/avatar.js ADDED
@@ -0,0 +1,280 @@
1
+ // @flow
2
+ //
3
+ // An avatar: three states, and an `alt` that is empty on purpose.
4
+ //
5
+ // This one looks like a rounded `<img>` with a `<span>` behind it, and it is a
6
+ // component because of two things that are not visible in a screenshot: what
7
+ // happens between the states, and what a screen reader says.
8
+ //
9
+ // # The three states, and the flash between two of them
10
+ //
11
+ // An image is *loading*, *loaded* or *failed*, and the naive version has two —
12
+ // there is an image or there is not — so it renders the fallback whenever the
13
+ // image has not painted yet. On a cached image that is a flash of somebody's
14
+ // initials for one frame, on every navigation, for ever. The fix is to hold the
15
+ // fallback back for a moment: an image that is going to appear immediately does
16
+ // so before the delay is up, and only an image that is genuinely slow or
17
+ // genuinely broken ever shows initials.
18
+ //
19
+ // # Asking the DOM, and the one direction it may be asked in
20
+ //
21
+ // The event is not enough on its own. A cached image can finish loading before
22
+ // React has attached `onLoad` — most obviously when the markup came from a
23
+ // server and the browser started the request while the JavaScript was still
24
+ // downloading — and a component that waits for an event that already happened
25
+ // waits for ever, which is the same flash held permanently.
26
+ //
27
+ // So the element is asked directly, in an effect, which is where this package
28
+ // is allowed to read the document (see `index.js`). And it is asked in *one*
29
+ // direction:
30
+ //
31
+ // * `complete` **and** `naturalWidth > 0` means it loaded. Pixels exist;
32
+ // nothing else produces them.
33
+ // * anything else means nothing. In particular `complete` with no pixels is
34
+ // *not* read as a failure, because that is also what a DOM that does not
35
+ // fetch images says about a perfectly good `src` — a test environment, a
36
+ // server-side render, a browser with images turned off — and concluding
37
+ // "failed" from it would put a fallback over an image that was never asked
38
+ // for. The `error` event is what says a load failed, and an image that
39
+ // failed before anything was listening falls through to the delay and shows
40
+ // the fallback a moment later, which is the same answer arriving late
41
+ // rather than the wrong answer arriving early.
42
+ //
43
+ // # `alt=""` is the default, and it is the accessible answer
44
+ //
45
+ // An avatar almost always sits beside the name of the person it is a picture
46
+ // of. Putting that name in `alt` makes every screen reader say it twice —
47
+ // "Ada Lovelace, image, Ada Lovelace" — which is the most common avatar bug
48
+ // there is, and it is caused by a component being helpful. An empty `alt` takes
49
+ // the image out of the accessibility tree, which is what "decorative" means and
50
+ // what this one is.
51
+ //
52
+ // A caller whose avatar is the *only* thing identifying the person — a bare
53
+ // grid of faces, a comment with no byline — passes `alt` and gets it. The
54
+ // default is the common case; the prop is the honest one.
55
+ //
56
+ // The fallback's content is the caller's and is announced, because this
57
+ // component cannot know whether "AL" is a decoration beside a name or the only
58
+ // thing on the row. A decorative avatar wants `aria-hidden` on its fallback for
59
+ // the same reason its image wants `alt=""`, and that is one prop the caller
60
+ // spreads.
61
+
62
+ "use client";
63
+
64
+ import * as React from "@uniflowed/react";
65
+ import {
66
+ createContext,
67
+ useCallback,
68
+ useContext,
69
+ useEffect,
70
+ useMemo,
71
+ useRef,
72
+ useState,
73
+ } from "@uniflowed/react";
74
+ import { useTimeout } from "@uniflowed/hooks/timing";
75
+
76
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
77
+ import {
78
+ composeHandlers,
79
+ composeRefs,
80
+ withProps,
81
+ withoutComposed,
82
+ } from "./internal/merge-props.js";
83
+
84
+ /**
85
+ * Where an avatar's image is between having been asked for and being there.
86
+ *
87
+ * Three members rather than a `loaded` boolean, because the fallback's whole
88
+ * job is to tell the middle one from the last one: an image that has not
89
+ * arrived *yet* must not be replaced, and one that is never arriving must.
90
+ */
91
+ export type AvatarStatus = "loading" | "loaded" | "error";
92
+
93
+ /** What has been decided, and about which source. */
94
+ type Seen = {| readonly source: string | null, readonly status: AvatarStatus |};
95
+
96
+ /** Nothing asked yet. A source of `null` with no verdict cannot collide. */
97
+ const START: Seen = Object.freeze({ source: null, status: "loading" });
98
+
99
+ type AvatarState = {|
100
+ readonly status: AvatarStatus,
101
+ readonly hasImage: boolean,
102
+ readonly report: (source: string | null, status: AvatarStatus) => void,
103
+ readonly registerImage: (present: boolean) => void,
104
+ |};
105
+
106
+ const AvatarContext: React.Context<AvatarState | null> = createContext(null);
107
+
108
+ /**
109
+ * The avatar a part belongs to.
110
+ *
111
+ * Raising rather than returning null, for `field.js`'s reason: an
112
+ * `Avatar.Fallback` outside a root would render initials that never go away and
113
+ * would look exactly like one that works.
114
+ */
115
+ hook useAvatar(part: string): AvatarState {
116
+ const state = useContext(AvatarContext);
117
+ if (state == null) {
118
+ throw new Error(`${part} must be rendered inside an Avatar.Root`);
119
+ }
120
+ return state;
121
+ }
122
+
123
+ /**
124
+ * The avatar, and the state machine its two parts read.
125
+ *
126
+ * A `<span>` rather than a `<div>`, because an avatar belongs beside a name —
127
+ * in a table cell, in a paragraph, inside a button's label — and a block
128
+ * element is invalid in half of those.
129
+ */
130
+ export component AvatarRoot(children: React.Node, render?: RenderProp, ...rest: Rest) {
131
+ const [seen, setSeen] = useState<Seen>(START);
132
+ const [hasImage, setHasImage] = useState(false);
133
+
134
+ const report = useCallback((source: string | null, status: AvatarStatus) => {
135
+ setSeen((current) => {
136
+ if (current.source !== source) {
137
+ return { source, status };
138
+ }
139
+ // A verdict already reached about this source is not revisited. Without
140
+ // this the failed image would be put back to ask again, fail again, and
141
+ // be put back again: `Avatar.Image` stops rendering the element once it
142
+ // has failed, so "loading" for the same source is a loop and not a retry.
143
+ if (status === "loading" || current.status === status) {
144
+ return current;
145
+ }
146
+ return { source, status };
147
+ });
148
+ }, []);
149
+
150
+ const state = useMemo(
151
+ () => ({
152
+ status: seen.status,
153
+ hasImage,
154
+ report,
155
+ registerImage: setHasImage,
156
+ }),
157
+ [seen, hasImage, report],
158
+ );
159
+ const props = withProps(rest, { children });
160
+
161
+ return (
162
+ <AvatarContext.Provider value={state}>
163
+ {render != null ? render(props) : <span {...props} />}
164
+ </AvatarContext.Provider>
165
+ );
166
+ }
167
+
168
+ /**
169
+ * The picture.
170
+ *
171
+ * `src` is named rather than left in `rest` because the state machine is about
172
+ * it: a new source is a new question, and the verdict reached about the last
173
+ * one has to stop applying the moment it changes.
174
+ *
175
+ * `alt` defaults to `""`, which is the decision this component exists for as
176
+ * much as the three states are — see the module header.
177
+ *
178
+ * The element stops being rendered once it has failed, rather than being left
179
+ * to show the browser's broken-image glyph beside the fallback that replaced
180
+ * it. That is a thing a package which ships no styles cannot leave to a
181
+ * stylesheet: `hidden` loses to any `display` the caller sets, and a caller who
182
+ * has not written that rule yet would see both.
183
+ */
184
+ export component AvatarImage(
185
+ alt?: string = "",
186
+ src?: string | null,
187
+ render?: RenderProp,
188
+ ...rest: Rest
189
+ ) {
190
+ const avatar = useAvatar("Avatar.Image");
191
+ const element = useRef<HTMLImageElement | null>(null);
192
+ const report = avatar.report;
193
+ const registerImage = avatar.registerImage;
194
+ const source = src ?? null;
195
+ const passed = withoutComposed(rest, ["onError", "onLoad", "ref"]);
196
+
197
+ useEffect(() => {
198
+ registerImage(true);
199
+ return () => registerImage(false);
200
+ }, [registerImage]);
201
+
202
+ useEffect(() => {
203
+ if (source == null || source === "") {
204
+ // No source is not a slow source. There is nothing coming, so the
205
+ // fallback is the answer now rather than after the delay.
206
+ report(source, "error");
207
+ return;
208
+ }
209
+ const image = element.current;
210
+ // Null only when this source has already failed and the element went with
211
+ // it, in which case the verdict on record is the right one.
212
+ if (image != null && image.complete && image.naturalWidth > 0) {
213
+ report(source, "loaded");
214
+ return;
215
+ }
216
+ report(source, "loading");
217
+ }, [report, source]);
218
+
219
+ if (avatar.status === "error") {
220
+ return null;
221
+ }
222
+ const props = withProps(passed, {
223
+ alt,
224
+ onError: composeHandlers(rest.onError, () => report(source, "error")),
225
+ onLoad: composeHandlers(rest.onLoad, () => report(source, "loaded")),
226
+ // React calls callback refs during commit; the load effect reads it later.
227
+ // uf-lint-disable-next-line react-compiler/refs
228
+ ref: composeRefs(rest.ref, (node: HTMLImageElement | null) => {
229
+ element.current = node;
230
+ }),
231
+ src: source ?? undefined,
232
+ });
233
+ if (render != null) {
234
+ return render(props);
235
+ }
236
+ return <img {...props} />;
237
+ }
238
+
239
+ /**
240
+ * What is shown instead: initials, a silhouette, a coloured disc.
241
+ *
242
+ * Absent while the image is still loading, which is the point. `delay` is how
243
+ * long "still loading" is allowed to last before the fallback appears anyway —
244
+ * long enough that a cached image never flashes initials, short enough that a
245
+ * genuinely slow one does not leave a hole. Pass `0` to show it the moment
246
+ * there is nothing to show instead.
247
+ *
248
+ * The delay applies to *loading* and to nothing else. A failed image and an
249
+ * avatar with no `Avatar.Image` at all are both answers rather than waits, and
250
+ * the fallback for either is immediate.
251
+ */
252
+ export component AvatarFallback(
253
+ children: React.Node,
254
+ delay?: number = 300,
255
+ render?: RenderProp,
256
+ ...rest: Rest
257
+ ) {
258
+ const avatar = useAvatar("Avatar.Fallback");
259
+ const [elapsed, setElapsed] = useState(false);
260
+ const waiting = avatar.hasImage && avatar.status === "loading";
261
+
262
+ useTimeout(() => setElapsed(true), waiting && delay > 0 ? delay : null);
263
+
264
+ useEffect(() => {
265
+ if (!waiting) {
266
+ // The fallback delay resets after the image leaves its loading window.
267
+ // uf-lint-disable-next-line react-compiler/set-state-in-effect
268
+ setElapsed(false);
269
+ }
270
+ }, [waiting]);
271
+
272
+ if (avatar.status === "loaded" || (waiting && delay > 0 && !elapsed)) {
273
+ return null;
274
+ }
275
+ const props = withProps(rest, { children });
276
+ if (render != null) {
277
+ return render(props);
278
+ }
279
+ return <span {...props} />;
280
+ }
package/breadcrumb.js ADDED
@@ -0,0 +1,138 @@
1
+ // @flow
2
+ //
3
+ // A breadcrumb trail, read as a trail rather than as punctuation.
4
+ //
5
+ // It is `pagination.js`'s shape — a named `<nav>` around a list, with one item
6
+ // marked `aria-current="page"` — and it is a separate module for the same
7
+ // reason those two are separate entries: a paginated table and a trail through
8
+ // a hierarchy are different things to a reader, and the parts are named after
9
+ // what they mean rather than after what they render.
10
+ //
11
+ // Three decisions, each of which is invisible when it is missing:
12
+ //
13
+ // * **It is navigation, so it is a `<nav>` with a name.** A page has more
14
+ // than one `nav` and an unnamed one is announced as "navigation", with
15
+ // nothing to tell it from the site's menu. `aria-label="Breadcrumb"` is
16
+ // what puts it in a screen reader's landmark list under a useful name, and
17
+ // it is the name assistive technology's own documentation tells readers to
18
+ // look for.
19
+ // * **The last item is `aria-current="page"`, and it is not a link.** It is
20
+ // where the reader already is. A trail whose last entry is a link that
21
+ // leads to the page it is on is a link that does nothing, and announcing
22
+ // "link" for it is a promise the page does not keep — so `Breadcrumb.Page`
23
+ // is a `<span>`. The `role="link"` with `aria-disabled` that this component
24
+ // is usually copied with says "link, dimmed", which is a *control the
25
+ // reader cannot use* rather than a place they have arrived at.
26
+ // * **The separators are `aria-hidden`.** Otherwise the trail is read as
27
+ // "Home slash Settings slash Billing", and the slashes are the loudest
28
+ // thing in it. They are `<li>` elements because an `<ol>` may only contain
29
+ // `<li>`, and they carry `role="presentation"` as well so that the count a
30
+ // reader is given — "list, three items" — is the number of places and not
31
+ // the number of places plus the punctuation between them.
32
+ //
33
+ // # What the type says that the markup cannot
34
+ //
35
+ // `Breadcrumb.List` declares `renders* (Breadcrumb.Item | Breadcrumb.Separator)`,
36
+ // so a `<div>` between two crumbs is a type error rather than an `<ol>` a
37
+ // validator would reject and a screen reader would count wrong.
38
+ // `Pagination.Content` states the same constraint for the same element and the
39
+ // same reason.
40
+ //
41
+ // # No `"use client"`
42
+ //
43
+ // Nothing here holds state, listens to anything or moves focus. Which crumb is
44
+ // current is the caller's, and the links are links. It renders on a server.
45
+
46
+ import * as React from "@uniflowed/react";
47
+
48
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
49
+ import { withProps } from "./internal/merge-props.js";
50
+
51
+ /**
52
+ * The trail, as a named landmark.
53
+ *
54
+ * `label` is the accessible name and has a default because there is one right
55
+ * answer in English and it is the one readers are taught to look for. Pass it
56
+ * to translate; there is no case for leaving it off, which is why it is not
57
+ * optional in the sense of being absent.
58
+ */
59
+ export component BreadcrumbRoot(
60
+ children: React.Node,
61
+ label?: string = "Breadcrumb",
62
+ render?: RenderProp,
63
+ ...rest: Rest
64
+ ) {
65
+ const props = withProps(rest, { "aria-label": label, children });
66
+ if (render != null) {
67
+ return render(withProps(props, { role: "navigation" }));
68
+ }
69
+ return <nav {...props} />;
70
+ }
71
+
72
+ /**
73
+ * The crumbs, in order.
74
+ *
75
+ * An ordered list rather than a row of links, because the order is the whole
76
+ * information: a reader is told how many levels there are before walking them,
77
+ * and can skip the lot in one keystroke.
78
+ */
79
+ export component BreadcrumbList(
80
+ children: renders* (BreadcrumbItem | BreadcrumbSeparator),
81
+ render?: RenderProp,
82
+ ...rest: Rest
83
+ ) {
84
+ const props = withProps(rest, { children });
85
+ if (render != null) {
86
+ return render(withProps(props, { role: "list" }));
87
+ }
88
+ return <ol {...props} />;
89
+ }
90
+
91
+ /** One level of the trail. Holds a `Breadcrumb.Link` or a `Breadcrumb.Page`. */
92
+ export component BreadcrumbItem(children: React.Node, render?: RenderProp, ...rest: Rest) {
93
+ const props = withProps(rest, { children });
94
+ if (render != null) {
95
+ return render(withProps(props, { role: "listitem" }));
96
+ }
97
+ return <li {...props} />;
98
+ }
99
+
100
+ /** A level you can go back to. */
101
+ export component BreadcrumbLink(children: React.Node, render?: RenderProp, ...rest: Rest) {
102
+ const props = withProps(rest, { children });
103
+ if (render != null) {
104
+ return render(withProps(props, { role: "link" }));
105
+ }
106
+ return <a {...props} />;
107
+ }
108
+
109
+ /**
110
+ * The level you are on.
111
+ *
112
+ * `aria-current="page"` is the whole of it, and it is on this part rather than
113
+ * being a `current` prop on `Breadcrumb.Link` so that the last crumb cannot be
114
+ * a link by accident. See the module header for why announcing it as a disabled
115
+ * link is worse than announcing it as text.
116
+ */
117
+ export component BreadcrumbPage(children: React.Node, render?: RenderProp, ...rest: Rest) {
118
+ const props = withProps(rest, { "aria-current": "page", children });
119
+ if (render != null) {
120
+ return render(props);
121
+ }
122
+ return <span {...props} />;
123
+ }
124
+
125
+ /**
126
+ * The mark between two crumbs.
127
+ *
128
+ * The glyph is the caller's — a slash, a chevron, an icon — because it is a
129
+ * design decision and this package makes none. What is not the caller's is that
130
+ * it is announced to nobody.
131
+ */
132
+ export component BreadcrumbSeparator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
133
+ const props = withProps(rest, { "aria-hidden": "true", children, role: "presentation" });
134
+ if (render != null) {
135
+ return render(props);
136
+ }
137
+ return <li {...props} />;
138
+ }