@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/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, and every id has to be unique on the page and stable across renders.
9
- // Doing that by hand is four attributes and two `useId` calls per field, and
10
- // getting one wrong is silent — the field looks right and a screen reader
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
- // four attributes on whatever element it eventually renders. A component that
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` is the root's business rather than the control's because three
67
- * parts have to agree about it: the control says `aria-invalid`, the error
68
- * message is rendered or not, and the control's `aria-describedby` includes the
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(children: React.Node, invalid?: boolean = false, ...rest: Rest) {
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
- invalid && hasError ? errorId : null,
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
- }, [base, invalid, hasDescription, hasError]);
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 {...rest}>{children}</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
- /** The label, pointing at the control by id rather than by nesting. */
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
- return render({
125
- id: field.controlId,
126
- "aria-labelledby": field.labelId,
127
- "aria-describedby": field.describedBy,
128
- "aria-invalid": field.invalid ? "true" : undefined,
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 submit.
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: React.Node, ...rest: Rest) {
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
@@ -39,6 +39,52 @@
39
39
  // the same components with exactly the same behaviour; the design-system layer
40
40
  // that adds uf's default styles is built *on* these, not into them.
41
41
  //
42
+ // # What is not here, and where it went instead
43
+ //
44
+ // About twenty of the catalogue this package is measured against have no
45
+ // behaviour at all. Badge, Card, Button, Input, Textarea, Label and Aspect
46
+ // Ratio are, between them, a class list and a `<div>`. For a library whose
47
+ // product *is* the styles that is coherent — you copy them in and you own
48
+ // them. It is not coherent here: a `Badge` with no styles is a `<span>`, a
49
+ // `Card` with no styles is a `<div>`, and shipping them from a package that
50
+ // ships no styles would make this a library of empty elements. Shipping them
51
+ // from `@uniflowed/stylex` would make *that* a component library, which its
52
+ // own header forbids. So for a long time the answer on record was both and
53
+ // neither, which is ubugeeei-prod/uf#298.
54
+ //
55
+ // The line that holds is not "styled versus headless". It is **whether the
56
+ // thing has a decision in it**:
57
+ //
58
+ // - An ARIA decision, a state machine or a keyboard requirement makes it a
59
+ // component here, even when it renders a single element. `Progress` is one
60
+ // `<div>`, and it belongs, because the conditional that omits
61
+ // `aria-valuenow` when the amount is unknown — rather than sending
62
+ // `aria-valuenow={0}`, which says "nothing has happened" — is the whole
63
+ // component. `Toggle` and `Checkbox` are the same shape for the same reason.
64
+ // - Anything that is only a class list belongs in `@uniflowed/stylex/preset`,
65
+ // which already has the right form: `buttonStyles({ tone, size })`,
66
+ // `cardStyles()`, `textStyles({ size, tone })` and `fieldStyles()` return
67
+ // `{ className }` for a caller to spread onto their own element. That is a
68
+ // better answer than a `<Badge>`, not a lesser one — a component wrapping a
69
+ // `<button>` takes away `type="submit"`, `formAction`, the ref and every
70
+ // attribute nobody thought to forward, and gives back a class name the
71
+ // caller could have written.
72
+ //
73
+ // This is stated rather than left as an omission, because an omission reads as
74
+ // an oversight and the next contributor closes it with a `<Badge>`.
75
+ // `crates/uf_lib/src/ui.rs` carries the same decision as data: those entries
76
+ // are `Declined` with the preset functions that replace them named on each,
77
+ // and `cargo test -p uf_lib` fails if a name there stops existing.
78
+ //
79
+ // Five of that twenty are not presentational and are missing rather than
80
+ // declined — Alert, Avatar, Breadcrumb, Separator and Skeleton — each for one
81
+ // specific reason, and the registry entry for each says which. The shortest is
82
+ // Alert: `role="alert"` is a live region, an element already in the document
83
+ // when the page loads announces on insertion or not at all, and a permanently
84
+ // rendered "your trial ends soon" box carrying that role is either an
85
+ // interruption on every page load or silence. `field.js` already makes that
86
+ // call correctly for `Field.Error`.
87
+ //
42
88
  // # What these components promise React
43
89
  //
44
90
  // Nothing here mutates during a render, reads a ref during a render, or depends
@@ -98,7 +144,13 @@
98
144
  // than what they replaced. Each module's header says what it gives that the
99
145
  // plain element does not; if it ever stops being true, the component should
100
146
  // be deleted rather than fixed.
101
- // - `menu.js` — the arrow keys, typeahead, submenus and `Escape` stacking.
147
+ // - `menu.js`, `context-menu.js` and `menubar.js` — the arrow keys, typeahead,
148
+ // submenus and `Escape` stacking, and the two components that are that
149
+ // behaviour opened differently. A context menu is opened by the right button,
150
+ // by `Shift+F10` and by a long press, and opens at a *point*; a menubar is a
151
+ // row of them with one tab stop and arrows that walk between the open menus.
152
+ // shadcn's fourth menu, Dropdown Menu, is `menu.js` under another name and is
153
+ // deliberately not a second export.
102
154
  // - `combobox.js` — `aria-activedescendant` over a filtered list, the count a
103
155
  // screen reader is told, and the option groups that make a command palette a
104
156
  // composition rather than a seventh module.
@@ -138,7 +190,7 @@
138
190
  // is by primitive because that is the unit a reader looks for, the unit a
139
191
  // bundler drops, and the unit the WAI-ARIA practices are written in.
140
192
  //
141
- // `internal/` holds nine modules and nothing else, each a rule the primitives
193
+ // `internal/` holds ten modules and nothing else, each a rule the primitives
142
194
  // must apply identically and a consumer must not be able to apply differently:
143
195
  // `merge-props.js` (the caller's props go on first, the component's semantics
144
196
  // last), `controlled-state.js` (what "controlled" means here),
@@ -151,8 +203,10 @@
151
203
  // fit where it was asked to go), `focus.js` (which elements a reader can reach,
152
204
  // which a focus trap and a popover want opposite things from), and
153
205
  // `hover-intent.js` (what WCAG requires of content shown on hover or focus,
154
- // which is three clauses and one mechanism). Each says in its own header why it
155
- // is unreachable rather than exported. There is no `internal/props.js`-shaped
206
+ // which is three clauses and one mechanism), and `menu-tree.js` (the chain of
207
+ // open menus the three menu components share, and what `Escape` and choosing an
208
+ // item are defined in terms of). Each says in its own header why it is
209
+ // unreachable rather than exported. There is no `internal/props.js`-shaped
156
210
  // bag of helpers: a module that cannot say what it is about does not belong in
157
211
  // this package.
158
212
 
@@ -191,6 +245,7 @@ import {
191
245
  CalendarRoot,
192
246
  } from "./calendar.js";
193
247
  import { Checkbox } from "./checkbox.js";
248
+ import { ContextMenuRoot, ContextMenuTrigger } from "./context-menu.js";
194
249
  import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "./collapsible.js";
195
250
  import {
196
251
  ComboboxEmpty,
@@ -237,15 +292,19 @@ import { HoverCardBody, HoverCardRoot, HoverCardTrigger } from "./hover-card.js"
237
292
  import { InputOtpGroup, InputOtpRoot, InputOtpSeparator, InputOtpSlot } from "./input-otp.js";
238
293
  import {
239
294
  MenuBody,
295
+ MenuCheckboxItem,
240
296
  MenuGroup,
241
297
  MenuItem,
242
298
  MenuLabel,
299
+ MenuRadioGroup,
300
+ MenuRadioItem,
243
301
  MenuRoot,
244
302
  MenuSeparator,
245
303
  MenuSub,
246
304
  MenuSubTrigger,
247
305
  MenuTrigger,
248
306
  } from "./menu.js";
307
+ import { MenubarMenu, MenubarRoot, MenubarTrigger } from "./menubar.js";
249
308
  import {
250
309
  NavigationMenuBody,
251
310
  NavigationMenuItem,
@@ -375,6 +434,20 @@ export { dismissAllToasts, dismissToast, toast, updateToast };
375
434
  * <Field.Description>We will not share it.</Field.Description>
376
435
  * <Field.Error>{error}</Field.Error>
377
436
  * </Field.Root>
437
+ *
438
+ * Inside a form, `field` replaces the hand-written `invalid`: the form says
439
+ * whether the field is wrong and what the message is, and the field composes
440
+ * every `aria-*` from that in one place. `@uniflowed/form`'s `useFieldSource`
441
+ * is what produces one, and `field.js`'s header says why the hook lives there
442
+ * rather than here.
443
+ *
444
+ * const email = useFieldSource(form, "email", { required: "We need one" });
445
+ * <Field.Root field={email}>…<Field.Error /></Field.Root>
446
+ *
447
+ * `group` is for a set with no single control to point a `<label for>` at — a
448
+ * radio group, a checkbox group, three selects making a date. The root becomes
449
+ * `role="group"` named by the label, and the description and the error describe
450
+ * the set.
378
451
  */
379
452
  export const Field = {
380
453
  Root: FieldRoot,
@@ -761,6 +834,77 @@ export const Menu = {
761
834
  Trigger: MenuTrigger,
762
835
  Body: MenuBody,
763
836
  Item: MenuItem,
837
+ CheckboxItem: MenuCheckboxItem,
838
+ RadioGroup: MenuRadioGroup,
839
+ RadioItem: MenuRadioItem,
840
+ Separator: MenuSeparator,
841
+ Group: MenuGroup,
842
+ Label: MenuLabel,
843
+ Sub: MenuSub,
844
+ SubTrigger: MenuSubTrigger,
845
+ };
846
+
847
+ /**
848
+ * The same menu, opened by the right button — and by the keyboard.
849
+ *
850
+ * `Shift+F10`, the `ContextMenu` key and a long press all open it, because a
851
+ * command reachable only by right-click is reachable only by a pointer, which
852
+ * is a WCAG 2.1.1 failure. `context-menu.js` says why the trigger is in the tab
853
+ * order and when to take it out again.
854
+ *
855
+ * The body needs an `aria-label`: its trigger is a table row or a canvas rather
856
+ * than a short name, so unlike `Menu.Body` it cannot name itself after one.
857
+ *
858
+ * <ContextMenu.Root>
859
+ * <ContextMenu.Trigger>{row}</ContextMenu.Trigger>
860
+ * <ContextMenu.Body aria-label="Row actions">
861
+ * <ContextMenu.Item onSelect={rename}>Rename…</ContextMenu.Item>
862
+ * <ContextMenu.CheckboxItem defaultChecked>Show hidden</ContextMenu.CheckboxItem>
863
+ * </ContextMenu.Body>
864
+ * </ContextMenu.Root>
865
+ */
866
+ export const ContextMenu = {
867
+ Root: ContextMenuRoot,
868
+ Trigger: ContextMenuTrigger,
869
+ Body: MenuBody,
870
+ Item: MenuItem,
871
+ CheckboxItem: MenuCheckboxItem,
872
+ RadioGroup: MenuRadioGroup,
873
+ RadioItem: MenuRadioItem,
874
+ Separator: MenuSeparator,
875
+ Group: MenuGroup,
876
+ Label: MenuLabel,
877
+ Sub: MenuSub,
878
+ SubTrigger: MenuSubTrigger,
879
+ };
880
+
881
+ /**
882
+ * A row of menus that behaves as one control: File, Edit, View.
883
+ *
884
+ * One tab stop for the whole bar, arrows between the menus, and — the part that
885
+ * is always missing — arrows *while a menu is open* that close it and open the
886
+ * next one, so a reader walks File → Edit → View without pressing Escape.
887
+ *
888
+ * <Menubar.Root aria-label="Main">
889
+ * <Menubar.Menu value="file">
890
+ * <Menubar.Trigger>File</Menubar.Trigger>
891
+ * <Menubar.Body>
892
+ * <Menubar.Item onSelect={open}>Open…</Menubar.Item>
893
+ * </Menubar.Body>
894
+ * </Menubar.Menu>
895
+ * </Menubar.Root>
896
+ */
897
+ export const Menubar = {
898
+ Root: MenubarRoot,
899
+ Menu: MenubarMenu,
900
+ Trigger: MenubarTrigger,
901
+ // `Menu.Body` itself: a bar's menu is a root menu, and `menubar.js`'s header
902
+ // says why a wrapper with the same defaults would be a second place to drift.
903
+ Body: MenuBody,
904
+ Item: MenuItem,
905
+ CheckboxItem: MenuCheckboxItem,
906
+ RadioGroup: MenuRadioGroup,
907
+ RadioItem: MenuRadioItem,
764
908
  Separator: MenuSeparator,
765
909
  Group: MenuGroup,
766
910
  Label: MenuLabel,
@@ -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,