@uniflowed/ui 0.0.0-alpha.13 → 0.0.0-alpha.15
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 +21 -3
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/combobox.js +4 -4
- package/context-menu.js +198 -0
- package/field.js +192 -25
- package/index.js +148 -4
- package/internal/anchor.js +24 -1
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/menu.js +264 -163
- package/menubar.js +285 -0
- package/package.json +6 -4
- package/select.js +19 -1
- package/switch.js +5 -3
- package/toggle.js +3 -2
package/internal/disclosure.js
CHANGED
|
@@ -48,6 +48,58 @@
|
|
|
48
48
|
// or re-adds the attribute and this upgrades it again. Nothing here writes an
|
|
49
49
|
// attribute React believes it owns while React believes it.
|
|
50
50
|
//
|
|
51
|
+
// # The height a closed panel would have
|
|
52
|
+
//
|
|
53
|
+
// The third rule, and the one that took a piece of work rather than a line.
|
|
54
|
+
// `height: 0 → var(--uf-collapsible-height)` is the whole of animating a
|
|
55
|
+
// disclosure, and the number in that property is the one thing a stylesheet
|
|
56
|
+
// cannot compute: it is the height the content *would* have, wanted at the
|
|
57
|
+
// moment the panel is still closed, because a transition has to know its
|
|
58
|
+
// destination before it starts.
|
|
59
|
+
//
|
|
60
|
+
// Every obvious way of getting it answers zero. A closed panel is `hidden`, so
|
|
61
|
+
// it has no box: `ResizeObserver` reports `0`, `getBoundingClientRect()` is
|
|
62
|
+
// empty, `scrollHeight` is `0`, and `useElementSize` from
|
|
63
|
+
// `@uniflowed/hooks/dom` measures a hidden element and reports zero — which is
|
|
64
|
+
// exactly the moment the number is wanted. Measuring after the panel opens
|
|
65
|
+
// gives the right number one frame late, which is the jank this removes.
|
|
66
|
+
//
|
|
67
|
+
// So `useMeasuredHeight` lays the panel out without painting it: inline
|
|
68
|
+
// `display`, `position: absolute` and `visibility: hidden`, read, restore. Four
|
|
69
|
+
// things about that are not obvious:
|
|
70
|
+
//
|
|
71
|
+
// * It overrides `display` inline rather than removing `hidden`. The
|
|
72
|
+
// attribute is `useUntilFound`'s and React's; an effect that took it away
|
|
73
|
+
// and put it back would be fighting both of them for one frame, and would
|
|
74
|
+
// lose whichever ran last. An inline declaration beats the user-agent
|
|
75
|
+
// stylesheet's `[hidden] { display: none }` and touches nothing anybody
|
|
76
|
+
// else believes they own.
|
|
77
|
+
// * It sets `content-visibility: visible` in the same pass, because
|
|
78
|
+
// `hidden="until-found"` is `content-visibility: hidden`, which does not lay
|
|
79
|
+
// its subtree out either. Defeating one of the two and not the other
|
|
80
|
+
// measures zero on exactly the panels this package ships.
|
|
81
|
+
// * It sets `height: auto` in the same pass, for the same reason and against
|
|
82
|
+
// the very rule this property exists for. A closed panel is `height: 0` —
|
|
83
|
+
// that is the half of `height: 0 → var(--uf-collapsible-height)` that is
|
|
84
|
+
// always on — so a panel laid out with the stylesheet still applying
|
|
85
|
+
// measures zero and writes `0px` back into the property it was asked to
|
|
86
|
+
// fill. Defeating `display` and not `height` is the same mistake as
|
|
87
|
+
// defeating `display` and not `content-visibility`, one declaration along.
|
|
88
|
+
// * And it pins the width, because taking a box out of flow makes it
|
|
89
|
+
// shrink-to-fit against its containing block — the nearest positioned
|
|
90
|
+
// ancestor, which on most pages is the viewport. Text that wraps to four
|
|
91
|
+
// lines where the panel lives measures one line there. The width it would
|
|
92
|
+
// have in flow is its parent's content box; when that cannot be read the
|
|
93
|
+
// pass leaves the width alone rather than inventing one.
|
|
94
|
+
//
|
|
95
|
+
// It is opt-in, and that is the honest answer to "it should cost nothing on a
|
|
96
|
+
// page that never animates". Whether a stylesheet reads the property is not
|
|
97
|
+
// something the component can ask — `getComputedStyle` on a `display: none`
|
|
98
|
+
// element answers about the declaration, not about whether anybody transitions
|
|
99
|
+
// on it — so the alternative to a prop is a forced layout per panel per render
|
|
100
|
+
// on every page that has a collapsible, animated or not. A forty-section FAQ is
|
|
101
|
+
// forty synchronous layouts nobody asked for.
|
|
102
|
+
//
|
|
51
103
|
// # Why this is `internal/` and not a subpath
|
|
52
104
|
//
|
|
53
105
|
// The same reason `roving-focus.js` gives. These are rules about markup this
|
|
@@ -95,3 +147,152 @@ export hook useUntilFound(ref: { current: HTMLElement | null }, open: boolean):
|
|
|
95
147
|
element.setAttribute("hidden", "until-found");
|
|
96
148
|
}, [ref, open]);
|
|
97
149
|
}
|
|
150
|
+
|
|
151
|
+
/** The custom property a stylesheet transitions a disclosure's height to. */
|
|
152
|
+
export const HEIGHT_PROPERTY: string = "--uf-collapsible-height";
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The width `element` would have in flow, as a CSS length, or nothing.
|
|
156
|
+
*
|
|
157
|
+
* Taking the panel out of flow to measure it costs shrink-to-fit: an absolutely
|
|
158
|
+
* positioned box with `width: auto` is as wide as its *content* wants to be,
|
|
159
|
+
* bounded by its containing block — which is the nearest positioned ancestor
|
|
160
|
+
* and, on most pages, is the viewport rather than the panel's parent. A
|
|
161
|
+
* paragraph that wraps to four lines in a sidebar measures one line there, and
|
|
162
|
+
* the number written into the property is then a height the panel never has.
|
|
163
|
+
*
|
|
164
|
+
* The width it would have in flow is its parent's content box, which is what
|
|
165
|
+
* `getComputedStyle` reports for `width` on a laid-out element. Nothing is
|
|
166
|
+
* returned when the answer is not a length — a parent that is itself
|
|
167
|
+
* `display: none`, or a DOM with no layout to report — and the pass then does
|
|
168
|
+
* what it did before rather than pinning a width it had to guess.
|
|
169
|
+
*/
|
|
170
|
+
function widthInFlow(element: HTMLElement): string | null {
|
|
171
|
+
const parent = element.parentElement;
|
|
172
|
+
const view: $FlowFixMe = element.ownerDocument?.defaultView;
|
|
173
|
+
if (parent == null || view == null || typeof view.getComputedStyle !== "function") {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const width: mixed = view.getComputedStyle(parent).width;
|
|
177
|
+
return typeof width === "string" && width.endsWith("px") ? width : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The height `element` has, or would have if it were not hidden.
|
|
182
|
+
*
|
|
183
|
+
* The measuring pass, and the reason this module has a header section about it.
|
|
184
|
+
* An open panel is measured where it stands; a closed one is briefly laid out
|
|
185
|
+
* and not painted. Either way this reads layout, which is a synchronous reflow
|
|
186
|
+
* — the caller is the one that decides it is worth paying.
|
|
187
|
+
*
|
|
188
|
+
* # The two things a closed panel is not, and has to be made
|
|
189
|
+
*
|
|
190
|
+
* Laying it out is necessary and is not sufficient, because the stylesheet this
|
|
191
|
+
* property exists for is `height: 0` on the closed panel and
|
|
192
|
+
* `height: var(--uf-collapsible-height)` on the open one. A panel laid out with
|
|
193
|
+
* that rule still applying measures **zero**, the property is written back as
|
|
194
|
+
* `0px`, and the transition has a destination of nothing — the bug the whole
|
|
195
|
+
* hook was written to avoid, arriving through the rule it was written for. So
|
|
196
|
+
* `height: auto` is set inline for the pass, exactly as `display` is: not
|
|
197
|
+
* because the panel wants an inline height but because the author declaration
|
|
198
|
+
* has to be defeated for one synchronous read and put back.
|
|
199
|
+
*
|
|
200
|
+
* `width` is the same argument for the other axis and is `widthInFlow`'s. Both
|
|
201
|
+
* are restored with everything else; a stylesheet is never left fighting an
|
|
202
|
+
* inline declaration this wrote.
|
|
203
|
+
*/
|
|
204
|
+
function heightOf(element: HTMLElement): number {
|
|
205
|
+
if (!element.hasAttribute("hidden")) {
|
|
206
|
+
return element.getBoundingClientRect().height;
|
|
207
|
+
}
|
|
208
|
+
const style = element.style;
|
|
209
|
+
const before = {
|
|
210
|
+
boxSizing: style.boxSizing,
|
|
211
|
+
contentVisibility: style.getPropertyValue("content-visibility"),
|
|
212
|
+
display: style.display,
|
|
213
|
+
height: style.height,
|
|
214
|
+
position: style.position,
|
|
215
|
+
visibility: style.visibility,
|
|
216
|
+
width: style.width,
|
|
217
|
+
};
|
|
218
|
+
// Out of flow and unpainted, so nothing below the panel moves and no frame
|
|
219
|
+
// shows it. The order is not significant; this is all one style
|
|
220
|
+
// recalculation, paid for by the single read below.
|
|
221
|
+
style.display = "block";
|
|
222
|
+
style.setProperty("content-visibility", "visible");
|
|
223
|
+
style.position = "absolute";
|
|
224
|
+
style.visibility = "hidden";
|
|
225
|
+
style.height = "auto";
|
|
226
|
+
const width = widthInFlow(element);
|
|
227
|
+
if (width != null) {
|
|
228
|
+
// `border-box`, because what fills the parent's content width in flow is
|
|
229
|
+
// the panel's margin box rather than its content box: measuring a padded
|
|
230
|
+
// panel content-box wide would make it wider than it will ever be and its
|
|
231
|
+
// text shorter than it will ever wrap to.
|
|
232
|
+
style.boxSizing = "border-box";
|
|
233
|
+
style.width = width;
|
|
234
|
+
}
|
|
235
|
+
const height = element.getBoundingClientRect().height;
|
|
236
|
+
style.boxSizing = before.boxSizing;
|
|
237
|
+
style.display = before.display;
|
|
238
|
+
style.setProperty("content-visibility", before.contentVisibility);
|
|
239
|
+
style.height = before.height;
|
|
240
|
+
style.position = before.position;
|
|
241
|
+
style.visibility = before.visibility;
|
|
242
|
+
style.width = before.width;
|
|
243
|
+
return height;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Keep `HEIGHT_PROPERTY` on a disclosure's panel equal to its content's height.
|
|
248
|
+
*
|
|
249
|
+
* `enabled` is the caller's opt-in; see the module header for why there is one.
|
|
250
|
+
* When it is off this writes nothing and measures nothing, which is what makes
|
|
251
|
+
* a page with no animation pay nothing.
|
|
252
|
+
*
|
|
253
|
+
* Two effects rather than one, because they answer different questions. The
|
|
254
|
+
* first measures on every render, with no dependency array on purpose: what the
|
|
255
|
+
* panel would be worth changes when the *caller* renders different children
|
|
256
|
+
* into it, and a dependency list here would be a claim about when that happens
|
|
257
|
+
* that only the caller could keep — `useFirstItem` in `roving-focus.js` makes
|
|
258
|
+
* the same argument for the same reason. The second watches for a size change
|
|
259
|
+
* no render caused: an image that finished loading, a font that swapped. It
|
|
260
|
+
* only ever fires while the panel is open, because a `display: none` element
|
|
261
|
+
* has no box for a `ResizeObserver` to report on — which is the whole problem
|
|
262
|
+
* this hook exists for, arriving one more time.
|
|
263
|
+
*/
|
|
264
|
+
export hook useMeasuredHeight(ref: { current: HTMLElement | null }, enabled: boolean): void {
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const element = ref.current;
|
|
267
|
+
if (!enabled || element == null) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const measured = `${String(heightOf(element))}px`;
|
|
271
|
+
// Compared before writing, so a render that changed nothing does not dirty
|
|
272
|
+
// the element's style and invite another style recalculation.
|
|
273
|
+
if (element.style.getPropertyValue(HEIGHT_PROPERTY) !== measured) {
|
|
274
|
+
element.style.setProperty(HEIGHT_PROPERTY, measured);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
const element = ref.current;
|
|
280
|
+
const view = element?.ownerDocument?.defaultView;
|
|
281
|
+
if (!enabled || element == null || view == null) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Read off the window rather than through a local, for the reason
|
|
285
|
+
// `internal/anchor.js` gives where it does the same: a capitalised name
|
|
286
|
+
// holding a constructor is read as a React component by `uf lint`, and the
|
|
287
|
+
// window's own property is the thing being asked about anyway.
|
|
288
|
+
const host: $FlowFixMe = view;
|
|
289
|
+
if (typeof host.ResizeObserver !== "function") {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const sizes = new host.ResizeObserver(() => {
|
|
293
|
+
element.style.setProperty(HEIGHT_PROPERTY, `${String(heightOf(element))}px`);
|
|
294
|
+
});
|
|
295
|
+
sizes.observe(element);
|
|
296
|
+
return () => sizes.disconnect();
|
|
297
|
+
}, [ref, enabled]);
|
|
298
|
+
}
|
|
@@ -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
|
+
}
|