@uniflowed/ui 0.0.0-alpha.10

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.
@@ -0,0 +1,251 @@
1
+ // @flow
2
+ //
3
+ // Site navigation, which is not a menu.
4
+ //
5
+ // This component exists mostly to prevent one mistake, and the mistake is
6
+ // reaching for `role="menu"` because the thing is called a navigation menu.
7
+ // `menu`, `menubar` and `menuitem` are for *application commands* — Cut, Paste,
8
+ // Export as PNG — and using them for a site's navigation costs a reader three
9
+ // things at once:
10
+ //
11
+ // * A screen reader announces "menu, five items" where the reader expected a
12
+ // list of links, and a list of links is a thing they know how to read.
13
+ // * The whole menu keyboard map comes with the role, and a reader who knows
14
+ // it will use it: `Tab` should leave the set in one press, arrow keys
15
+ // should move between items, typing a letter should jump. Claiming the role
16
+ // and not implementing the map is worse than not claiming it.
17
+ // * `menuitem` is not a link. It is not announced as one, it is not in the
18
+ // list of links a reader can pull up, and "open in a new tab" is not
19
+ // obviously available on it.
20
+ //
21
+ // The WAI-ARIA practices have a pattern for exactly this and it is the
22
+ // *Disclosure Navigation Menu*: a `<nav>` containing a list, where an
23
+ // expandable entry is a `button` with `aria-expanded` controlling a list of
24
+ // ordinary links. Radix's NavigationMenu makes the same choice. So: `Tab` walks
25
+ // the links, because they are links; `Escape` closes the open group and gives
26
+ // focus back to the button that opened it; choosing a link closes the group,
27
+ // because the reader is leaving.
28
+ //
29
+ // # One group open at a time
30
+ //
31
+ // Opening one closes the others, which is what the practices' example does and
32
+ // what a site's navigation looks like everywhere. It is also why the root holds
33
+ // which entry is open rather than each entry holding its own state: "the open
34
+ // one" is a fact about the set.
35
+ //
36
+ // # The closed group is not rendered
37
+ //
38
+ // `accordion.js` keeps its closed panels in the document with
39
+ // `hidden="until-found"` so that find-in-page can reach the text in them. This
40
+ // deliberately does not, and the difference is what the content is *for*. An
41
+ // accordion panel is prose a reader might be searching. A closed navigation
42
+ // group is a list of destinations, and having Ctrl+F unfold the site's
43
+ // navigation on the way to a word in the article would be a surprise with
44
+ // nothing to show for it.
45
+ //
46
+ // # Name the landmark
47
+ //
48
+ // `<nav>` is a landmark, and a page with two unnamed ones gives a reader a
49
+ // choice between "navigation" and "navigation". Pass `aria-label`.
50
+
51
+ "use client";
52
+
53
+ import * as React from "@uniflowed/react";
54
+ import { createContext, useContext, useId, useMemo, useState } from "@uniflowed/react";
55
+
56
+ import type { Rest } from "./internal/merge-props.js";
57
+ import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
58
+ import { usePresence } from "./internal/disclosure.js";
59
+ import { useControlled } from "./internal/controlled-state.js";
60
+
61
+ type NavigationMenuState = {|
62
+ /** The entry whose group is open, or null for none. */
63
+ readonly open: string | null,
64
+ readonly setOpen: (value: string | null) => void,
65
+ |};
66
+
67
+ const NavigationMenuContext: React.Context<NavigationMenuState | null> = createContext(null);
68
+
69
+ type NavigationMenuItemState = {|
70
+ readonly triggerId: string,
71
+ readonly bodyId: string,
72
+ readonly expanded: boolean,
73
+ readonly toggle: () => void,
74
+ readonly close: () => void,
75
+ /** Whether a `NavigationMenu.Body` is rendered, so the trigger names one that exists. */
76
+ readonly present: boolean,
77
+ readonly registerBody: (present: boolean) => void,
78
+ |};
79
+
80
+ const NavigationMenuItemContext: React.Context<NavigationMenuItemState | null> =
81
+ createContext(null);
82
+
83
+ hook useNavigationMenu(part: string): NavigationMenuState {
84
+ const state = useContext(NavigationMenuContext);
85
+ if (state == null) {
86
+ throw new Error(`${part} must be rendered inside a NavigationMenu.Root`);
87
+ }
88
+ return state;
89
+ }
90
+
91
+ hook useNavigationMenuItem(part: string): NavigationMenuItemState {
92
+ const state = useContext(NavigationMenuItemContext);
93
+ if (state == null) {
94
+ throw new Error(`${part} must be rendered inside a NavigationMenu.Item`);
95
+ }
96
+ return state;
97
+ }
98
+
99
+ /**
100
+ * The landmark, and the one place `Escape` is handled.
101
+ *
102
+ * `Escape` is here rather than on each group because the reader may be anywhere
103
+ * inside the open one when they press it, and because the button to give focus
104
+ * back to is found the same way everything else in this package finds things:
105
+ * by asking the document at the moment of the press. The alternative — every
106
+ * trigger writing itself into a ref — is a registry that has to be kept in step
107
+ * with a document that already knows the answer.
108
+ */
109
+ export component NavigationMenuRoot(
110
+ children: renders* NavigationMenuList,
111
+ defaultValue?: string | null = null,
112
+ value?: string | null,
113
+ onValueChange?: (value: string | null) => void,
114
+ ...rest: Rest
115
+ ) {
116
+ const [open, setOpen] = useControlled<string | null>(value, defaultValue, onValueChange);
117
+ const state = useMemo(() => ({ open, setOpen }), [open, setOpen]);
118
+ const passed = withoutComposed(rest, ["onKeyDown"]);
119
+
120
+ return (
121
+ <NavigationMenuContext.Provider value={state}>
122
+ <nav
123
+ {...passed}
124
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
125
+ if (event.key !== "Escape" || open == null) {
126
+ return;
127
+ }
128
+ event.preventDefault();
129
+ // This navigation menu, not a dialog around it.
130
+ event.stopPropagation();
131
+ const nav: $FlowFixMe = event.currentTarget;
132
+ const trigger = nav.querySelector('[aria-expanded="true"]');
133
+ setOpen(null);
134
+ // After closing, and synchronously: the trigger is not the element
135
+ // being removed — the group inside it is — so it is still there to
136
+ // take focus, and leaving focus on a `<li>` that no longer contains
137
+ // anything focusable drops the reader at the top of the page.
138
+ trigger?.focus?.();
139
+ })}
140
+ >
141
+ {children}
142
+ </nav>
143
+ </NavigationMenuContext.Provider>
144
+ );
145
+ }
146
+
147
+ /** The list of entries. A `<ul>`, because a reader is told how many there are. */
148
+ export component NavigationMenuList(children: renders* NavigationMenuItem, ...rest: Rest) {
149
+ useNavigationMenu("NavigationMenu.List");
150
+ return <ul {...rest}>{children}</ul>;
151
+ }
152
+
153
+ /** One entry: a link on its own, or a button and the group it opens. */
154
+ export component NavigationMenuItem(value: string, children: React.Node, ...rest: Rest) {
155
+ const menu = useNavigationMenu("NavigationMenu.Item");
156
+ const base = useId();
157
+ const [present, setPresent] = useState(false);
158
+ const setOpen = menu.setOpen;
159
+ const expanded = menu.open === value;
160
+
161
+ const state = useMemo(
162
+ () => ({
163
+ triggerId: `${base}-trigger`,
164
+ bodyId: `${base}-body`,
165
+ expanded,
166
+ toggle: () => setOpen(expanded ? null : value),
167
+ close: () => setOpen(null),
168
+ present,
169
+ registerBody: setPresent,
170
+ }),
171
+ [base, expanded, setOpen, value, present],
172
+ );
173
+
174
+ return (
175
+ <NavigationMenuItemContext.Provider value={state}>
176
+ <li {...rest}>{children}</li>
177
+ </NavigationMenuItemContext.Provider>
178
+ );
179
+ }
180
+
181
+ /** The button that opens an entry's group. Not a `menuitem`; see the header. */
182
+ export component NavigationMenuTrigger(children: React.Node, ...rest: Rest) {
183
+ const item = useNavigationMenuItem("NavigationMenu.Trigger");
184
+ const passed = withoutComposed(rest, ["onClick"]);
185
+
186
+ return (
187
+ <button
188
+ {...passed}
189
+ aria-controls={item.present ? item.bodyId : undefined}
190
+ aria-expanded={item.expanded ? "true" : "false"}
191
+ id={item.triggerId}
192
+ onClick={composeHandlers(rest.onClick, item.toggle)}
193
+ type="button"
194
+ >
195
+ {children}
196
+ </button>
197
+ );
198
+ }
199
+
200
+ /**
201
+ * The group of links an entry opens.
202
+ *
203
+ * Named after its trigger, so a reader who lands in it by `Tab` is told which
204
+ * entry they are inside rather than hearing an unnamed list of four links.
205
+ */
206
+ export component NavigationMenuBody(children: renders* NavigationMenuLink, ...rest: Rest) {
207
+ const item = useNavigationMenuItem("NavigationMenu.Body");
208
+ // Registered only while the group is actually in the document, which for this
209
+ // component means only while it is open — see the module header for why a
210
+ // closed group is removed rather than hidden. The register has to be handed
211
+ // over conditionally rather than the hook called conditionally, because a
212
+ // hook that runs on some renders and not others is a different bug.
213
+ usePresence(item.expanded ? item.registerBody : undefined);
214
+
215
+ if (!item.expanded) {
216
+ return null;
217
+ }
218
+
219
+ return (
220
+ <ul {...rest} aria-labelledby={item.triggerId} id={item.bodyId}>
221
+ {children}
222
+ </ul>
223
+ );
224
+ }
225
+
226
+ /**
227
+ * A destination.
228
+ *
229
+ * It renders its own `<li>` around the `<a>`, which is worth knowing before it
230
+ * surprises somebody: a `<ul>` may only contain `<li>`, and a `Link` that
231
+ * rendered a bare anchor would put this package's own markup outside what HTML
232
+ * allows in the list it sits in. The caller's props go on the anchor, which is
233
+ * the element they are about.
234
+ *
235
+ * Choosing one closes the group, because the reader is leaving. That is done
236
+ * without preventing anything: the click still navigates, and the group is shut
237
+ * behind them so that coming back — or a router that never unmounted the page —
238
+ * does not leave it hanging open.
239
+ */
240
+ export component NavigationMenuLink(children: React.Node, ...rest: Rest) {
241
+ const item = useNavigationMenuItem("NavigationMenu.Link");
242
+ const passed = withoutComposed(rest, ["onClick"]);
243
+
244
+ return (
245
+ <li>
246
+ <a {...passed} onClick={composeHandlers(rest.onClick, item.close)}>
247
+ {children}
248
+ </a>
249
+ </li>
250
+ );
251
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@uniflowed/ui",
3
+ "version": "0.0.0-alpha.10",
4
+ "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/ui"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./accordion": "./accordion.js",
16
+ "./alert-dialog": "./alert-dialog.js",
17
+ "./carousel": "./carousel.js",
18
+ "./checkbox": "./checkbox.js",
19
+ "./collapsible": "./collapsible.js",
20
+ "./combobox": "./combobox.js",
21
+ "./dialog": "./dialog.js",
22
+ "./drawer": "./drawer.js",
23
+ "./field": "./field.js",
24
+ "./hover-card": "./hover-card.js",
25
+ "./input-otp": "./input-otp.js",
26
+ "./menu": "./menu.js",
27
+ "./navigation-menu": "./navigation-menu.js",
28
+ "./pagination": "./pagination.js",
29
+ "./popover": "./popover.js",
30
+ "./progress": "./progress.js",
31
+ "./radio-group": "./radio-group.js",
32
+ "./resizable": "./resizable.js",
33
+ "./scroll-area": "./scroll-area.js",
34
+ "./select": "./select.js",
35
+ "./sheet": "./sheet.js",
36
+ "./sidebar": "./sidebar.js",
37
+ "./slider": "./slider.js",
38
+ "./switch": "./switch.js",
39
+ "./table": "./table.js",
40
+ "./tabs": "./tabs.js",
41
+ "./toast": "./toast.js",
42
+ "./toggle": "./toggle.js",
43
+ "./toggle-group": "./toggle-group.js",
44
+ "./tooltip": "./tooltip.js"
45
+ },
46
+ "files": [
47
+ "*.js",
48
+ "internal"
49
+ ],
50
+ "dependencies": {
51
+ "@uniflowed/hooks": "0.0.0-alpha.10",
52
+ "@uniflowed/react": "0.0.0-alpha.10"
53
+ },
54
+ "peerDependencies": {
55
+ "react": ">=19"
56
+ }
57
+ }
package/pagination.js ADDED
@@ -0,0 +1,197 @@
1
+ // @flow
2
+ //
3
+ // Pagination: the navigation a table needs to be usable, and its four rules.
4
+ //
5
+ // It is here rather than in `table.js` because it is `crates/uf_lib`'s own
6
+ // entry and because a paginated list is not always a table — but it is written
7
+ // for the table next door, and the two are documented together.
8
+ //
9
+ // Four things, each of which is invisible when it is missing:
10
+ //
11
+ // * **It is navigation, so it is a `<nav>` with a name.** A page has more
12
+ // than one `nav`, and an unnamed one is announced as "navigation" with no
13
+ // way to tell it from the site's menu. `aria-label="Pagination"` is what
14
+ // puts it in a screen reader's landmark list under a useful name.
15
+ // * **The current page is `aria-current="page"`.** Not a class, not bold
16
+ // text, not `aria-selected` — `page` is the value ARIA defines for exactly
17
+ // this, and it is the only one that tells a reader where they are.
18
+ // * **Previous and next are named as such.** A link whose content is `‹` is
19
+ // announced as "link, left single quotation mark", which is not a thing
20
+ // anybody can act on. The glyph stays; the name is words.
21
+ // * **The change is announced.** Pressing "next" replaces the rows and moves
22
+ // nothing a reader is looking at, so a live region that was already there
23
+ // says "Page 4 of 25". `combobox.js` states the rule about why it has to
24
+ // have been there first.
25
+ //
26
+ // # Why the controls are links
27
+ //
28
+ // `Pagination.Item` renders an `<a>`, not a `<button>`, and that is an opinion
29
+ // worth stating because it constrains the caller: page four of a table is a
30
+ // *place*, and a reader expects to be able to open it in a new tab, copy it,
31
+ // bookmark it and come back to it. A list paginated with buttons is a list
32
+ // whose fourth page does not exist as far as the rest of the web is concerned.
33
+ //
34
+ // An application that genuinely has no URL for a page — a modal, an unsaved
35
+ // draft — is the case where this is the wrong component, and a `<button>` the
36
+ // caller writes themselves is the right answer. That is a smaller cost than
37
+ // making every well-behaved application invent its own links.
38
+ //
39
+ // # No `"use client"`
40
+ //
41
+ // Nothing here holds state, listens to anything or moves focus. Which page is
42
+ // current is the caller's, the links are links, and the announcement is a
43
+ // string in a div. It renders on a server.
44
+
45
+ import * as React from "@uniflowed/react";
46
+
47
+ import type { Rest } from "./internal/merge-props.js";
48
+ import { withoutComposed } from "./internal/merge-props.js";
49
+
50
+ /**
51
+ * The pagination, as a named landmark, and the region that announces it.
52
+ *
53
+ * `page` and `pageCount` are what the announcement says. They are separate
54
+ * from which `Pagination.Item` is marked current because the two answer
55
+ * different questions — a reader is told "page 4 of 25" whether or not 25
56
+ * links are on screen, and a component that showed five links out of
57
+ * twenty-five would otherwise announce "page 4 of 5".
58
+ */
59
+ export component PaginationRoot(
60
+ children: React.Node,
61
+ label?: string = "Pagination",
62
+ page?: number | null = null,
63
+ pageCount?: number | null = null,
64
+ announcePage?: (page: number, pageCount: number) => string,
65
+ ...rest: Rest
66
+ ) {
67
+ const message =
68
+ page == null || pageCount == null ? "" : (announcePage ?? defaultAnnouncement)(page, pageCount);
69
+
70
+ return (
71
+ <>
72
+ <nav {...rest} aria-label={label}>
73
+ {children}
74
+ </nav>
75
+ {/*
76
+ Beside the navigation rather than inside it, so a reader walking the
77
+ landmark hears the links and not a sentence about them — and mounted
78
+ from the first render holding nothing, because a live region that
79
+ appears together with its text is not announced at all.
80
+ */}
81
+ <div aria-atomic="true" aria-live="polite" data-uf-pagination-status="" role="status">
82
+ {message}
83
+ </div>
84
+ </>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The list of pages.
90
+ *
91
+ * A real list, so a reader is told how many there are before walking them and
92
+ * can skip the whole thing in one keystroke.
93
+ */
94
+ export component PaginationContent(
95
+ children: renders* (PaginationItem | PaginationPrevious | PaginationNext),
96
+ ...rest: Rest
97
+ ) {
98
+ return <ul {...rest}>{children}</ul>;
99
+ }
100
+
101
+ /**
102
+ * One page.
103
+ *
104
+ * The `<li>` is structural and takes nothing; everything a caller passes goes
105
+ * on the `<a>`, which is what they style and what a reader activates.
106
+ */
107
+ export component PaginationItem(
108
+ children: React.Node,
109
+ current?: boolean = false,
110
+ disabled?: boolean = false,
111
+ ...rest: Rest
112
+ ) {
113
+ return (
114
+ <PageLink current={current} disabled={disabled} rest={rest}>
115
+ {children}
116
+ </PageLink>
117
+ );
118
+ }
119
+
120
+ /**
121
+ * The link to the page before this one.
122
+ *
123
+ * `label` is its accessible name and has a default, because the content of
124
+ * this link is a chevron every time — and "link, left single quotation mark"
125
+ * is not something a reader can act on. Pass `label` to translate it; the
126
+ * glyph stays whatever the caller rendered.
127
+ */
128
+ export component PaginationPrevious(
129
+ children?: React.Node,
130
+ label?: string = "Previous page",
131
+ disabled?: boolean = false,
132
+ ...rest: Rest
133
+ ) {
134
+ return (
135
+ <PageLink disabled={disabled} label={label} rest={rest}>
136
+ {children}
137
+ </PageLink>
138
+ );
139
+ }
140
+
141
+ /** The link to the page after this one. See `Pagination.Previous`. */
142
+ export component PaginationNext(
143
+ children?: React.Node,
144
+ label?: string = "Next page",
145
+ disabled?: boolean = false,
146
+ ...rest: Rest
147
+ ) {
148
+ return (
149
+ <PageLink disabled={disabled} label={label} rest={rest}>
150
+ {children}
151
+ </PageLink>
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The `<li><a>` the three parts above all render.
157
+ *
158
+ * `rest` arrives as a *named prop* rather than as a spread, and that is not a
159
+ * style choice. `Rest` is the type of props on their way onto an intrinsic —
160
+ * `merge-props.js` explains why an intrinsic's own props are unchecked here —
161
+ * and spreading its `mixed` indexer onto a typed component makes the checker
162
+ * say, correctly, that `disabled` might not be a boolean. Handing the bag over
163
+ * as one value keeps it a bag until it reaches the element it was always for.
164
+ *
165
+ * `disabled` drops the `href` rather than adding an attribute, because there
166
+ * is no such thing as a disabled link: an `<a>` with no `href` is not in the
167
+ * tab order and is not announced as a link, which is exactly what "there is no
168
+ * previous page" means. `aria-disabled` is there too, so a reader who reaches
169
+ * it another way is told why it does nothing.
170
+ */
171
+ component PageLink(
172
+ rest: Rest,
173
+ children?: React.Node,
174
+ current?: boolean = false,
175
+ disabled?: boolean = false,
176
+ label?: string,
177
+ ) {
178
+ const passed = withoutComposed(rest, disabled ? ["href"] : []);
179
+
180
+ return (
181
+ <li>
182
+ <a
183
+ {...passed}
184
+ aria-current={current ? "page" : undefined}
185
+ aria-disabled={disabled ? "true" : undefined}
186
+ aria-label={label}
187
+ >
188
+ {children}
189
+ </a>
190
+ </li>
191
+ );
192
+ }
193
+
194
+ /** The wording used when the caller supplies none. */
195
+ function defaultAnnouncement(page: number, pageCount: number): string {
196
+ return `Page ${String(page)} of ${String(pageCount)}.`;
197
+ }