@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 CHANGED
@@ -54,6 +54,19 @@
54
54
  // are here because a long FAQ is nicer with them — but nothing about them takes
55
55
  // a header out of the tab order.
56
56
  //
57
+ // # The height a stylesheet animates to
58
+ //
59
+ // `measure` on the root puts each panel's would-be height on it as
60
+ // `--uf-collapsible-height` — one property name across both disclosure
61
+ // components, because it is one measurement and a second name would be a second
62
+ // rule to keep in step. `collapsible.js`'s header shows the stylesheet, and
63
+ // `internal/disclosure.js` holds the measuring pass and the argument for the
64
+ // opt-in.
65
+ //
66
+ // It is on `Accordion.Root` rather than on each `Accordion.Content` because a
67
+ // forty-section FAQ is one decision, made once, rather than forty props that
68
+ // have to agree.
69
+ //
57
70
  // # How the sections are found
58
71
  //
59
72
  // By a `data-*` attribute of this package's own rather than by role, which is
@@ -81,7 +94,7 @@ import type { Rest } from "./internal/merge-props.js";
81
94
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
82
95
  import { moveOnKey } from "./internal/roving-focus.js";
83
96
  import type { RovingSet } from "./internal/roving-focus.js";
84
- import { usePresence, useUntilFound } from "./internal/disclosure.js";
97
+ import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
85
98
  import { useControlled } from "./internal/controlled-state.js";
86
99
 
87
100
  /** Whether one section is open at a time, or any number of them. */
@@ -110,6 +123,8 @@ type AccordionState = {|
110
123
  /** Whether closing the last open section is allowed; only meaningful for `single`. */
111
124
  readonly closable: boolean,
112
125
  readonly type: AccordionType,
126
+ /** Whether each panel carries its measured height; see the module header. */
127
+ readonly measure: boolean,
113
128
  |};
114
129
 
115
130
  const AccordionContext: React.Context<AccordionState | null> = createContext(null);
@@ -160,6 +175,7 @@ export component AccordionRoot(
160
175
  defaultValue?: $ReadOnlyArray<string> = NOTHING,
161
176
  value?: $ReadOnlyArray<string>,
162
177
  onValueChange?: (value: $ReadOnlyArray<string>) => void,
178
+ measure?: boolean = false,
163
179
  ...rest: Rest
164
180
  ) {
165
181
  const [open, setOpen] = useControlled<$ReadOnlyArray<string>>(value, defaultValue, onValueChange);
@@ -184,8 +200,8 @@ export component AccordionRoot(
184
200
  const state = useMemo(
185
201
  // `collapsible` only ever narrows a `single` accordion: in `multiple` mode
186
202
  // every section closes on its own, and there is no last one to protect.
187
- () => ({ open, toggle, closable: type === "multiple" || collapsible, type }),
188
- [open, toggle, type, collapsible],
203
+ () => ({ open, toggle, closable: type === "multiple" || collapsible, type, measure }),
204
+ [open, toggle, type, collapsible, measure],
189
205
  );
190
206
  const passed = withoutComposed(rest, ["onKeyDown"]);
191
207
 
@@ -312,10 +328,12 @@ export component AccordionTrigger(children: React.Node, ...rest: Rest) {
312
328
  * what `hidden` is upgraded to for it.
313
329
  */
314
330
  export component AccordionContent(children: React.Node, ...rest: Rest) {
331
+ const accordion = useAccordion("Accordion.Content");
315
332
  const item = useAccordionItem("Accordion.Content");
316
333
  const contentRef = useRef<HTMLElement | null>(null);
317
334
  usePresence(item.registerContent);
318
335
  useUntilFound(contentRef, item.open);
336
+ useMeasuredHeight(contentRef, accordion.measure);
319
337
 
320
338
  return (
321
339
  <div
package/checkbox.js CHANGED
@@ -20,12 +20,49 @@
20
20
  // `indeterminate` is a prop, and clicking a mixed checkbox reports `true`,
21
21
  // which is the state a reader expects "select all" to move to.
22
22
  //
23
- // # `Enter` is deliberately not handled
23
+ // # `Enter` submits the form; it does not toggle
24
24
  //
25
- // `Space` toggles; `Enter` is left alone, so a checkbox inside a form still
26
- // submits it. That is the difference between a control that answers a question
27
- // and one that operates a thing — `switch.js` takes `Enter` because a switch is
28
- // the second kind.
25
+ // This is the difference between a control that answers a question and one that
26
+ // operates a thing — `switch.js` takes `Enter` because a switch is the second
27
+ // kind — and for a long time this header claimed it by *leaving the key alone*.
28
+ // Neither half of that claim survived contact with the component, which is
29
+ // ubugeeei-prod/uf#324:
30
+ //
31
+ // * **A `<button type="button">` never submits a form.** That is the whole of
32
+ // what `type="button"` means, and this renders one. So the form was not
33
+ // submitted by anybody.
34
+ // * **And leaving a key unhandled does not make it inert.** It lets the
35
+ // *default action* happen, and the default action of `Enter` on a focused
36
+ // `<button>` is a click — which this component's own `onClick` turns into a
37
+ // toggle. So a focused checkbox both failed to submit the form and changed
38
+ // its own state, which is the opposite of the intent on both counts.
39
+ //
40
+ // What a native `<input type="checkbox">` does with `Enter` is *implicit
41
+ // submission*: the key does not touch the checkbox, and the form around it is
42
+ // submitted as though its default button had been pressed. That is the
43
+ // behaviour this replaces, so it is the behaviour it owes, and it is written
44
+ // out here because a styled substitute that silently drops it is exactly the
45
+ // kind of regression `index.js` says this package exists to prevent.
46
+ //
47
+ // So `Enter` is claimed — `preventDefault()`, which is what stops the browser's
48
+ // click and the toggle behind it — and turned back into a submission of the
49
+ // `<form>` the control is in. Outside a form it does nothing at all, which is
50
+ // again what the native control does. `requestSubmit` rather than `submit`
51
+ // because only the first fires the `submit` event and runs constraint
52
+ // validation, and a `<form onSubmit>` that never heard about the submission is
53
+ // the failure this would otherwise trade for the old one.
54
+ //
55
+ // The default button is passed to it rather than left out. `requestSubmit()`
56
+ // with no argument submits with *no* submitter, so a form whose handler reads
57
+ // `event.submitter` — a Server Action's `formAction`, a "save" and a "save and
58
+ // close" beside each other — would be told nobody pressed anything. Implicit
59
+ // submission names the default button, so this does too.
60
+ //
61
+ // Which button that is, and whether a form with no button submits at all, are
62
+ // both the specification's questions rather than this component's, and both are
63
+ // answered below: a submit button belongs to the form that *owns* it rather
64
+ // than to the form it sits inside, and a form with no submit button submits
65
+ // itself only while at most one of its fields blocks implicit submission.
29
66
 
30
67
  "use client";
31
68
 
@@ -35,6 +72,137 @@ import type { Rest } from "./internal/merge-props.js";
35
72
  import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
36
73
  import { useControlled } from "./internal/controlled-state.js";
37
74
 
75
+ /**
76
+ * The button a form would submit itself through, or nothing.
77
+ *
78
+ * The specification's "default button" is the first submit button in tree order
79
+ * **whose form owner is this form**, and neither half of that is "a
80
+ * descendant". A control's form owner is the `form` attribute when it has one
81
+ * and the nearest ancestor `<form>` otherwise, so the two cases a subtree
82
+ * search gets wrong are both real markup:
83
+ *
84
+ * * `<button type="submit" form="signup">` *beside* the form — the pattern a
85
+ * dialog's footer is written in — is the default button and a subtree
86
+ * search never sees it. Missing it is not a small error: it falls through
87
+ * to the no-submitter branch, which is the exact `event.submitter` this
88
+ * component exists to answer.
89
+ * * `<button type="submit" form="other">` *inside* the form belongs to the
90
+ * other one, and handing it to `requestSubmit` throws `NotFoundError` —
91
+ * which reaches a reader as a key that does nothing and a console the page
92
+ * did not write.
93
+ *
94
+ * So the search is over the form's root and each candidate is asked which form
95
+ * it belongs to. The root rather than the document, because a form in a shadow
96
+ * tree, or one rendered but not yet inserted, has to find its own buttons and
97
+ * only its own.
98
+ *
99
+ * A `<button>` with no `type` is a submit button, which is the case most easily
100
+ * missed. A disabled one is skipped, because the browser skips it — implicit
101
+ * submission through a button nobody could press is not a thing the platform
102
+ * does.
103
+ */
104
+ function defaultButtonOf(form: HTMLElement): HTMLElement | null {
105
+ const searched: $FlowFixMe = (form as $FlowFixMe).getRootNode?.() ?? form.ownerDocument;
106
+ if (searched == null || typeof searched.querySelectorAll !== "function") {
107
+ return null;
108
+ }
109
+ const candidates = searched.querySelectorAll(
110
+ 'button:not([type]), button[type="submit"], input[type="submit"], input[type="image"]',
111
+ );
112
+ for (const candidate of candidates) {
113
+ const button: $FlowFixMe = candidate;
114
+ if (button.form === form && button.disabled !== true) {
115
+ return button as $FlowFixMe;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * The `<input>` types that block implicit submission.
123
+ *
124
+ * The specification's list, copied rather than reasoned about, because what is
125
+ * being reproduced is what the browser does. A checkbox, a radio, a hidden
126
+ * field, a `<select>` and a `<textarea>` are not on it.
127
+ */
128
+ const BLOCKING_TYPES: Set<string> = new Set([
129
+ "date",
130
+ "datetime-local",
131
+ "email",
132
+ "month",
133
+ "number",
134
+ "password",
135
+ "search",
136
+ "tel",
137
+ "text",
138
+ "time",
139
+ "url",
140
+ "week",
141
+ ]);
142
+
143
+ /**
144
+ * Whether the platform would decline to submit `form` from the form itself.
145
+ *
146
+ * The other half of the implicit submission rule, and the half a script never
147
+ * meets: a form with **no** submit button is submitted implicitly only when at
148
+ * most one of its fields blocks implicit submission. A login form with a
149
+ * username and a password and no button is the everyday case — `Enter` in
150
+ * either field does nothing at all in every browser.
151
+ *
152
+ * `requestSubmit()` does not apply that rule, and is right not to: it is the
153
+ * route a script takes to submit deliberately. A component reproducing the
154
+ * *implicit* mechanism has to apply it here, or `Enter` on a checkbox submits
155
+ * forms that `Enter` in the text field beside it would not — which is the same
156
+ * class of divergence this whole change is about, pointing the other way.
157
+ */
158
+ function moreThanOneFieldBlocks(form: $FlowFixMe): boolean {
159
+ const fields: $FlowFixMe = form.elements;
160
+ if (fields == null) {
161
+ return false;
162
+ }
163
+ let blocking = 0;
164
+ for (const field of fields) {
165
+ const control: $FlowFixMe = field;
166
+ // `.type` rather than the attribute: it is missing on `<input>` and any
167
+ // value the specification does not know is the Text state, and both of
168
+ // those block.
169
+ if (control.tagName === "INPUT" && BLOCKING_TYPES.has(String(control.type))) {
170
+ blocking += 1;
171
+ if (blocking > 1) {
172
+ return true;
173
+ }
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Submit the form this control is in, the way `Enter` on a native checkbox does.
181
+ *
182
+ * Does nothing when there is no form, when the browser has no `requestSubmit` —
183
+ * it is everywhere current, and a checkbox that threw on an old one would be
184
+ * worse than a key that does nothing — or when the form is one the platform
185
+ * would not submit implicitly either, which is the rule
186
+ * `moreThanOneFieldBlocks` states.
187
+ */
188
+ function submitImplicitly(control: HTMLElement): void {
189
+ const form: $FlowFixMe = (control as $FlowFixMe).form;
190
+ if (form == null || typeof form.requestSubmit !== "function") {
191
+ return;
192
+ }
193
+ const submitter = defaultButtonOf(form);
194
+ if (submitter == null) {
195
+ // A form with no submit button submits itself, but only under the rule
196
+ // `moreThanOneFieldBlocks` carries — `requestSubmit()` will not apply it,
197
+ // so this does.
198
+ if (!moreThanOneFieldBlocks(form)) {
199
+ form.requestSubmit();
200
+ }
201
+ return;
202
+ }
203
+ form.requestSubmit(submitter);
204
+ }
205
+
38
206
  /** A checkbox, which may also be mixed. */
39
207
  export component Checkbox(
40
208
  checked?: boolean,
@@ -63,13 +231,23 @@ export component Checkbox(
63
231
  }
64
232
  })}
65
233
  onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
66
- if (disabled || event.key !== " ") {
234
+ if (disabled) {
67
235
  return;
68
236
  }
69
- // Stops `Space` scrolling the page, and stops the browser's own click
70
- // arriving afterwards and toggling this a second time.
71
- event.preventDefault();
72
- setOn(next);
237
+ if (event.key === " ") {
238
+ // Stops `Space` scrolling the page, and stops the browser's own click
239
+ // arriving afterwards and toggling this a second time.
240
+ event.preventDefault();
241
+ setOn(next);
242
+ return;
243
+ }
244
+ if (event.key === "Enter") {
245
+ // Claimed, and *not* to make the key inert: the default action here
246
+ // is a click on this button, and a click on this button toggles. See
247
+ // the module header for the whole of it.
248
+ event.preventDefault();
249
+ submitImplicitly(event.currentTarget as $FlowFixMe);
250
+ }
73
251
  })}
74
252
  role="checkbox"
75
253
  type="button"
package/collapsible.js CHANGED
@@ -23,16 +23,29 @@
23
23
  // `internal/disclosure.js` explains what is done instead, and why React needs a
24
24
  // hook to say it.
25
25
  //
26
- // # No height, yet
26
+ // # The height, for a stylesheet that animates it
27
27
  //
28
- // A collapsible that animates open needs the height its content *would* have,
29
- // which a stylesheet cannot compute — the obvious `useElementSize` from
30
- // `@uniflowed/hooks/dom` measures the element while it is hidden and reports
31
- // zero, which is exactly the moment the number is wanted. Getting it right
32
- // means a measuring pass with the panel briefly laid out and not painted, and
33
- // that is a piece of work of its own rather than a line to be added here — #330.
34
- // This component ships without it rather than with a custom property that reads
35
- // `0px`.
28
+ // `measure` puts the height the content *would* have on
29
+ // `Collapsible.Content` as `--uf-collapsible-height`, correct while the panel
30
+ // is still closed — which is the only moment it is any use, because
31
+ // `height: 0 → var(--uf-collapsible-height)` is a transition that has to know
32
+ // its destination before it starts. `internal/disclosure.js` holds the
33
+ // measuring pass and says why the obvious ways of asking all answer zero, and
34
+ // why the prop is opt-in rather than always on.
35
+ //
36
+ // [data-collapsible-content] {
37
+ // overflow: hidden;
38
+ // transition: height 150ms;
39
+ // height: 0;
40
+ // }
41
+ // [data-collapsible-content]:not([hidden]) {
42
+ // height: var(--uf-collapsible-height);
43
+ // }
44
+ //
45
+ // The selector is the caller's — a class, a `data-*` of their own, whatever
46
+ // they already style with. This package emits the number and no styles at all,
47
+ // which is the same division `internal/anchor.js` keeps for a popover: a
48
+ // stylesheet can say *how* to move, and only the component can say how far.
36
49
 
37
50
  "use client";
38
51
 
@@ -41,7 +54,7 @@ import { createContext, useContext, useId, useMemo, useRef, useState } from "@un
41
54
 
42
55
  import type { Rest } from "./internal/merge-props.js";
43
56
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
44
- import { usePresence, useUntilFound } from "./internal/disclosure.js";
57
+ import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
45
58
  import { useControlled } from "./internal/controlled-state.js";
46
59
 
47
60
  type CollapsibleState = {|
@@ -51,6 +64,8 @@ type CollapsibleState = {|
51
64
  /** Whether a `Collapsible.Content` is rendered, so the trigger names one that exists. */
52
65
  readonly present: boolean,
53
66
  readonly registerContent: (present: boolean) => void,
67
+ /** Whether the content carries its measured height; see the module header. */
68
+ readonly measure: boolean,
54
69
  |};
55
70
 
56
71
  const CollapsibleContext: React.Context<CollapsibleState | null> = createContext(null);
@@ -76,14 +91,15 @@ export component CollapsibleRoot(
76
91
  defaultOpen?: boolean = false,
77
92
  open?: boolean,
78
93
  onOpenChange?: (open: boolean) => void,
94
+ measure?: boolean = false,
79
95
  ) {
80
96
  const contentId = `${useId()}-content`;
81
97
  const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
82
98
  const [present, setPresent] = useState(false);
83
99
 
84
100
  const state = useMemo(
85
- () => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent }),
86
- [contentId, isOpen, setOpen, present],
101
+ () => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent, measure }),
102
+ [contentId, isOpen, setOpen, present, measure],
87
103
  );
88
104
 
89
105
  return <CollapsibleContext.Provider value={state}>{children}</CollapsibleContext.Provider>;
@@ -131,6 +147,7 @@ export component CollapsibleContent(children: React.Node, ...rest: Rest) {
131
147
  const contentRef = useRef<HTMLElement | null>(null);
132
148
  usePresence(collapsible.registerContent);
133
149
  useUntilFound(contentRef, collapsible.open);
150
+ useMeasuredHeight(contentRef, collapsible.measure);
134
151
 
135
152
  return (
136
153
  <div
package/combobox.js CHANGED
@@ -618,10 +618,10 @@ export component ComboboxOption(
618
618
  * a boundary they cannot see, and the heading is never a place the cursor can
619
619
  * land, because it is not an option.
620
620
  *
621
- * `children` is narrower than `Select.Group`'s `React.Node`, and the narrower
622
- * one is the true statement: a `group` inside a `listbox` may own options and
623
- * its own heading, and nothing else. `Select.Group` should say the same and
624
- * does not yet.
621
+ * `children` is the true statement rather than a `React.Node` that would take
622
+ * anything: a `group` inside a `listbox` may own options and its own heading,
623
+ * and nothing else. `Select.Group` says the same since ubugeeei-prod/uf#562 —
624
+ * it is the same listbox, and it took a second breaking change to get there.
625
625
  */
626
626
  export component ComboboxGroup(
627
627
  children: renders* (ComboboxOption | ComboboxGroupLabel),
@@ -0,0 +1,198 @@
1
+ // @flow
2
+ //
3
+ // The same menu, opened by the right button.
4
+ //
5
+ // Everything below the trigger is `menu.js` — the arrow keys, typeahead,
6
+ // submenus, `Escape` stacking, the roving tab stop and the two checkable item
7
+ // kinds — because a context menu *is* a menu and a second implementation of one
8
+ // would be a second set of keyboard bugs. What is here is the two things that
9
+ // make it a component rather than an `oncontextmenu` handler, and both of them
10
+ // are the parts people leave out.
11
+ //
12
+ // # It has to be reachable from the keyboard
13
+ //
14
+ // `Shift+F10` and the `ContextMenu` key open a context menu, on every platform,
15
+ // and a component that only listens for `contextmenu` is a WCAG 2.1.1 failure:
16
+ // the commands in it are reachable by pointer and by nothing else. Long press
17
+ // is the touch equivalent of the same gesture, and `@uniflowed/hooks/dom`'s
18
+ // `useLongPress` already knows what a long press is — including that a press
19
+ // that moves is a drag and not a press.
20
+ //
21
+ // The trigger is therefore focusable. That is a real cost and it is stated
22
+ // rather than hidden: a list of two hundred rows with a context menu on each is
23
+ // two hundred tab stops. A caller whose trigger already *contains* something
24
+ // focusable should pass `tabIndex={-1}` and let the keys arrive from inside it,
25
+ // which they do — the handler is on the trigger and the event bubbles. What is
26
+ // not on offer is leaving the keys out, because the alternative to a tab stop
27
+ // is a command a keyboard cannot reach.
28
+ //
29
+ // # It opens at a point, and sometimes at an element
30
+ //
31
+ // A context menu opened by the pointer belongs at the pointer — the reader is
32
+ // looking at their cursor, and a menu that appeared against the top-left corner
33
+ // of a table row is a menu they have to go and find. Opened by the keyboard
34
+ // there is no pointer, and the menu belongs against the element that has focus.
35
+ //
36
+ // So the anchor is a rectangle rather than an element, and
37
+ // `internal/anchor.js`'s `anchorRect` is the seam: the trigger element is still
38
+ // what the writing direction is read from and what focus goes back to, and only
39
+ // the *measurement* is replaced. `null` — which is what the keyboard path
40
+ // leaves behind — measures the trigger, so both routes end in one code path
41
+ // rather than two placements that drift.
42
+ //
43
+ // # The body is not named after the trigger
44
+ //
45
+ // `Menu.Body` names itself with `aria-labelledby` pointing at its trigger,
46
+ // because a dropdown menu's trigger is a button with a short label — "File" —
47
+ // and that is the menu's name. A context menu's trigger is arbitrary content: a
48
+ // table row, a canvas, a paragraph. Naming the menu after it would announce the
49
+ // whole row as the menu's name. So `ContextMenu.Trigger` registers itself as
50
+ // the thing focus returns to and *not* as a name, and the caller gives
51
+ // `ContextMenu.Body` an `aria-label`. That is the one attribute this component
52
+ // cannot supply and the reference page says so.
53
+
54
+ "use client";
55
+
56
+ import * as React from "@uniflowed/react";
57
+ import { useCallback, useContext, useMemo, useRef, useState } from "@uniflowed/react";
58
+ import { useLongPress } from "@uniflowed/hooks/dom";
59
+
60
+ import type { Rect } from "./internal/anchor.js";
61
+ import type { Rest } from "./internal/merge-props.js";
62
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
63
+ import { MenuAnchorContext, MenuContext, MenuLevel, useMenu } from "./internal/menu-tree.js";
64
+
65
+ /**
66
+ * Where the pointer was, or nothing when the keyboard opened the menu.
67
+ *
68
+ * Held by the root rather than by the trigger because the *body* is what reads
69
+ * it, and the body is a sibling of the trigger rather than a child of it.
70
+ */
71
+ type PointState = {|
72
+ readonly point: Rect | null,
73
+ readonly openAt: (point: Rect | null) => void,
74
+ |};
75
+
76
+ const PointContext: React.Context<PointState | null> = React.createContext(null);
77
+
78
+ /** A zero-sized box at a pointer's coordinates, which is what a point is. */
79
+ function pointAt(x: number, y: number): Rect {
80
+ return { height: 0, width: 0, x, y };
81
+ }
82
+
83
+ /**
84
+ * The trigger, the menu, and where the pointer was when it opened.
85
+ *
86
+ * Renders no element of its own, for the reason `Menu.Root` gives: the trigger
87
+ * and the body are siblings in whatever layout the caller wrote.
88
+ */
89
+ export component ContextMenuRoot(
90
+ children: React.Node,
91
+ defaultOpen?: boolean = false,
92
+ open?: boolean,
93
+ onOpenChange?: (open: boolean) => void,
94
+ ) {
95
+ // State rather than a ref, and that is load-bearing: the rectangle is one of
96
+ // the things the placement effect re-runs for, so a second right-click
97
+ // somewhere else has to be a new value React has committed rather than a
98
+ // mutation nothing heard about.
99
+ const [point, setPoint] = useState<Rect | null>(null);
100
+ const openAt = useCallback((next: Rect | null) => setPoint(next), []);
101
+ const state = useMemo(() => ({ point, openAt }), [point, openAt]);
102
+
103
+ return (
104
+ <PointContext.Provider value={state}>
105
+ <MenuAnchorContext.Provider value={point}>
106
+ <MenuLevel defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} parent={null}>
107
+ {children}
108
+ </MenuLevel>
109
+ </MenuAnchorContext.Provider>
110
+ </PointContext.Provider>
111
+ );
112
+ }
113
+
114
+ hook usePoint(part: string): PointState {
115
+ const state = useContext(PointContext);
116
+ if (state == null) {
117
+ throw new Error(`${part} must be rendered inside a ContextMenu.Root`);
118
+ }
119
+ return state;
120
+ }
121
+
122
+ /**
123
+ * The content the menu belongs to.
124
+ *
125
+ * A `<div>` rather than a button, because what a context menu hangs off is a
126
+ * region of the page. The module header says why it is in the tab order and
127
+ * when a caller should take it out again.
128
+ */
129
+ export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
130
+ const menu = useMenu("ContextMenu.Trigger");
131
+ const { openAt } = usePoint("ContextMenu.Trigger");
132
+ const triggerRef = useRef<HTMLElement | null>(null);
133
+ const passed = withoutComposed(rest, ["onContextMenu", "onKeyDown", "ref"]);
134
+
135
+ const openHere = useCallback(() => {
136
+ // No point: the menu goes against the element, which is where the reader's
137
+ // focus already is.
138
+ openAt(null);
139
+ menu.pendingFocus.current = "first";
140
+ menu.setOpen(true);
141
+ }, [menu, openAt]);
142
+
143
+ // The touch equivalent of the right button. `useLongPress` cancels itself
144
+ // when the pointer moves, so a drag across a list is not two hundred menus.
145
+ useLongPress(triggerRef, (event: Event) => {
146
+ const pointer: $FlowFixMe = event;
147
+ openAt(pointAt(pointer.clientX ?? 0, pointer.clientY ?? 0));
148
+ menu.pendingFocus.current = "first";
149
+ menu.setOpen(true);
150
+ });
151
+
152
+ return (
153
+ <div
154
+ // Above the spread, alone, because it is the one attribute here a caller
155
+ // is invited to overrule: the module header promises `tabIndex={-1}` to a
156
+ // caller whose trigger already contains something focusable, and a prop
157
+ // written *after* `{...passed}` wins over the caller's silently — which
158
+ // is a documented escape hatch that does nothing. Everything below the
159
+ // spread is this component's own and stays there.
160
+ tabIndex={0}
161
+ {...passed}
162
+ aria-haspopup="menu"
163
+ id={`${menu.base}-trigger`}
164
+ onContextMenu={composeHandlers(rest.onContextMenu, (event) => {
165
+ const press: $FlowFixMe = event;
166
+ // The browser's own menu would otherwise cover this one, and the reader
167
+ // would be looking at the platform's Back/Reload rather than at the
168
+ // commands the page has for what they pressed on.
169
+ press.preventDefault();
170
+ openAt(pointAt(press.clientX ?? 0, press.clientY ?? 0));
171
+ menu.pendingFocus.current = "first";
172
+ menu.setOpen(true);
173
+ })}
174
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
175
+ // Both spellings. `ContextMenu` is the dedicated key on a PC keyboard;
176
+ // `Shift+F10` is the one every platform has, and is what a laptop
177
+ // without that key leaves a reader with.
178
+ const asked =
179
+ event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey === true);
180
+ if (!asked) {
181
+ return;
182
+ }
183
+ event.preventDefault();
184
+ openHere();
185
+ })}
186
+ ref={composeRefs(rest.ref, (element) => {
187
+ triggerRef.current = element;
188
+ // What focus goes back to when the menu closes. It is deliberately not
189
+ // registered as the menu's *name*; see the module header.
190
+ menu.triggerRef.current = element;
191
+ })}
192
+ >
193
+ {children}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ export type { MenuSelect } from "./menu.js";