@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/field.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// The hard part of a form field is not the markup, it is the wiring: the label
|
|
6
6
|
// has to point at the control, the description and the error message have to be
|
|
7
7
|
// named by `aria-describedby`, the control has to say `aria-invalid` when it is
|
|
8
|
-
// wrong
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// announces nothing.
|
|
8
|
+
// wrong and `aria-required` before the reader gets there, and every id has to be
|
|
9
|
+
// unique on the page and stable across renders. Doing that by hand is five
|
|
10
|
+
// attributes and two `useId` calls per field, and getting one wrong is silent —
|
|
11
|
+
// the field looks right and a screen reader announces nothing.
|
|
12
12
|
//
|
|
13
13
|
// So the parts read the ids off a context the root creates. `Field.Label` knows
|
|
14
14
|
// which control it labels because there is exactly one in its root, and
|
|
@@ -17,14 +17,79 @@
|
|
|
17
17
|
// document makes a screen reader announce nothing at all, which is worse than
|
|
18
18
|
// omitting the attribute.
|
|
19
19
|
//
|
|
20
|
+
// # One place computes the four attributes
|
|
21
|
+
//
|
|
22
|
+
// This is the whole of ubugeeei-prod/uf#297, and it was a bug that looked like
|
|
23
|
+
// working code. `@uniflowed/form`'s `register("email")` also produces
|
|
24
|
+
// `aria-invalid` and `aria-describedby`, from the form store's errors, and it is
|
|
25
|
+
// right to: a form library is what knows whether a field is wrong. Spread both
|
|
26
|
+
// onto one `<input>` and the *later spread wins*, so the field was described by
|
|
27
|
+
// the form's message or by `Field.Description`, depending on argument order,
|
|
28
|
+
// and never by both.
|
|
29
|
+
//
|
|
30
|
+
// The division that fixes it is the one each package can actually keep:
|
|
31
|
+
//
|
|
32
|
+
// * **`Field` owns the ids and the attributes**, because it owns the label and
|
|
33
|
+
// the description and is the only thing that can compose a token list out of
|
|
34
|
+
// them.
|
|
35
|
+
// * **The form owns the facts**: is this field invalid, is it required, what
|
|
36
|
+
// does the message say, and what does the control have to be bound with.
|
|
37
|
+
//
|
|
38
|
+
// `FieldSource` is that handover, and `@uniflowed/form`'s `useFieldSource` is
|
|
39
|
+
// what fills it. Notice what it does *not* contain: no `aria-*` at all. A source
|
|
40
|
+
// that handed over a finished `aria-describedby` would be the same collision
|
|
41
|
+
// with an extra step.
|
|
42
|
+
//
|
|
43
|
+
// # Why the form fills a prop rather than the field reading a context
|
|
44
|
+
//
|
|
45
|
+
// Because the dependency can only run one way. `@uniflowed/ui` is published to
|
|
46
|
+
// npm and `@uniflowed/form` is not — its name has never been bound
|
|
47
|
+
// (ubugeeei-prod/uf#210) — and `tools/ci/publishable.sh` refuses a published
|
|
48
|
+
// package that depends on an unpublished one, because `npm install` would answer
|
|
49
|
+
// `ETARGET` for a package that installs perfectly well from this workspace. So
|
|
50
|
+
// `Field.Root` cannot import a form context, cannot take a `name` and look it
|
|
51
|
+
// up, and cannot know that `@uniflowed/form` exists.
|
|
52
|
+
//
|
|
53
|
+
// What it can do is state the shape it accepts and let the package that *may*
|
|
54
|
+
// depend on it produce one. `field={useFieldSource(form, "email")}` is one hook
|
|
55
|
+
// call where the application used to write `invalid={errors.email != null}` by
|
|
56
|
+
// hand and then choose which of two `aria-describedby` values survived.
|
|
57
|
+
//
|
|
20
58
|
// # Why it takes a render function
|
|
21
59
|
//
|
|
22
60
|
// `Field.Control` hands the attributes to a callback rather than rendering an
|
|
23
61
|
// `<input>`, because a field wraps a select, a textarea, a `Combobox.Input` or
|
|
24
62
|
// somebody else's component just as often, and each of those needs the same
|
|
25
|
-
//
|
|
63
|
+
// attributes on whatever element it eventually renders. A component that
|
|
26
64
|
// rendered the input itself would have to grow a prop for every element anyone
|
|
27
65
|
// might want, and would still be wrong for the next one.
|
|
66
|
+
//
|
|
67
|
+
// # A group is not a label's `for`
|
|
68
|
+
//
|
|
69
|
+
// `<label for>` names *one* form control. A set of radios, a checkbox group and
|
|
70
|
+
// a date picker made of three selects have no single control to point at, and a
|
|
71
|
+
// `for` aimed at the wrapper around them points at something that is not a form
|
|
72
|
+
// control — which browsers ignore, silently. `Field.Root group` is the answer:
|
|
73
|
+
// the root becomes `role="group"` named by the label through `aria-labelledby`,
|
|
74
|
+
// the label stops being a `<label>`, and the description and the error describe
|
|
75
|
+
// the set rather than one member of it.
|
|
76
|
+
//
|
|
77
|
+
// # Why `Field.Error` is `role="alert"` and `combobox.js` says the opposite
|
|
78
|
+
//
|
|
79
|
+
// `combobox.js`'s header argues that a live region inserted *together with* its
|
|
80
|
+
// content is usually not announced, and that a status region has to be in the
|
|
81
|
+
// document before there is anything to say. Both are true, and this component
|
|
82
|
+
// does the opposite on purpose.
|
|
83
|
+
//
|
|
84
|
+
// The difference is politeness. `role="status"` is polite: a screen reader waits
|
|
85
|
+
// for a pause, by which time a region that appeared and filled in one commit has
|
|
86
|
+
// nothing left to distinguish it, and several engines never announce it at all.
|
|
87
|
+
// `role="alert"` is assertive, and assertive regions are announced on insertion
|
|
88
|
+
// by every engine that implements them — it is what `alert` is for. So the
|
|
89
|
+
// constraint is real and it is `status`'s, not `alert`'s. Written down here
|
|
90
|
+
// because the package states the general rule strongly in one file and does the
|
|
91
|
+
// opposite in another, and a reader who finds the second one first is entitled
|
|
92
|
+
// to know it was a decision.
|
|
28
93
|
|
|
29
94
|
"use client";
|
|
30
95
|
|
|
@@ -32,6 +97,29 @@ import * as React from "@uniflowed/react";
|
|
|
32
97
|
import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
|
|
33
98
|
|
|
34
99
|
import type { Rest } from "./internal/merge-props.js";
|
|
100
|
+
import { withProps } from "./internal/merge-props.js";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What a form library tells a field about one of its fields.
|
|
104
|
+
*
|
|
105
|
+
* Facts, and no attributes: see the module header for why an `aria-describedby`
|
|
106
|
+
* in here would be the collision this type exists to end. `control` is the
|
|
107
|
+
* binding — the `name`, the `ref` the store attaches through, `onChange`,
|
|
108
|
+
* `onBlur` and the constraint attributes a progressive form emits — spread onto
|
|
109
|
+
* whatever element `Field.Control` renders, underneath the attributes the field
|
|
110
|
+
* computes.
|
|
111
|
+
*/
|
|
112
|
+
export type FieldSource = {|
|
|
113
|
+
readonly invalid: boolean,
|
|
114
|
+
readonly required: boolean,
|
|
115
|
+
readonly disabled: boolean,
|
|
116
|
+
/** What is wrong, or null while the field is valid. */
|
|
117
|
+
readonly message: string | null,
|
|
118
|
+
readonly control: Rest,
|
|
119
|
+
|};
|
|
120
|
+
|
|
121
|
+
/** No form: the shape the field falls back to, made once. */
|
|
122
|
+
const NO_CONTROL: Rest = Object.freeze({});
|
|
35
123
|
|
|
36
124
|
type FieldState = {|
|
|
37
125
|
readonly controlId: string,
|
|
@@ -39,6 +127,10 @@ type FieldState = {|
|
|
|
39
127
|
readonly descriptionId: string,
|
|
40
128
|
readonly errorId: string,
|
|
41
129
|
readonly invalid: boolean,
|
|
130
|
+
readonly required: boolean,
|
|
131
|
+
readonly group: boolean,
|
|
132
|
+
readonly message: string | null,
|
|
133
|
+
readonly control: Rest,
|
|
42
134
|
readonly describedBy: string | void,
|
|
43
135
|
readonly registerDescription: (present: boolean) => void,
|
|
44
136
|
readonly registerError: (present: boolean) => void,
|
|
@@ -63,24 +155,43 @@ hook useField(part: string): FieldState {
|
|
|
63
155
|
/**
|
|
64
156
|
* The field's container, and the only place ids are made.
|
|
65
157
|
*
|
|
66
|
-
* `invalid`
|
|
67
|
-
* parts have to agree about
|
|
68
|
-
* message is rendered or not, and the control's
|
|
69
|
-
* error's id or not.
|
|
158
|
+
* `invalid` and `required` are the root's business rather than the control's
|
|
159
|
+
* because three parts have to agree about each: the control says `aria-invalid`
|
|
160
|
+
* and `aria-required`, the error message is rendered or not, and the control's
|
|
161
|
+
* `aria-describedby` includes the error's id or not.
|
|
162
|
+
*
|
|
163
|
+
* `field` is the same two facts arriving from a form store instead of from the
|
|
164
|
+
* caller's hand, and it wins over the props when it is there — a field inside a
|
|
165
|
+
* form has one source of truth about its own validity, and the props are what a
|
|
166
|
+
* field with no form around it uses. Passing both is not an error; it is a
|
|
167
|
+
* caller saying "and also mark it invalid", which is what `invalid || source`
|
|
168
|
+
* means and what a server-side error arriving beside a client-side one needs.
|
|
70
169
|
*/
|
|
71
|
-
export component FieldRoot(
|
|
170
|
+
export component FieldRoot(
|
|
171
|
+
children: React.Node,
|
|
172
|
+
invalid?: boolean = false,
|
|
173
|
+
required?: boolean = false,
|
|
174
|
+
field?: FieldSource,
|
|
175
|
+
group?: boolean = false,
|
|
176
|
+
...rest: Rest
|
|
177
|
+
) {
|
|
72
178
|
const base = useId();
|
|
73
179
|
const [hasDescription, setHasDescription] = useState(false);
|
|
74
180
|
const [hasError, setHasError] = useState(false);
|
|
181
|
+
const sourceInvalid = field?.invalid ?? false;
|
|
182
|
+
const sourceRequired = field?.required ?? false;
|
|
183
|
+
const message = field?.message ?? null;
|
|
184
|
+
const control = field?.control ?? NO_CONTROL;
|
|
75
185
|
|
|
76
186
|
const state = useMemo(() => {
|
|
77
187
|
const descriptionId = `${base}-description`;
|
|
78
188
|
const errorId = `${base}-error`;
|
|
189
|
+
const wrong = invalid || sourceInvalid;
|
|
79
190
|
// Only ids that are in the document. `aria-describedby` naming a missing
|
|
80
191
|
// element makes a screen reader announce nothing rather than skipping it.
|
|
81
192
|
const described = [
|
|
82
193
|
hasDescription ? descriptionId : null,
|
|
83
|
-
|
|
194
|
+
wrong && hasError ? errorId : null,
|
|
84
195
|
].filter(Boolean);
|
|
85
196
|
|
|
86
197
|
return {
|
|
@@ -88,25 +199,64 @@ export component FieldRoot(children: React.Node, invalid?: boolean = false, ...r
|
|
|
88
199
|
labelId: `${base}-label`,
|
|
89
200
|
descriptionId,
|
|
90
201
|
errorId,
|
|
91
|
-
invalid,
|
|
202
|
+
invalid: wrong,
|
|
203
|
+
required: required || sourceRequired,
|
|
204
|
+
group,
|
|
205
|
+
message,
|
|
206
|
+
control,
|
|
92
207
|
describedBy: described.length === 0 ? undefined : described.join(" "),
|
|
93
208
|
registerDescription: setHasDescription,
|
|
94
209
|
registerError: setHasError,
|
|
95
210
|
};
|
|
96
|
-
}, [
|
|
211
|
+
}, [
|
|
212
|
+
base,
|
|
213
|
+
invalid,
|
|
214
|
+
sourceInvalid,
|
|
215
|
+
required,
|
|
216
|
+
sourceRequired,
|
|
217
|
+
group,
|
|
218
|
+
message,
|
|
219
|
+
control,
|
|
220
|
+
hasDescription,
|
|
221
|
+
hasError,
|
|
222
|
+
]);
|
|
97
223
|
|
|
98
224
|
return (
|
|
99
225
|
<FieldContext.Provider value={state}>
|
|
100
|
-
<div
|
|
226
|
+
<div
|
|
227
|
+
{...rest}
|
|
228
|
+
// A group names itself, describes itself and reports its own validity,
|
|
229
|
+
// because there is no one control inside it to carry any of the three.
|
|
230
|
+
// See the module header.
|
|
231
|
+
aria-describedby={group ? state.describedBy : undefined}
|
|
232
|
+
aria-invalid={group && state.invalid ? "true" : undefined}
|
|
233
|
+
aria-labelledby={group ? state.labelId : undefined}
|
|
234
|
+
role={group ? "group" : undefined}
|
|
235
|
+
>
|
|
236
|
+
{children}
|
|
237
|
+
</div>
|
|
101
238
|
</FieldContext.Provider>
|
|
102
239
|
);
|
|
103
240
|
}
|
|
104
241
|
|
|
105
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* The label, pointing at the control by id rather than by nesting.
|
|
244
|
+
*
|
|
245
|
+
* In a group it is a `<span>` instead, and the group points at *it*: a
|
|
246
|
+
* `<label for>` naming something that is not a form control is ignored by every
|
|
247
|
+
* browser, and ignored silently. The module header says more.
|
|
248
|
+
*/
|
|
106
249
|
export component FieldLabel(children: React.Node, ...rest: Rest) {
|
|
107
250
|
const field = useField("Field.Label");
|
|
108
251
|
// `rest` first: a caller `id` here would break the relationship the control
|
|
109
252
|
// points at, and it would break it silently.
|
|
253
|
+
if (field.group) {
|
|
254
|
+
return (
|
|
255
|
+
<span {...rest} id={field.labelId}>
|
|
256
|
+
{children}
|
|
257
|
+
</span>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
110
260
|
return (
|
|
111
261
|
<label {...rest} htmlFor={field.controlId} id={field.labelId}>
|
|
112
262
|
{children}
|
|
@@ -117,16 +267,27 @@ export component FieldLabel(children: React.Node, ...rest: Rest) {
|
|
|
117
267
|
/**
|
|
118
268
|
* The control, given every attribute the rest of the field implies.
|
|
119
269
|
*
|
|
120
|
-
* See the module header for why this takes a render function
|
|
270
|
+
* See the module header for why this takes a render function, and for the
|
|
271
|
+
* division of labour that decides what is in here. The form's own binding is
|
|
272
|
+
* underneath, so a caller who spreads these onto an element gets the store's
|
|
273
|
+
* `ref` and handlers *and* the field's attributes from one spread instead of
|
|
274
|
+
* two that overwrite each other.
|
|
121
275
|
*/
|
|
122
276
|
export component FieldControl(render: (props: Rest) => React.Node) {
|
|
123
277
|
const field = useField("Field.Control");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"aria-
|
|
129
|
-
|
|
278
|
+
// A group already carries the name, the description and the validity, and a
|
|
279
|
+
// control repeating them makes a reader hear the error once for the set and
|
|
280
|
+
// again for the member. What is left is what only the control can say.
|
|
281
|
+
const own: Rest = field.group
|
|
282
|
+
? { "aria-required": field.required ? "true" : undefined }
|
|
283
|
+
: {
|
|
284
|
+
id: field.controlId,
|
|
285
|
+
"aria-labelledby": field.labelId,
|
|
286
|
+
"aria-describedby": field.describedBy,
|
|
287
|
+
"aria-invalid": field.invalid ? "true" : undefined,
|
|
288
|
+
"aria-required": field.required ? "true" : undefined,
|
|
289
|
+
};
|
|
290
|
+
return render(withProps(field.control, own));
|
|
130
291
|
}
|
|
131
292
|
|
|
132
293
|
/** Help text, which the control points at while it is rendered. */
|
|
@@ -149,9 +310,15 @@ export component FieldDescription(children: React.Node, ...rest: Rest) {
|
|
|
149
310
|
* The error message, rendered only when the field is invalid.
|
|
150
311
|
*
|
|
151
312
|
* `role="alert"` so it is announced when it appears, which is the point of an
|
|
152
|
-
* error that arrives after a blur or a
|
|
313
|
+
* error that arrives after a blur, a submit, or a Server Action — see the
|
|
314
|
+
* module header for why an assertive region may be inserted with its text where
|
|
315
|
+
* a polite one may not.
|
|
316
|
+
*
|
|
317
|
+
* With no children it shows the message the form gave, so a field bound to a
|
|
318
|
+
* store does not need the caller to reach back into `formState.errors` for a
|
|
319
|
+
* string the source is already carrying.
|
|
153
320
|
*/
|
|
154
|
-
export component FieldError(children
|
|
321
|
+
export component FieldError(children?: React.Node, ...rest: Rest) {
|
|
155
322
|
const field = useField("Field.Error");
|
|
156
323
|
const register = field.registerError;
|
|
157
324
|
useEffect(() => {
|
|
@@ -164,7 +331,7 @@ export component FieldError(children: React.Node, ...rest: Rest) {
|
|
|
164
331
|
}
|
|
165
332
|
return (
|
|
166
333
|
<p {...rest} id={field.errorId} role="alert">
|
|
167
|
-
{children}
|
|
334
|
+
{children ?? field.message}
|
|
168
335
|
</p>
|
|
169
336
|
);
|
|
170
337
|
}
|
package/index.js
CHANGED
|
@@ -98,7 +98,13 @@
|
|
|
98
98
|
// than what they replaced. Each module's header says what it gives that the
|
|
99
99
|
// plain element does not; if it ever stops being true, the component should
|
|
100
100
|
// be deleted rather than fixed.
|
|
101
|
-
// - `menu.js` — the arrow keys, typeahead,
|
|
101
|
+
// - `menu.js`, `context-menu.js` and `menubar.js` — the arrow keys, typeahead,
|
|
102
|
+
// submenus and `Escape` stacking, and the two components that are that
|
|
103
|
+
// behaviour opened differently. A context menu is opened by the right button,
|
|
104
|
+
// by `Shift+F10` and by a long press, and opens at a *point*; a menubar is a
|
|
105
|
+
// row of them with one tab stop and arrows that walk between the open menus.
|
|
106
|
+
// shadcn's fourth menu, Dropdown Menu, is `menu.js` under another name and is
|
|
107
|
+
// deliberately not a second export.
|
|
102
108
|
// - `combobox.js` — `aria-activedescendant` over a filtered list, the count a
|
|
103
109
|
// screen reader is told, and the option groups that make a command palette a
|
|
104
110
|
// composition rather than a seventh module.
|
|
@@ -138,7 +144,7 @@
|
|
|
138
144
|
// is by primitive because that is the unit a reader looks for, the unit a
|
|
139
145
|
// bundler drops, and the unit the WAI-ARIA practices are written in.
|
|
140
146
|
//
|
|
141
|
-
// `internal/` holds
|
|
147
|
+
// `internal/` holds ten modules and nothing else, each a rule the primitives
|
|
142
148
|
// must apply identically and a consumer must not be able to apply differently:
|
|
143
149
|
// `merge-props.js` (the caller's props go on first, the component's semantics
|
|
144
150
|
// last), `controlled-state.js` (what "controlled" means here),
|
|
@@ -151,8 +157,10 @@
|
|
|
151
157
|
// fit where it was asked to go), `focus.js` (which elements a reader can reach,
|
|
152
158
|
// which a focus trap and a popover want opposite things from), and
|
|
153
159
|
// `hover-intent.js` (what WCAG requires of content shown on hover or focus,
|
|
154
|
-
// which is three clauses and one mechanism)
|
|
155
|
-
//
|
|
160
|
+
// which is three clauses and one mechanism), and `menu-tree.js` (the chain of
|
|
161
|
+
// open menus the three menu components share, and what `Escape` and choosing an
|
|
162
|
+
// item are defined in terms of). Each says in its own header why it is
|
|
163
|
+
// unreachable rather than exported. There is no `internal/props.js`-shaped
|
|
156
164
|
// bag of helpers: a module that cannot say what it is about does not belong in
|
|
157
165
|
// this package.
|
|
158
166
|
|
|
@@ -191,6 +199,7 @@ import {
|
|
|
191
199
|
CalendarRoot,
|
|
192
200
|
} from "./calendar.js";
|
|
193
201
|
import { Checkbox } from "./checkbox.js";
|
|
202
|
+
import { ContextMenuRoot, ContextMenuTrigger } from "./context-menu.js";
|
|
194
203
|
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "./collapsible.js";
|
|
195
204
|
import {
|
|
196
205
|
ComboboxEmpty,
|
|
@@ -237,15 +246,19 @@ import { HoverCardBody, HoverCardRoot, HoverCardTrigger } from "./hover-card.js"
|
|
|
237
246
|
import { InputOtpGroup, InputOtpRoot, InputOtpSeparator, InputOtpSlot } from "./input-otp.js";
|
|
238
247
|
import {
|
|
239
248
|
MenuBody,
|
|
249
|
+
MenuCheckboxItem,
|
|
240
250
|
MenuGroup,
|
|
241
251
|
MenuItem,
|
|
242
252
|
MenuLabel,
|
|
253
|
+
MenuRadioGroup,
|
|
254
|
+
MenuRadioItem,
|
|
243
255
|
MenuRoot,
|
|
244
256
|
MenuSeparator,
|
|
245
257
|
MenuSub,
|
|
246
258
|
MenuSubTrigger,
|
|
247
259
|
MenuTrigger,
|
|
248
260
|
} from "./menu.js";
|
|
261
|
+
import { MenubarMenu, MenubarRoot, MenubarTrigger } from "./menubar.js";
|
|
249
262
|
import {
|
|
250
263
|
NavigationMenuBody,
|
|
251
264
|
NavigationMenuItem,
|
|
@@ -375,6 +388,20 @@ export { dismissAllToasts, dismissToast, toast, updateToast };
|
|
|
375
388
|
* <Field.Description>We will not share it.</Field.Description>
|
|
376
389
|
* <Field.Error>{error}</Field.Error>
|
|
377
390
|
* </Field.Root>
|
|
391
|
+
*
|
|
392
|
+
* Inside a form, `field` replaces the hand-written `invalid`: the form says
|
|
393
|
+
* whether the field is wrong and what the message is, and the field composes
|
|
394
|
+
* every `aria-*` from that in one place. `@uniflowed/form`'s `useFieldSource`
|
|
395
|
+
* is what produces one, and `field.js`'s header says why the hook lives there
|
|
396
|
+
* rather than here.
|
|
397
|
+
*
|
|
398
|
+
* const email = useFieldSource(form, "email", { required: "We need one" });
|
|
399
|
+
* <Field.Root field={email}>…<Field.Error /></Field.Root>
|
|
400
|
+
*
|
|
401
|
+
* `group` is for a set with no single control to point a `<label for>` at — a
|
|
402
|
+
* radio group, a checkbox group, three selects making a date. The root becomes
|
|
403
|
+
* `role="group"` named by the label, and the description and the error describe
|
|
404
|
+
* the set.
|
|
378
405
|
*/
|
|
379
406
|
export const Field = {
|
|
380
407
|
Root: FieldRoot,
|
|
@@ -761,6 +788,77 @@ export const Menu = {
|
|
|
761
788
|
Trigger: MenuTrigger,
|
|
762
789
|
Body: MenuBody,
|
|
763
790
|
Item: MenuItem,
|
|
791
|
+
CheckboxItem: MenuCheckboxItem,
|
|
792
|
+
RadioGroup: MenuRadioGroup,
|
|
793
|
+
RadioItem: MenuRadioItem,
|
|
794
|
+
Separator: MenuSeparator,
|
|
795
|
+
Group: MenuGroup,
|
|
796
|
+
Label: MenuLabel,
|
|
797
|
+
Sub: MenuSub,
|
|
798
|
+
SubTrigger: MenuSubTrigger,
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* The same menu, opened by the right button — and by the keyboard.
|
|
803
|
+
*
|
|
804
|
+
* `Shift+F10`, the `ContextMenu` key and a long press all open it, because a
|
|
805
|
+
* command reachable only by right-click is reachable only by a pointer, which
|
|
806
|
+
* is a WCAG 2.1.1 failure. `context-menu.js` says why the trigger is in the tab
|
|
807
|
+
* order and when to take it out again.
|
|
808
|
+
*
|
|
809
|
+
* The body needs an `aria-label`: its trigger is a table row or a canvas rather
|
|
810
|
+
* than a short name, so unlike `Menu.Body` it cannot name itself after one.
|
|
811
|
+
*
|
|
812
|
+
* <ContextMenu.Root>
|
|
813
|
+
* <ContextMenu.Trigger>{row}</ContextMenu.Trigger>
|
|
814
|
+
* <ContextMenu.Body aria-label="Row actions">
|
|
815
|
+
* <ContextMenu.Item onSelect={rename}>Rename…</ContextMenu.Item>
|
|
816
|
+
* <ContextMenu.CheckboxItem defaultChecked>Show hidden</ContextMenu.CheckboxItem>
|
|
817
|
+
* </ContextMenu.Body>
|
|
818
|
+
* </ContextMenu.Root>
|
|
819
|
+
*/
|
|
820
|
+
export const ContextMenu = {
|
|
821
|
+
Root: ContextMenuRoot,
|
|
822
|
+
Trigger: ContextMenuTrigger,
|
|
823
|
+
Body: MenuBody,
|
|
824
|
+
Item: MenuItem,
|
|
825
|
+
CheckboxItem: MenuCheckboxItem,
|
|
826
|
+
RadioGroup: MenuRadioGroup,
|
|
827
|
+
RadioItem: MenuRadioItem,
|
|
828
|
+
Separator: MenuSeparator,
|
|
829
|
+
Group: MenuGroup,
|
|
830
|
+
Label: MenuLabel,
|
|
831
|
+
Sub: MenuSub,
|
|
832
|
+
SubTrigger: MenuSubTrigger,
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* A row of menus that behaves as one control: File, Edit, View.
|
|
837
|
+
*
|
|
838
|
+
* One tab stop for the whole bar, arrows between the menus, and — the part that
|
|
839
|
+
* is always missing — arrows *while a menu is open* that close it and open the
|
|
840
|
+
* next one, so a reader walks File → Edit → View without pressing Escape.
|
|
841
|
+
*
|
|
842
|
+
* <Menubar.Root aria-label="Main">
|
|
843
|
+
* <Menubar.Menu value="file">
|
|
844
|
+
* <Menubar.Trigger>File</Menubar.Trigger>
|
|
845
|
+
* <Menubar.Body>
|
|
846
|
+
* <Menubar.Item onSelect={open}>Open…</Menubar.Item>
|
|
847
|
+
* </Menubar.Body>
|
|
848
|
+
* </Menubar.Menu>
|
|
849
|
+
* </Menubar.Root>
|
|
850
|
+
*/
|
|
851
|
+
export const Menubar = {
|
|
852
|
+
Root: MenubarRoot,
|
|
853
|
+
Menu: MenubarMenu,
|
|
854
|
+
Trigger: MenubarTrigger,
|
|
855
|
+
// `Menu.Body` itself: a bar's menu is a root menu, and `menubar.js`'s header
|
|
856
|
+
// says why a wrapper with the same defaults would be a second place to drift.
|
|
857
|
+
Body: MenuBody,
|
|
858
|
+
Item: MenuItem,
|
|
859
|
+
CheckboxItem: MenuCheckboxItem,
|
|
860
|
+
RadioGroup: MenuRadioGroup,
|
|
861
|
+
RadioItem: MenuRadioItem,
|
|
764
862
|
Separator: MenuSeparator,
|
|
765
863
|
Group: MenuGroup,
|
|
766
864
|
Label: MenuLabel,
|
package/internal/anchor.js
CHANGED
|
@@ -183,6 +183,24 @@ export type Anchored = {|
|
|
|
183
183
|
/** What `useAnchor` is told, on top of the geometry `placeOverlay` needs. */
|
|
184
184
|
export type AnchorRequest = {|
|
|
185
185
|
readonly anchorRef: { current: HTMLElement | null },
|
|
186
|
+
/**
|
|
187
|
+
* A box to place against instead of the anchor element's own.
|
|
188
|
+
*
|
|
189
|
+
* A context menu opens *at a point* rather than against an element: the
|
|
190
|
+
* pointer coordinates the reader right-clicked at, which is a zero-sized
|
|
191
|
+
* rectangle no element in the document has. The element is still needed —
|
|
192
|
+
* it is what the writing direction is read from, what a `ResizeObserver`
|
|
193
|
+
* watches, and what focus returns to — so this replaces the *measurement*
|
|
194
|
+
* and nothing else.
|
|
195
|
+
*
|
|
196
|
+
* `null` (and absent) means "measure the element", which is every other
|
|
197
|
+
* overlay in this package.
|
|
198
|
+
*
|
|
199
|
+
* It has to be stable between renders for the same position, because it is
|
|
200
|
+
* one of the things the placement effect re-runs for; a fresh object each
|
|
201
|
+
* render would re-measure on every render of the page around it.
|
|
202
|
+
*/
|
|
203
|
+
readonly anchorRect?: Rect | null,
|
|
186
204
|
readonly overlayRef: { current: HTMLElement | null },
|
|
187
205
|
/** Nothing is measured while it is closed: there is nothing to measure. */
|
|
188
206
|
readonly open: boolean,
|
|
@@ -424,6 +442,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
424
442
|
const {
|
|
425
443
|
align,
|
|
426
444
|
alignOffset,
|
|
445
|
+
anchorRect,
|
|
427
446
|
anchorRef,
|
|
428
447
|
avoidCollisions,
|
|
429
448
|
collisionPadding,
|
|
@@ -432,6 +451,9 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
432
451
|
side,
|
|
433
452
|
sideOffset,
|
|
434
453
|
} = request;
|
|
454
|
+
// Absent and `null` are one answer here — "measure the element" — so the two
|
|
455
|
+
// spellings become one value before anything depends on it.
|
|
456
|
+
const virtual = anchorRect ?? null;
|
|
435
457
|
// The left-to-right reading of a logical side, which is what `Anchored`
|
|
436
458
|
// reports until something has been measured. In a right-to-left page a
|
|
437
459
|
// submenu's `inline-end` is the *left*, and the first `reflow` says so - one
|
|
@@ -446,7 +468,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
446
468
|
if (anchor == null || overlay == null || view == null) {
|
|
447
469
|
return;
|
|
448
470
|
}
|
|
449
|
-
const box = rectOf(anchor);
|
|
471
|
+
const box = virtual ?? rectOf(anchor);
|
|
450
472
|
const placement = placeOverlay({
|
|
451
473
|
align,
|
|
452
474
|
alignOffset,
|
|
@@ -524,6 +546,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
524
546
|
}, [
|
|
525
547
|
align,
|
|
526
548
|
alignOffset,
|
|
549
|
+
anchorRect,
|
|
527
550
|
anchorRef,
|
|
528
551
|
avoidCollisions,
|
|
529
552
|
collisionPadding,
|