@uniflowed/ui 0.0.0-alpha.13 → 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.
- package/accordion.js +21 -3
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/context-menu.js +198 -0
- package/field.js +192 -25
- package/index.js +102 -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/switch.js +5 -3
- package/toggle.js +3 -2
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`
|
|
23
|
+
// # `Enter` submits the form; it does not toggle
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the
|
|
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
|
|
234
|
+
if (disabled) {
|
|
67
235
|
return;
|
|
68
236
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
// #
|
|
26
|
+
// # The height, for a stylesheet that animates it
|
|
27
27
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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/context-menu.js
ADDED
|
@@ -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";
|