@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.14

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,228 @@
1
+ // @flow
2
+ //
3
+ // What every menu in this package is a menu *of*.
4
+ //
5
+ // `menu.js` was one module because there was one menu. shadcn ships four
6
+ // components on this behaviour — a dropdown menu, a context menu, a menubar and
7
+ // the checkable items all three of them share — and the three that are not the
8
+ // dropdown differ from it in exactly two places: what opens the menu, and where
9
+ // the menu goes. Everything between those two — the tree of open levels, what
10
+ // `Escape` closes, what "choosing an item" dismisses, which selector finds an
11
+ // item and which finds its owner — is one set of rules, and this is it.
12
+ //
13
+ // Written down here rather than exported from `menu.js` for the reason every
14
+ // other module in `internal/` gives: these are relationships the components
15
+ // build, not a menu-building kit. `MENU_SELECTOR` is only true because
16
+ // `Menu.Body` renders `role="menu"`; handed to a consumer it would be a
17
+ // selector that happens to work.
18
+ //
19
+ // # A menu is a chain, not a flag
20
+ //
21
+ // A submenu is a menu whose parent is another menu, and almost every rule that
22
+ // distinguishes the two is a statement about that chain:
23
+ //
24
+ // * `Escape` closes *one* level, so it needs to know which one it is in.
25
+ // * Choosing an item closes the whole chain, because leaving the parent menu
26
+ // open after a command has run is a state no native menu has been in.
27
+ // * Only the outermost level listens for a press outside, because a submenu
28
+ // closes with the tree and two listeners would each answer one press.
29
+ //
30
+ // So the chain is the shape the context carries, and `parent` is what a level
31
+ // is given rather than something it works out.
32
+ //
33
+ // # Where a menu goes, when it is not against its trigger
34
+ //
35
+ // A context menu opens at the pointer, which is a *point* and not an element.
36
+ // `MenuAnchorContext` is how a level says so: a rectangle that replaces the
37
+ // trigger's own measurement inside `internal/anchor.js`, and nothing else — the
38
+ // trigger element is still what the writing direction is read from and what
39
+ // focus goes back to. A level with no provider above it measures its trigger,
40
+ // which is every menu that hangs off a button.
41
+
42
+ "use client";
43
+
44
+ import * as React from "@uniflowed/react";
45
+ import {
46
+ createContext,
47
+ useContext,
48
+ useEffect,
49
+ useId,
50
+ useMemo,
51
+ useRef,
52
+ useState,
53
+ } from "@uniflowed/react";
54
+
55
+ import type { Rect } from "./anchor.js";
56
+ import type { Direction } from "./roving-focus.js";
57
+ import { useControlled } from "./controlled-state.js";
58
+
59
+ /**
60
+ * Anything that plays the part of a menu item, including the two checkable
61
+ * kinds. The keyboard has to move between all of them, so the selector names
62
+ * all of them rather than only the plain command.
63
+ */
64
+ export const ITEM_SELECTOR: string =
65
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
66
+
67
+ /** What owns an item: the nearest menu, so a submenu keeps its own. */
68
+ export const MENU_SELECTOR: string = '[role="menu"]';
69
+
70
+ /**
71
+ * Which arrow key opens a submenu, and which closes it.
72
+ *
73
+ * The WAI-ARIA menu pattern puts a submenu on the *inline end*, so it opens to
74
+ * the right of a left-to-right menu and to the left of a right-to-left one, and
75
+ * the key that opens it is the one pointing at it. Written out as
76
+ * `ArrowRight` to open and `ArrowLeft` to close, an RTL reader pressed the key
77
+ * aimed at the submenu and closed the menu they were standing in — which is
78
+ * worse than nothing happening, because it loses their place.
79
+ */
80
+ export function submenuKeys(direction: Direction): {|
81
+ readonly open: string,
82
+ readonly close: string,
83
+ |} {
84
+ return direction === "rtl"
85
+ ? { open: "ArrowLeft", close: "ArrowRight" }
86
+ : { open: "ArrowRight", close: "ArrowLeft" };
87
+ }
88
+
89
+ export type MenuState = {|
90
+ readonly base: string,
91
+ readonly open: boolean,
92
+ readonly setOpen: (open: boolean) => void,
93
+ /** What opened this menu, and what focus goes back to when it closes. */
94
+ readonly triggerRef: { current: HTMLElement | null },
95
+ /**
96
+ * Which end the menu should open onto, written by whatever opened it.
97
+ *
98
+ * A ref rather than state because it is an instruction for the next commit,
99
+ * not a value anything renders: `ArrowUp` on a closed menu opens it *and*
100
+ * lands on the last item, and re-rendering the trigger to say so would be a
101
+ * render whose only purpose is to carry a message to an effect.
102
+ */
103
+ readonly pendingFocus: { current: "first" | "last" | null },
104
+ /** The menu this one hangs off, or null for the outermost. */
105
+ readonly parent: MenuState | null,
106
+ /**
107
+ * Whether a trigger is rendered *and* is something worth naming the menu
108
+ * after, so the body only claims a name that exists.
109
+ *
110
+ * A menu opened by `defaultOpen` in a page that never renders a trigger is a
111
+ * real arrangement, and an `aria-labelledby` pointing at the id that trigger
112
+ * *would* have had makes a screen reader announce nothing at all. A context
113
+ * menu's trigger is arbitrary content rather than a label, and registers
114
+ * itself as a trigger without registering itself as a name.
115
+ */
116
+ readonly triggered: boolean,
117
+ readonly registerTrigger: (present: boolean) => void,
118
+ |};
119
+
120
+ export const MenuContext: React.Context<MenuState | null> = createContext(null);
121
+
122
+ /**
123
+ * The roving tab stop of one open menu.
124
+ *
125
+ * Provided by `Menu.Body` rather than by the root, because a submenu is a
126
+ * second list with a tab stop of its own: nesting the provider is what stops
127
+ * the parent menu and the submenu from fighting over which item is `tabindex=0`.
128
+ */
129
+ export type MenuListState = {|
130
+ readonly activeId: string | null,
131
+ readonly setActiveId: (id: string | null) => void,
132
+ |};
133
+
134
+ export const MenuListContext: React.Context<MenuListState | null> = createContext(null);
135
+
136
+ /**
137
+ * A box a menu body is placed against instead of its trigger's own.
138
+ *
139
+ * `null`, and no provider at all, both mean "measure the trigger". See the
140
+ * module header, and `AnchorRequest.anchorRect` for what it replaces.
141
+ */
142
+ export const MenuAnchorContext: React.Context<Rect | null> = createContext(null);
143
+
144
+ export hook useMenu(part: string): MenuState {
145
+ const state = useContext(MenuContext);
146
+ if (state == null) {
147
+ throw new Error(`${part} must be rendered inside a Menu.Root`);
148
+ }
149
+ return state;
150
+ }
151
+
152
+ /**
153
+ * Tell the menu that something worth naming it after is in the document.
154
+ *
155
+ * `Menu.Body` names its trigger with `aria-labelledby`, and it may only do that
156
+ * while there is one to name — a menu opened by `defaultOpen` in a page with no
157
+ * trigger would otherwise point at an id nothing has, and a screen reader given
158
+ * a dangling `aria-labelledby` announces nothing at all rather than falling back
159
+ * to the element's own content.
160
+ */
161
+ export hook useTriggerRegistration(menu: MenuState): void {
162
+ const register = menu.registerTrigger;
163
+ useEffect(() => {
164
+ register(true);
165
+ return () => register(false);
166
+ }, [register]);
167
+ }
168
+
169
+ /** Every menu from `menu` outwards, innermost first. */
170
+ export function ancestry(menu: MenuState): Array<MenuState> {
171
+ const chain = [];
172
+ let at: MenuState | null = menu;
173
+ while (at != null) {
174
+ chain.push(at);
175
+ at = at.parent;
176
+ }
177
+ return chain;
178
+ }
179
+
180
+ /**
181
+ * Close this menu and every menu it hangs off.
182
+ *
183
+ * Choosing an item in a submenu dismisses the whole thing — leaving the parent
184
+ * menu open after a command has run is a state no native menu has ever been in,
185
+ * and it leaves the reader looking at a menu whose action already happened.
186
+ */
187
+ export function closeTree(menu: MenuState): void {
188
+ for (const each of ancestry(menu)) {
189
+ each.setOpen(false);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * One level of the menu tree.
195
+ *
196
+ * Shared by `Menu.Root`, `Menu.Sub`, `ContextMenu.Root` and `Menubar.Menu`,
197
+ * which differ only in what they hand it: a submenu knows its parent, and a
198
+ * menubar's menu is opened and closed by the bar rather than by itself.
199
+ */
200
+ export component MenuLevel(
201
+ children: React.Node,
202
+ parent: MenuState | null,
203
+ defaultOpen: boolean,
204
+ open?: boolean,
205
+ onOpenChange?: (open: boolean) => void,
206
+ ) {
207
+ const base = useId();
208
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
209
+ const triggerRef = useRef<HTMLElement | null>(null);
210
+ const pendingFocus = useRef<"first" | "last" | null>(null);
211
+ const [triggered, setTriggered] = useState(false);
212
+
213
+ const state = useMemo(
214
+ () => ({
215
+ base,
216
+ open: isOpen,
217
+ setOpen,
218
+ triggerRef,
219
+ pendingFocus,
220
+ parent,
221
+ triggered,
222
+ registerTrigger: setTriggered,
223
+ }),
224
+ [base, isOpen, setOpen, parent, triggered],
225
+ );
226
+
227
+ return <MenuContext.Provider value={state}>{children}</MenuContext.Provider>;
228
+ }