@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.37
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.
- package/accordion.js +360 -0
- package/alert-dialog.js +282 -0
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +550 -0
- package/carousel.js +410 -0
- package/checkbox.js +264 -0
- package/collapsible.js +169 -0
- package/combobox.js +728 -0
- package/context-menu.js +206 -0
- package/date-picker.js +346 -0
- package/dialog.js +523 -0
- package/drawer.js +490 -0
- package/field.js +387 -0
- package/hover-card.js +330 -0
- package/index.js +1699 -22
- package/input-otp.js +218 -0
- package/interactions.js +2163 -0
- package/internal/anchor.js +565 -0
- package/internal/controlled-state.js +65 -0
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +298 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +285 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +823 -0
- package/menubar.js +287 -0
- package/navigation-menu.js +251 -0
- package/package.json +9 -9
- package/pagination.js +209 -0
- package/popover.js +343 -0
- package/progress.js +91 -0
- package/radio-group.js +302 -0
- package/resizable.js +447 -0
- package/scroll-area.js +283 -0
- package/select.js +902 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +300 -0
- package/skeleton.js +159 -0
- package/slider.js +405 -0
- package/switch.js +81 -0
- package/table.js +502 -0
- package/tabs.js +289 -0
- package/toast.js +592 -0
- package/toggle-group.js +283 -0
- package/toggle.js +105 -0
- package/tooltip.js +400 -0
- package/internal/dialog.js +0 -236
- package/internal/field.js +0 -161
- package/internal/props.js +0 -78
- package/internal/switch.js +0 -122
- package/internal/tabs.js +0 -270
package/menubar.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A row of menus that behaves as one control: File, Edit, View.
|
|
4
|
+
//
|
|
5
|
+
// It is not a row of `Menu.Root`s, and the reason is that the *set* has a
|
|
6
|
+
// keyboard map of its own — one that only exists because the menus are next to
|
|
7
|
+
// each other:
|
|
8
|
+
//
|
|
9
|
+
// * The whole bar takes **one** stop in the page's tab order, so `Tab` past an
|
|
10
|
+
// application menu is one press rather than six.
|
|
11
|
+
// * `ArrowLeft` / `ArrowRight` move between the top-level menus, mirrored in a
|
|
12
|
+
// right-to-left page because they are the inline axis.
|
|
13
|
+
// * `ArrowDown` opens the menu under the cursor and lands on its first item;
|
|
14
|
+
// `ArrowUp` opens it onto its last, which is the same argument `Menu.Trigger`
|
|
15
|
+
// makes about the destructive command at the bottom of a long menu.
|
|
16
|
+
// * `Home` / `End` go to the first and last menu.
|
|
17
|
+
// * And the part that is always missing: `ArrowLeft` / `ArrowRight` **while a
|
|
18
|
+
// menu is open** close it and open the adjacent one, so a reader can walk
|
|
19
|
+
// File → Edit → View without pressing `Escape` between them. A menubar
|
|
20
|
+
// without it makes the arrow keys mean two different things depending on
|
|
21
|
+
// whether a menu happens to be showing.
|
|
22
|
+
// * `Escape` closes the open menu and leaves focus on its trigger, in the bar,
|
|
23
|
+
// which `Menu.Body` already does — a menubar trigger is the menu's trigger.
|
|
24
|
+
//
|
|
25
|
+
// Everything inside a menu is `menu.js`: the arrow keys within it, typeahead,
|
|
26
|
+
// submenus, the checkable items and the roving tab stop of the menu itself.
|
|
27
|
+
// `Menubar.Body` is `Menu.Body` itself rather than a wrapper around it, because
|
|
28
|
+
// a bar's menu *is* a root menu — it hangs off a button and drops from it — and
|
|
29
|
+
// `Menu.Body` already places a root menu on the bottom, aligned to the start. A
|
|
30
|
+
// wrapper would have been a second component with the same defaults written out
|
|
31
|
+
// again, and a second place for them to drift.
|
|
32
|
+
//
|
|
33
|
+
// # How the bar finds its own triggers
|
|
34
|
+
//
|
|
35
|
+
// `internal/roving-focus.js`'s `itemsOf(container, item, owner)` takes the owner
|
|
36
|
+
// selector as a parameter for exactly this: a set says what owns it. A menu
|
|
37
|
+
// passes `[role="menu"]`, and a menubar has to pass **both** — an open menu is a
|
|
38
|
+
// DOM descendant of the bar, and its items are `role="menuitem"` too, so a bar
|
|
39
|
+
// that only asked "menu items inside me" would step into the open menu's items
|
|
40
|
+
// with `ArrowRight`. Naming the two owners makes `closest` stop at the menu for
|
|
41
|
+
// an item inside one and at the bar for a trigger, which is the distinction, and
|
|
42
|
+
// it is settled by the roles the two containers already carry.
|
|
43
|
+
//
|
|
44
|
+
// Turning a trigger *element* back into the menu it opens is the one thing the
|
|
45
|
+
// roles cannot say, and `data-uf-menubar-value` is that and nothing more. The
|
|
46
|
+
// alternative is a registry of refs, which `roving-focus.js` explains at length
|
|
47
|
+
// is a second opinion about document order.
|
|
48
|
+
//
|
|
49
|
+
// # Which menu is open is the bar's state
|
|
50
|
+
//
|
|
51
|
+
// One value, `string | null`, rather than a boolean per menu. Two menus open at
|
|
52
|
+
// once is the state this component exists to prevent, and a per-menu boolean is
|
|
53
|
+
// a set of booleans somebody has to keep exclusive; `accordion.js` makes the
|
|
54
|
+
// same argument about `single`. It is also what makes "close this one and open
|
|
55
|
+
// the next" a single assignment rather than a pair of them that render twice.
|
|
56
|
+
|
|
57
|
+
"use client";
|
|
58
|
+
|
|
59
|
+
import * as React from "@uniflowed/react";
|
|
60
|
+
import {
|
|
61
|
+
createContext,
|
|
62
|
+
useCallback,
|
|
63
|
+
useContext,
|
|
64
|
+
useMemo,
|
|
65
|
+
useRef,
|
|
66
|
+
useState,
|
|
67
|
+
} from "@uniflowed/react";
|
|
68
|
+
|
|
69
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
70
|
+
import {
|
|
71
|
+
composeHandlers,
|
|
72
|
+
composeRefs,
|
|
73
|
+
withProps,
|
|
74
|
+
withoutComposed,
|
|
75
|
+
} from "./internal/merge-props.js";
|
|
76
|
+
import type { RovingSet } from "./internal/roving-focus.js";
|
|
77
|
+
import {
|
|
78
|
+
directionOf,
|
|
79
|
+
indexOfActive,
|
|
80
|
+
itemsOf,
|
|
81
|
+
movementFor,
|
|
82
|
+
moveTo,
|
|
83
|
+
useFirstItem,
|
|
84
|
+
} from "./internal/roving-focus.js";
|
|
85
|
+
import { MenuLevel, useMenu } from "./internal/menu-tree.js";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The bar's own items, and the two things that may own one.
|
|
89
|
+
*
|
|
90
|
+
* See the module header: an open menu is inside the bar and its items wear the
|
|
91
|
+
* same role, so the owner selector names both and `closest` settles it.
|
|
92
|
+
*/
|
|
93
|
+
const TRIGGERS: RovingSet = {
|
|
94
|
+
item: '[role="menuitem"]',
|
|
95
|
+
owner: '[role="menu"], [role="menubar"]',
|
|
96
|
+
orientation: "horizontal",
|
|
97
|
+
wrap: true,
|
|
98
|
+
skipDisabled: true,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type MenubarState = {|
|
|
102
|
+
/** Which menu is showing, by the `value` its `Menubar.Menu` was given. */
|
|
103
|
+
readonly open: string | null,
|
|
104
|
+
readonly setOpen: (value: string | null) => void,
|
|
105
|
+
/** Which trigger holds the bar's single tab stop, or null for "the first". */
|
|
106
|
+
readonly active: string | null,
|
|
107
|
+
readonly setActive: (value: string) => void,
|
|
108
|
+
readonly firstId: string | null,
|
|
109
|
+
|};
|
|
110
|
+
|
|
111
|
+
const MenubarContext: React.Context<MenubarState | null> = createContext(null);
|
|
112
|
+
|
|
113
|
+
/** The `value` of the `Menubar.Menu` a trigger belongs to. */
|
|
114
|
+
const MenubarMenuContext: React.Context<string | null> = createContext(null);
|
|
115
|
+
|
|
116
|
+
hook useMenubar(part: string): MenubarState {
|
|
117
|
+
const state = useContext(MenubarContext);
|
|
118
|
+
if (state == null) {
|
|
119
|
+
throw new Error(`${part} must be rendered inside a Menubar.Root`);
|
|
120
|
+
}
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The bar: `role="menubar"`, one tab stop, and the arrows between the menus.
|
|
126
|
+
*
|
|
127
|
+
* `aria-label` is the caller's and matters more here than on most containers —
|
|
128
|
+
* a page with an application menubar and a formatting toolbar has two, and
|
|
129
|
+
* "menu bar" twice tells a reader nothing about which is which.
|
|
130
|
+
*/
|
|
131
|
+
export component MenubarRoot(children: renders* MenubarMenu, render?: RenderProp, ...rest: Rest) {
|
|
132
|
+
const barRef = useRef<HTMLElement | null>(null);
|
|
133
|
+
const [open, setOpenValue] = useState<string | null>(null);
|
|
134
|
+
const [active, setActive] = useState<string | null>(null);
|
|
135
|
+
// Only while nothing has been focused or opened. Once a trigger holds the tab
|
|
136
|
+
// stop, asking the document which one comes first is work with no reader.
|
|
137
|
+
const firstId = useFirstItem(barRef, TRIGGERS, active == null);
|
|
138
|
+
|
|
139
|
+
const setOpen = useCallback((value: string | null) => {
|
|
140
|
+
setOpenValue(value);
|
|
141
|
+
if (value != null) {
|
|
142
|
+
setActive(value);
|
|
143
|
+
}
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const state = useMemo(
|
|
147
|
+
() => ({ open, setOpen, active, setActive, firstId }),
|
|
148
|
+
[open, setOpen, active, firstId],
|
|
149
|
+
);
|
|
150
|
+
const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
|
|
151
|
+
"aria-orientation": "horizontal",
|
|
152
|
+
children,
|
|
153
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
154
|
+
const bar: $FlowFixMe = event.currentTarget;
|
|
155
|
+
const movement = movementFor(event.key, "horizontal", directionOf(bar));
|
|
156
|
+
if (movement == null) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const triggers = itemsOf(bar, TRIGGERS.item, TRIGGERS.owner);
|
|
160
|
+
// With a menu open, focus is on one of *its* items rather than on a
|
|
161
|
+
// trigger, so "where am I in the bar" is the expanded trigger. This
|
|
162
|
+
// is the whole of walking File → Edit → View without pressing Escape.
|
|
163
|
+
const focused = indexOfActive(triggers, bar.ownerDocument?.activeElement);
|
|
164
|
+
const at =
|
|
165
|
+
focused >= 0
|
|
166
|
+
? focused
|
|
167
|
+
: triggers.findIndex((each) => each.getAttribute("aria-expanded") === "true");
|
|
168
|
+
const next = moveTo(triggers, at, movement, TRIGGERS.wrap, TRIGGERS.skipDisabled);
|
|
169
|
+
if (next == null) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Claimed before focus moves, or the browser scrolls the page under
|
|
173
|
+
// the trigger that has just taken it; `moveOnKey` says the same.
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
event.stopPropagation();
|
|
176
|
+
const value = next.getAttribute("data-uf-menubar-value");
|
|
177
|
+
if (open != null && value != null) {
|
|
178
|
+
// Swap which menu is showing. Focus lands on the new menu's first
|
|
179
|
+
// item through `Menu.Body`'s own opening effect, so nothing here
|
|
180
|
+
// moves it: focusing the trigger as well would be two focus moves
|
|
181
|
+
// in one commit and the reader would see the second.
|
|
182
|
+
setOpen(value);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
next.focus();
|
|
186
|
+
if (value != null) {
|
|
187
|
+
setActive(value);
|
|
188
|
+
}
|
|
189
|
+
}),
|
|
190
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
191
|
+
barRef.current = element;
|
|
192
|
+
}),
|
|
193
|
+
role: "menubar",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<MenubarContext.Provider value={state}>
|
|
198
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
199
|
+
</MenubarContext.Provider>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One menu of the bar, named by the `value` the bar opens and closes it with.
|
|
205
|
+
*
|
|
206
|
+
* Renders no element of its own, for the reason `Menu.Root` gives: a trigger and
|
|
207
|
+
* its body are siblings in whatever layout the caller wrote.
|
|
208
|
+
*/
|
|
209
|
+
export component MenubarMenu(children: React.Node, value: string) {
|
|
210
|
+
const bar = useMenubar("Menubar.Menu");
|
|
211
|
+
const setOpen = bar.setOpen;
|
|
212
|
+
const onOpenChange = useCallback(
|
|
213
|
+
(next: boolean) => setOpen(next ? value : null),
|
|
214
|
+
[setOpen, value],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<MenubarMenuContext.Provider value={value}>
|
|
219
|
+
<MenuLevel
|
|
220
|
+
defaultOpen={false}
|
|
221
|
+
onOpenChange={onOpenChange}
|
|
222
|
+
open={bar.open === value}
|
|
223
|
+
parent={null}
|
|
224
|
+
>
|
|
225
|
+
{children}
|
|
226
|
+
</MenuLevel>
|
|
227
|
+
</MenubarMenuContext.Provider>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The button that opens one of the bar's menus.
|
|
233
|
+
*
|
|
234
|
+
* `role="menuitem"` rather than a plain button, because it *is* an item of the
|
|
235
|
+
* menubar — a reader is told "File, menu item, has popup, 1 of 3" — and it is
|
|
236
|
+
* what makes the bar's arrow keys agree with what they were told is in it.
|
|
237
|
+
*/
|
|
238
|
+
export component MenubarTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
239
|
+
const bar = useMenubar("Menubar.Trigger");
|
|
240
|
+
const menu = useMenu("Menubar.Trigger");
|
|
241
|
+
const value = useContext(MenubarMenuContext);
|
|
242
|
+
if (value == null) {
|
|
243
|
+
throw new Error("Menubar.Trigger must be rendered inside a Menubar.Menu");
|
|
244
|
+
}
|
|
245
|
+
const id = `${menu.base}-trigger`;
|
|
246
|
+
// The bar's single tab stop. Before anything has been focused or opened it
|
|
247
|
+
// belongs to the first trigger, which is a fact about the document rather
|
|
248
|
+
// than about this component — `useFirstItem` reads it in the bar.
|
|
249
|
+
const stop = bar.active == null ? bar.firstId === id : bar.active === value;
|
|
250
|
+
|
|
251
|
+
const props = withProps(withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]), {
|
|
252
|
+
"aria-controls": menu.open ? `${menu.base}-body` : undefined,
|
|
253
|
+
"aria-expanded": menu.open ? "true" : "false",
|
|
254
|
+
"aria-haspopup": "menu",
|
|
255
|
+
children,
|
|
256
|
+
// How the bar's keyboard turns a trigger element back into the menu it
|
|
257
|
+
// opens; the module header says why this is an attribute and the rest of
|
|
258
|
+
// the bar's arithmetic is not.
|
|
259
|
+
"data-uf-menubar-value": value,
|
|
260
|
+
id,
|
|
261
|
+
onClick: composeHandlers(rest.onClick, () => bar.setOpen(menu.open ? null : value)),
|
|
262
|
+
onFocus: composeHandlers(rest.onFocus, () => bar.setActive(value)),
|
|
263
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
264
|
+
const end = match (event.key) {
|
|
265
|
+
"ArrowDown" => "first",
|
|
266
|
+
"ArrowUp" => "last",
|
|
267
|
+
_ => null,
|
|
268
|
+
};
|
|
269
|
+
if (end == null) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
menu.pendingFocus.current = end;
|
|
274
|
+
bar.setOpen(value);
|
|
275
|
+
}),
|
|
276
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
277
|
+
menu.triggerRef.current = element;
|
|
278
|
+
}),
|
|
279
|
+
role: "menuitem",
|
|
280
|
+
tabIndex: stop ? 0 : -1,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (render != null) {
|
|
284
|
+
return render(props);
|
|
285
|
+
}
|
|
286
|
+
return <button {...props} type="button" />;
|
|
287
|
+
}
|
|
@@ -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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/ui",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.37",
|
|
4
4
|
"description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
"directory": "packages/ui"
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
|
-
"
|
|
15
|
-
"./form/types": "./form/types.js",
|
|
16
|
-
".": "./index.js",
|
|
17
|
-
"./types/renders": "./types/renders.js"
|
|
14
|
+
".": "./index.js"
|
|
18
15
|
},
|
|
19
16
|
"files": [
|
|
20
|
-
"
|
|
21
|
-
"internal"
|
|
17
|
+
"*.js",
|
|
18
|
+
"internal",
|
|
19
|
+
"!*.test.js"
|
|
22
20
|
],
|
|
23
21
|
"dependencies": {
|
|
24
|
-
"@uniflowed/
|
|
25
|
-
"@uniflowed/
|
|
22
|
+
"@uniflowed/core": "0.0.0-alpha.37",
|
|
23
|
+
"@uniflowed/hooks": "0.0.0-alpha.37",
|
|
24
|
+
"@uniflowed/react": "0.0.0-alpha.37",
|
|
25
|
+
"@uniflowed/state": "0.0.0-alpha.37"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"react": ">=19"
|