@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.
Files changed (58) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. package/internal/tabs.js +0 -270
@@ -0,0 +1,83 @@
1
+ // @flow
2
+ //
3
+ // What a `<form>` submits for a control the browser has never heard of.
4
+ //
5
+ // Every widget in this package is a `button` or a `div` wearing an ARIA role,
6
+ // which is what makes it styleable and what makes it invisible to form
7
+ // submission: `new FormData(form)` collects the form's *listed* elements, and a
8
+ // `<div role="listbox">` is not one. So a Select inside a form submitted
9
+ // nothing at all, and a Combobox submitted `Combobox.Input`'s value — which is
10
+ // the label the reader sees and not the value the application meant. A country
11
+ // picker posted "United Kingdom" where the server was waiting for `GB`.
12
+ //
13
+ // The fix is one hidden `<input>` carrying the real value, rendered only when
14
+ // the caller asked for one by giving a `name`. No `name`, no control: a Select
15
+ // used to drive a filter has nothing to submit, and a form field the caller
16
+ // never named is not one this package should invent.
17
+ //
18
+ // # Why a hidden input and not a hidden `<select>`
19
+ //
20
+ // Rendering a real, visually hidden `<select>` is the other answer, and it buys
21
+ // two things: the browser autofills it, and `required` gets native constraint
22
+ // validation. Both were tried and neither survives contact with the
23
+ // accessibility tree.
24
+ //
25
+ // A `<select>` is focusable, so it is announced. A reader who tabs into the
26
+ // widget hears the styled combobox and then a second, invisible combobox with
27
+ // the same options — the duplicate-announcement bug that makes people describe
28
+ // a component library as "noisy". Taking it out of the tree means
29
+ // `aria-hidden="true"`, and `aria-hidden` on a focusable element is itself the
30
+ // violation: it hides the element from a screen reader while leaving it in the
31
+ // tab order, so the reader lands on something their software says is not there.
32
+ // `tabindex="-1"` plus `aria-hidden` closes that hole and gives up the tab
33
+ // order, which is the autofill affordance the native control was for.
34
+ //
35
+ // And native validation cannot work here either. The browser reports a
36
+ // constraint failure by focusing the invalid control and drawing a bubble at
37
+ // it; on a control with no box, Chrome logs "An invalid form control with
38
+ // name='country' is not focusable" and refuses to submit the form at all, with
39
+ // nothing shown to the reader. A headless select's `required` therefore belongs
40
+ // to `@uniflowed/form` and `@uniflowed/validator`, which is where uf already
41
+ // put every other rule, and `Field.Error` is where the message goes.
42
+ //
43
+ // An `<input type="hidden">` is none of those things: never focusable, never in
44
+ // the accessibility tree, never validated, and always submitted. What it costs
45
+ // is autofill, which is a real loss and is written down rather than hidden —
46
+ // a browser will not fill a hidden input the way it fills `<select
47
+ // name="country">`.
48
+ //
49
+ // # This is not how `@uniflowed/form` reads a value
50
+ //
51
+ // Worth stating because the two look like they overlap and do not.
52
+ // `@uniflowed/form` holds values in its own store and calls `preventDefault()`
53
+ // on submit, so it never builds a `FormData` and never sees this element. A
54
+ // Select bound to that library is bound through `useController` — `field.value`
55
+ // into `value`, `field.onChange` into `onValueChange` — and needs no `name`
56
+ // here at all. This element is for the other kind of form: a plain `<form
57
+ // action={…}>`, a Server Action, or anything else that submits the document.
58
+ //
59
+ // # Why this is `internal/` and not a subpath
60
+ //
61
+ // It is one sentence about what this package promises a form, and the failure
62
+ // mode of writing it twice is that Select and Combobox disagree about what a
63
+ // disabled control submits. Exported, it would be a `<HiddenInput>` a consumer
64
+ // could reach for in a component that had not thought about any of the above.
65
+
66
+ "use client";
67
+
68
+ /**
69
+ * The control a form actually reads.
70
+ *
71
+ * `value` is the widget's value, not its label. `null` renders an empty string
72
+ * rather than omitting the control, so a form that submits a Select the reader
73
+ * left alone still carries the field — a key missing from the payload and a key
74
+ * present and empty are different questions to a server, and "the reader saw
75
+ * this field and chose nothing" is the second one.
76
+ *
77
+ * `disabled` is passed through rather than interpreted: a disabled control is
78
+ * omitted from the submission by the browser, which is the behaviour a native
79
+ * `<select disabled>` has and the one a caller who disabled the widget expects.
80
+ */
81
+ export component FormValue(name: string, value: string | null, disabled?: boolean = false) {
82
+ return <input disabled={disabled} name={name} type="hidden" value={value ?? ""} />;
83
+ }
@@ -0,0 +1,259 @@
1
+ // @flow
2
+ //
3
+ // What WCAG requires of anything that appears because a pointer or focus
4
+ // arrived, which is a tooltip and a hover card and nothing else in this
5
+ // package.
6
+ //
7
+ // SC 1.4.13, *Content on Hover or Focus*, is three clauses, and a hand-written
8
+ // tooltip fails all three. They are not a matter of taste and they are not
9
+ // separable, so they live in one module:
10
+ //
11
+ // * **Dismissible** — `Escape` removes it without moving the pointer. The
12
+ // content never holds focus, so the key never reaches it: the listener has
13
+ // to be on the document, which is `useDismissOnEscape` below.
14
+ // * **Hoverable** — the pointer can travel from the trigger onto the content
15
+ // without it vanishing on the way. That is why leaving schedules a close
16
+ // rather than performing one, and why arriving anywhere cancels it. A
17
+ // component that closes on `pointerleave` snatches the content away from a
18
+ // reader who was moving towards it — including every reader who magnifies
19
+ // the screen, for whom the trip is long.
20
+ // * **Persistent** — it stays until it is dismissed or the pointer and focus
21
+ // have both left. A timer that closes it on its own is out.
22
+ //
23
+ // The opening delay is not one of the three clauses; it is what makes the
24
+ // component bearable. A pointer crossing a toolbar enters six triggers on its
25
+ // way somewhere else, and a tooltip that opened on each would be six
26
+ // interruptions. **A delay belongs to the pointer and not to focus**: a reader
27
+ // who tabbed to a control has already said what they want, and making them wait
28
+ // for it is a delay with nothing to prevent.
29
+ //
30
+ // # The clock is a ref, and `useTimeout` is deliberately not used
31
+ //
32
+ // `@uniflowed/hooks/timing` has the hook this looks like it wants, and its
33
+ // contract is not this one: `useTimeout(body, millis)` sets its timer in an
34
+ // effect keyed on `millis`, so asking again for the *same* delay does not
35
+ // restart it. Every interesting sequence here asks twice — enter, leave,
36
+ // enter — and with an open delay equal to the close delay the second request
37
+ // would inherit the first request's deadline and fire early. What is wanted is
38
+ // "restart the clock", which is a command rather than a state, so it is written
39
+ // as one.
40
+ //
41
+ // # Why this is `internal/` and not a subpath
42
+ //
43
+ // It is not a `useHoverIntent` for anybody to build a tooltip with; it is the
44
+ // half of `tooltip.js` and `hover-card.js` that has to be the same in both. A
45
+ // consumer given a copy could build the component that closes on
46
+ // `pointerleave`, which is the failure this exists to prevent.
47
+
48
+ import { useEffect, useMemo, useRef } from "@uniflowed/react";
49
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
50
+
51
+ import { FOCUS_STOPS } from "./focus.js";
52
+
53
+ /** How long a pointer must rest on a trigger before its tooltip opens. */
54
+ export const DEFAULT_OPEN_DELAY: number = 700;
55
+
56
+ /**
57
+ * How long the content stays after the pointer leaves.
58
+ *
59
+ * This is the hoverable clause's whole implementation: the gap between a
60
+ * trigger and its overlay takes a moment to cross, and a reader who is crossing
61
+ * it has not left.
62
+ */
63
+ export const DEFAULT_CLOSE_DELAY: number = 300;
64
+
65
+ /**
66
+ * How long after one tooltip closes the next one opens with no delay.
67
+ *
68
+ * A reader who has waited out the delay once has established that they are
69
+ * reading tooltips; the second icon in a toolbar should answer immediately.
70
+ */
71
+ export const DEFAULT_SKIP_DELAY: number = 300;
72
+
73
+ /** Opening and closing, on a clock that can be restarted or called off. */
74
+ export type HoverIntent = {|
75
+ /** Open after `millis`, or in this tick when that is nought. */
76
+ readonly openAfter: (millis: number) => void,
77
+ /** Close after `millis`, or in this tick when that is nought. */
78
+ readonly closeAfter: (millis: number) => void,
79
+ /** Forget whatever was scheduled. Arriving anywhere calls this first. */
80
+ readonly cancel: () => void,
81
+ |};
82
+
83
+ /**
84
+ * A group of tooltips that share one clock.
85
+ *
86
+ * `Tooltip.Provider` holds it; `Tooltip.Root` asks it what delay to use. A
87
+ * tooltip outside a provider never sees one and uses its own delay, which is
88
+ * the behaviour a tooltip on its own has always had.
89
+ */
90
+ export type DelayGroup = {|
91
+ /** `own`, or nought while the group is inside its skip window. */
92
+ readonly delayFor: (own: number) => number,
93
+ /** Told that a tooltip in the group has opened. */
94
+ readonly opened: () => void,
95
+ /** Told that one has closed, which is what starts the window. */
96
+ readonly closed: () => void,
97
+ |};
98
+
99
+ /**
100
+ * A pending open or close, restartable, and cancelled when the component goes.
101
+ *
102
+ * `setOpen` is called with the answer rather than with a toggle, so a schedule
103
+ * that is overtaken by a second one does not leave the component holding the
104
+ * first one's opinion.
105
+ */
106
+ export hook useHoverIntent(setOpen: (open: boolean) => void): HoverIntent {
107
+ const timer = useRef<TimeoutID | null>(null);
108
+ const change = useStableCallback(setOpen);
109
+
110
+ const cancel = useStableCallback(() => {
111
+ if (timer.current != null) {
112
+ clearTimeout(timer.current);
113
+ timer.current = null;
114
+ }
115
+ });
116
+
117
+ const schedule = useStableCallback((open: boolean, millis: number) => {
118
+ cancel();
119
+ if (millis <= 0) {
120
+ // In this tick, not in a zero-millisecond timeout. A tooltip that opens
121
+ // on focus, and the second tooltip in a toolbar, both have to be open by
122
+ // the time the event handler returns — a test that has to advance a clock
123
+ // to see them is describing a wait the reader would also have had.
124
+ change(open);
125
+ return;
126
+ }
127
+ timer.current = setTimeout(() => {
128
+ timer.current = null;
129
+ change(open);
130
+ }, millis);
131
+ });
132
+
133
+ // The component can be taken away while a tooltip is waiting to open, and a
134
+ // timer that outlives it sets state on something that is gone.
135
+ useEffect(() => cancel, [cancel]);
136
+
137
+ return useMemo(
138
+ () => ({
139
+ cancel,
140
+ closeAfter: (millis: number) => schedule(false, millis),
141
+ openAfter: (millis: number) => schedule(true, millis),
142
+ }),
143
+ [cancel, schedule],
144
+ );
145
+ }
146
+
147
+ /**
148
+ * The shared clock behind `Tooltip.Provider`.
149
+ *
150
+ * Refs rather than state, and that is the whole design: nothing here is
151
+ * rendered. A group that held "are we skipping" in state would re-render every
152
+ * tooltip in a toolbar twice for each one the pointer passed over, to change
153
+ * nothing anybody can see.
154
+ *
155
+ * The window is open while a tooltip in the group is showing — moving along a
156
+ * toolbar with one already open is the case that must not stutter — and for
157
+ * `skipDelay` after the last one closes.
158
+ */
159
+ export hook useDelayGroup(skipDelay: number): DelayGroup {
160
+ const skipping = useRef(false);
161
+ const timer = useRef<TimeoutID | null>(null);
162
+
163
+ const stop = useStableCallback(() => {
164
+ if (timer.current != null) {
165
+ clearTimeout(timer.current);
166
+ timer.current = null;
167
+ }
168
+ });
169
+
170
+ useEffect(() => stop, [stop]);
171
+
172
+ return useMemo(
173
+ () => ({
174
+ closed: () => {
175
+ stop();
176
+ if (skipDelay <= 0) {
177
+ skipping.current = false;
178
+ return;
179
+ }
180
+ skipping.current = true;
181
+ timer.current = setTimeout(() => {
182
+ timer.current = null;
183
+ skipping.current = false;
184
+ }, skipDelay);
185
+ },
186
+ delayFor: (own: number) => (skipping.current ? 0 : own),
187
+ opened: () => {
188
+ // While one is open the group is answering instantly, and the window
189
+ // does not start counting down until it closes.
190
+ stop();
191
+ skipping.current = true;
192
+ },
193
+ }),
194
+ [skipDelay, stop],
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Close on `Escape`, from wherever focus happens to be.
200
+ *
201
+ * On the document, because the content shown on hover holds no focus and a
202
+ * handler on it would never be reached — which is exactly why the hand-written
203
+ * version fails the dismissible clause rather than implementing it wrongly.
204
+ *
205
+ * Capture, and `stopPropagation`, so one `Escape` is one dismissal: a tooltip
206
+ * inside a dialog answers the key itself rather than leaving the reader with a
207
+ * dialog that closed because a tooltip was showing.
208
+ */
209
+ export hook useDismissOnEscape(
210
+ open: boolean,
211
+ ref: { current: HTMLElement | null },
212
+ onDismiss: () => void,
213
+ ): void {
214
+ const dismiss = useStableCallback(onDismiss);
215
+
216
+ useEffect(() => {
217
+ const document = ref.current?.ownerDocument;
218
+ if (!open || document == null) {
219
+ return;
220
+ }
221
+ const onKeyDown = (event: $FlowFixMe) => {
222
+ if (event.key !== "Escape") {
223
+ return;
224
+ }
225
+ event.stopPropagation();
226
+ dismiss();
227
+ };
228
+ document.addEventListener("keydown", onKeyDown, true);
229
+ return () => document.removeEventListener("keydown", onKeyDown, true);
230
+ }, [open, ref, dismiss]);
231
+ }
232
+
233
+ /**
234
+ * Refuse a trigger the keyboard cannot reach.
235
+ *
236
+ * A tooltip on a `<span>` is a tooltip only a mouse can find, and it looks
237
+ * perfect: the markup is right, the styles are right, and a reader who never
238
+ * touches a mouse is told nothing at all. It is the failure this package exists
239
+ * to make loud, so it is an error rather than a warning — the same answer
240
+ * `useDialog` gives to a part outside its root.
241
+ *
242
+ * Checked in an effect because it is a question about an element, and the
243
+ * element does not exist until one has been committed.
244
+ */
245
+ export hook useFocusableTrigger(ref: { current: HTMLElement | null }, part: string): void {
246
+ useEffect(() => {
247
+ const element = ref.current;
248
+ if (element == null || element.matches(FOCUS_STOPS)) {
249
+ return;
250
+ }
251
+ throw new Error(
252
+ `${part} must be something the keyboard can reach: it was rendered onto ` +
253
+ `<${element.tagName.toLowerCase()}>, which is not focusable, so the ` +
254
+ `content would only ever appear for a pointer. Render a button or a ` +
255
+ `link, or give the element a tabindex of 0. A disabled control is not ` +
256
+ `focusable either: use aria-disabled and keep it in the tab order.`,
257
+ );
258
+ }, [ref, part]);
259
+ }
@@ -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
+ }