@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/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
@@ -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, submenus and `Escape` stacking.
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 nine modules and nothing else, each a rule the primitives
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). Each says in its own header why it
155
- // is unreachable rather than exported. There is no `internal/props.js`-shaped
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,
@@ -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,