@uniflowed/ui 0.0.0-alpha.9 → 0.2.0

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.
Files changed (65) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +587 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +243 -178
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +100 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +48 -55
  64. package/tree.js +8 -0
  65. package/visually-hidden.js +259 -0
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,30 +17,142 @@
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
 
31
96
  import * as React from "@uniflowed/react";
32
97
  import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
33
98
 
34
- import type { Rest } from "./internal/merge-props.js";
99
+ import type { RenderProp, 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
+ * # It is declared here and produced there
113
+ *
114
+ * `@uniflowed/form`'s `useFieldSource` builds one of these, so this type is the
115
+ * seam between the two packages — and it lives on this side only because the
116
+ * dependency can only run this way today: this package is on npm and that one
117
+ * is not, and `tools/release/publishable.sh` refuses a published package that
118
+ * depends on an unpublished one.
119
+ *
120
+ * That is backwards, and ubugeeei-prod/uf#614 says so. It stands because
121
+ * declaring the type twice trades a documented edge for silent drift — a
122
+ * `Field.Root` accepting a shape `useFieldSource` no longer produces would
123
+ * type-check on both sides and fail only where they meet — and because the
124
+ * import is type-only, so no project installing `@uniflowed/form` loads,
125
+ * bundles or runs any of this package. #210 is the trigger: once
126
+ * `@uniflowed/form` publishes, this moves there and `@uniflowed/ui` imports it.
127
+ */
128
+ export type FieldSource = {|
129
+ readonly invalid: boolean,
130
+ readonly required: boolean,
131
+ readonly disabled: boolean,
132
+ readonly busy: boolean,
133
+ /** What is wrong, or null while the field is valid. */
134
+ readonly message: string | null,
135
+ readonly control: Rest,
136
+ |};
137
+
138
+ /** No form: the shape the field falls back to, made once. */
139
+ const NO_CONTROL: Rest = Object.freeze({});
35
140
 
36
141
  type FieldState = {|
37
142
  readonly controlId: string,
38
143
  readonly labelId: string,
39
144
  readonly descriptionId: string,
145
+ readonly statusId: string,
40
146
  readonly errorId: string,
41
147
  readonly invalid: boolean,
148
+ readonly required: boolean,
149
+ readonly busy: boolean,
150
+ readonly group: boolean,
151
+ readonly message: string | null,
152
+ readonly control: Rest,
42
153
  readonly describedBy: string | void,
43
154
  readonly registerDescription: (present: boolean) => void,
155
+ readonly registerStatus: (present: boolean) => void,
44
156
  readonly registerError: (present: boolean) => void,
45
157
  |};
46
158
 
@@ -63,74 +175,151 @@ hook useField(part: string): FieldState {
63
175
  /**
64
176
  * The field's container, and the only place ids are made.
65
177
  *
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.
178
+ * `invalid` and `required` are the root's business rather than the control's
179
+ * because three parts have to agree about each: the control says `aria-invalid`
180
+ * and `aria-required`, the error message is rendered or not, and the control's
181
+ * `aria-describedby` includes the error's id or not.
182
+ *
183
+ * `field` is the same two facts arriving from a form store instead of from the
184
+ * caller's hand, and it wins over the props when it is there — a field inside a
185
+ * form has one source of truth about its own validity, and the props are what a
186
+ * field with no form around it uses. Passing both is not an error; it is a
187
+ * caller saying "and also mark it invalid", which is what `invalid || source`
188
+ * means and what a server-side error arriving beside a client-side one needs.
70
189
  */
71
- export component FieldRoot(children: React.Node, invalid?: boolean = false, ...rest: Rest) {
190
+ export component FieldRoot(
191
+ children: React.Node,
192
+ invalid?: boolean = false,
193
+ required?: boolean = false,
194
+ busy?: boolean = false,
195
+ field?: FieldSource,
196
+ group?: boolean = false,
197
+ render?: RenderProp,
198
+ ...rest: Rest
199
+ ) {
72
200
  const base = useId();
73
201
  const [hasDescription, setHasDescription] = useState(false);
202
+ const [hasStatus, setHasStatus] = useState(false);
74
203
  const [hasError, setHasError] = useState(false);
204
+ const sourceInvalid = field?.invalid ?? false;
205
+ const sourceRequired = field?.required ?? false;
206
+ const sourceBusy = field?.busy ?? false;
207
+ const message = field?.message ?? null;
208
+ const control = field?.control ?? NO_CONTROL;
75
209
 
76
210
  const state = useMemo(() => {
77
211
  const descriptionId = `${base}-description`;
212
+ const statusId = `${base}-status`;
78
213
  const errorId = `${base}-error`;
214
+ const wrong = invalid || sourceInvalid;
79
215
  // Only ids that are in the document. `aria-describedby` naming a missing
80
216
  // element makes a screen reader announce nothing rather than skipping it.
81
217
  const described = [
82
218
  hasDescription ? descriptionId : null,
83
- invalid && hasError ? errorId : null,
219
+ hasStatus ? statusId : null,
220
+ wrong && hasError ? errorId : null,
84
221
  ].filter(Boolean);
85
222
 
86
223
  return {
87
224
  controlId: `${base}-control`,
88
225
  labelId: `${base}-label`,
89
226
  descriptionId,
227
+ statusId,
90
228
  errorId,
91
- invalid,
229
+ invalid: wrong,
230
+ required: required || sourceRequired,
231
+ busy: busy || sourceBusy,
232
+ group,
233
+ message,
234
+ control,
92
235
  describedBy: described.length === 0 ? undefined : described.join(" "),
93
236
  registerDescription: setHasDescription,
237
+ registerStatus: setHasStatus,
94
238
  registerError: setHasError,
95
239
  };
96
- }, [base, invalid, hasDescription, hasError]);
240
+ }, [
241
+ base,
242
+ invalid,
243
+ sourceInvalid,
244
+ required,
245
+ sourceRequired,
246
+ busy,
247
+ sourceBusy,
248
+ group,
249
+ message,
250
+ control,
251
+ hasDescription,
252
+ hasStatus,
253
+ hasError,
254
+ ]);
255
+
256
+ const props = withProps(rest, {
257
+ // A group names itself, describes itself and reports its own validity,
258
+ // because there is no one control inside it to carry any of the three.
259
+ // See the module header.
260
+ "aria-busy": group && state.busy ? "true" : undefined,
261
+ "aria-describedby": group ? state.describedBy : undefined,
262
+ "aria-invalid": group && state.invalid ? "true" : undefined,
263
+ "aria-labelledby": group ? state.labelId : undefined,
264
+ children,
265
+ role: group ? "group" : undefined,
266
+ });
97
267
 
98
268
  return (
99
269
  <FieldContext.Provider value={state}>
100
- <div {...rest}>{children}</div>
270
+ {render != null ? render(props) : <div {...props} />}
101
271
  </FieldContext.Provider>
102
272
  );
103
273
  }
104
274
 
105
- /** The label, pointing at the control by id rather than by nesting. */
106
- export component FieldLabel(children: React.Node, ...rest: Rest) {
275
+ /**
276
+ * The label, pointing at the control by id rather than by nesting.
277
+ *
278
+ * In a group it is a `<span>` instead, and the group points at *it*: a
279
+ * `<label for>` naming something that is not a form control is ignored by every
280
+ * browser, and ignored silently. The module header says more.
281
+ */
282
+ export component FieldLabel(children: React.Node, render?: RenderProp, ...rest: Rest) {
107
283
  const field = useField("Field.Label");
108
284
  // `rest` first: a caller `id` here would break the relationship the control
109
285
  // points at, and it would break it silently.
110
- return (
111
- <label {...rest} htmlFor={field.controlId} id={field.labelId}>
112
- {children}
113
- </label>
114
- );
286
+ if (field.group) {
287
+ const props = withProps(rest, { children, id: field.labelId });
288
+ return render != null ? render(props) : <span {...props} />;
289
+ }
290
+ const props = withProps(rest, { children, htmlFor: field.controlId, id: field.labelId });
291
+ return render != null ? render(props) : <label {...props} />;
115
292
  }
116
293
 
117
294
  /**
118
295
  * The control, given every attribute the rest of the field implies.
119
296
  *
120
- * See the module header for why this takes a render function.
297
+ * See the module header for why this takes a render function, and for the
298
+ * division of labour that decides what is in here. The form's own binding is
299
+ * underneath, so a caller who spreads these onto an element gets the store's
300
+ * `ref` and handlers *and* the field's attributes from one spread instead of
301
+ * two that overwrite each other.
121
302
  */
122
- export component FieldControl(render: (props: Rest) => React.Node) {
303
+ export component FieldControl(render: RenderProp) {
123
304
  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
- });
305
+ // A group already carries the name, the description and the validity, and a
306
+ // control repeating them makes a reader hear the error once for the set and
307
+ // again for the member. What is left is what only the control can say.
308
+ const own: Rest = field.group
309
+ ? { "aria-required": field.required ? "true" : undefined }
310
+ : {
311
+ "aria-busy": field.busy ? "true" : undefined,
312
+ id: field.controlId,
313
+ "aria-labelledby": field.labelId,
314
+ "aria-describedby": field.describedBy,
315
+ "aria-invalid": field.invalid ? "true" : undefined,
316
+ "aria-required": field.required ? "true" : undefined,
317
+ };
318
+ return render(withProps(field.control, own));
130
319
  }
131
320
 
132
321
  /** Help text, which the control points at while it is rendered. */
133
- export component FieldDescription(children: React.Node, ...rest: Rest) {
322
+ export component FieldDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
134
323
  const field = useField("Field.Description");
135
324
  const register = field.registerDescription;
136
325
  useEffect(() => {
@@ -138,20 +327,47 @@ export component FieldDescription(children: React.Node, ...rest: Rest) {
138
327
  return () => register(false);
139
328
  }, [register]);
140
329
 
141
- return (
142
- <p {...rest} id={field.descriptionId}>
143
- {children}
144
- </p>
145
- );
330
+ const props = withProps(rest, { children, id: field.descriptionId });
331
+ return render != null ? render(props) : <p {...props} />;
332
+ }
333
+
334
+ /**
335
+ * Non-error feedback for the field.
336
+ *
337
+ * A polite live region has to exist before the text changes. So unlike
338
+ * `Field.Error`, this part stays in the document even while it is empty; the
339
+ * control points at it only while the caller rendered the part.
340
+ */
341
+ export component FieldStatus(children?: React.Node, render?: RenderProp, ...rest: Rest) {
342
+ const field = useField("Field.Status");
343
+ const register = field.registerStatus;
344
+ useEffect(() => {
345
+ register(true);
346
+ return () => register(false);
347
+ }, [register]);
348
+
349
+ const props = withProps(rest, {
350
+ "aria-live": "polite",
351
+ children: children ?? "",
352
+ id: field.statusId,
353
+ role: "status",
354
+ });
355
+ return render != null ? render(props) : <p {...props} />;
146
356
  }
147
357
 
148
358
  /**
149
359
  * The error message, rendered only when the field is invalid.
150
360
  *
151
361
  * `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.
362
+ * error that arrives after a blur, a submit, or a Server Action — see the
363
+ * module header for why an assertive region may be inserted with its text where
364
+ * a polite one may not.
365
+ *
366
+ * With no children it shows the message the form gave, so a field bound to a
367
+ * store does not need the caller to reach back into `formState.errors` for a
368
+ * string the source is already carrying.
153
369
  */
154
- export component FieldError(children: React.Node, ...rest: Rest) {
370
+ export component FieldError(children?: React.Node, render?: RenderProp, ...rest: Rest) {
155
371
  const field = useField("Field.Error");
156
372
  const register = field.registerError;
157
373
  useEffect(() => {
@@ -162,9 +378,10 @@ export component FieldError(children: React.Node, ...rest: Rest) {
162
378
  if (!field.invalid) {
163
379
  return null;
164
380
  }
165
- return (
166
- <p {...rest} id={field.errorId} role="alert">
167
- {children}
168
- </p>
169
- );
381
+ const props = withProps(rest, {
382
+ children: children ?? field.message,
383
+ id: field.errorId,
384
+ role: "alert",
385
+ });
386
+ return render != null ? render(props) : <p {...props} />;
170
387
  }
package/grid-list.js ADDED
@@ -0,0 +1,8 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { CollectionRoot } from "./internal/collection.js";
5
+ import type { CollectionProps } from "./internal/collection.js";
6
+ export component GridList(...props: CollectionProps) {
7
+ return <CollectionRoot options={props} kind="grid" />;
8
+ }
package/hover-card.js CHANGED
@@ -43,9 +43,9 @@ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@u
43
43
  import { useEventListener } from "@uniflowed/hooks/dom";
44
44
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
45
45
 
46
- import type { Align, Side } from "./internal/anchor.js";
46
+ import type { Align, LogicalSide } from "./internal/anchor.js";
47
47
  import type { HoverIntent } from "./internal/hover-intent.js";
48
- import type { Rest } from "./internal/merge-props.js";
48
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
49
49
  import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
50
50
  import {
51
51
  DEFAULT_CLOSE_DELAY,
@@ -57,7 +57,7 @@ import {
57
57
  import { useAnchor } from "./internal/anchor.js";
58
58
  import { useControlled } from "./internal/controlled-state.js";
59
59
 
60
- export type { Align, Side } from "./internal/anchor.js";
60
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
61
61
 
62
62
  type HoverCardState = {|
63
63
  readonly base: string,
@@ -68,7 +68,7 @@ type HoverCardState = {|
68
68
  readonly openDelay: number,
69
69
  readonly closeDelay: number,
70
70
  /** Whether `Escape` has dismissed it; see `tooltip.js`, which shares the rule. */
71
- readonly dismissed: { current: boolean },
71
+ readonly dismissedRef: { current: boolean },
72
72
  |};
73
73
 
74
74
  const HoverCardContext: React.Context<HoverCardState | null> = createContext(null);
@@ -99,14 +99,14 @@ export component HoverCardRoot(
99
99
  const base = useId();
100
100
  const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
101
101
  const triggerRef = useRef<HTMLElement | null>(null);
102
- const dismissed = useRef(false);
102
+ const dismissedRef = useRef(false);
103
103
  const intent = useHoverIntent(setOpen);
104
104
 
105
105
  const state = useMemo(
106
106
  () => ({
107
107
  base,
108
108
  closeDelay,
109
- dismissed,
109
+ dismissedRef,
110
110
  intent,
111
111
  open: isOpen,
112
112
  openDelay,
@@ -127,42 +127,38 @@ export component HoverCardRoot(
127
127
  * reachable by keyboard, and `useFocusableTrigger` refuses anything else —
128
128
  * a hover card on a `<span>` is one a keyboard reader can never see.
129
129
  */
130
- export component HoverCardTrigger(
131
- children?: React.Node,
132
- render?: (props: Rest) => React.Node,
133
- ...rest: Rest
134
- ) {
130
+ export component HoverCardTrigger(children?: React.Node, render?: RenderProp, ...rest: Rest) {
135
131
  const card = useHoverCard("HoverCard.Trigger");
136
- const { closeDelay, dismissed, intent, openDelay, triggerRef } = card;
132
+ const { closeDelay, dismissedRef, intent, openDelay, triggerRef } = card;
137
133
  useFocusableTrigger(triggerRef, "HoverCard.Trigger");
138
134
 
139
135
  // A press focuses the trigger, and a card opening under the reader's own
140
136
  // click would cover what they just went to. See `tooltip.js`.
141
- const pressed = useRef(false);
137
+ const pressedRef = useRef(false);
142
138
 
143
139
  useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
144
- if (event.pointerType === "touch" || dismissed.current) {
140
+ if (event.pointerType === "touch" || dismissedRef.current) {
145
141
  return;
146
142
  }
147
143
  intent.openAfter(openDelay);
148
144
  });
149
145
  useEventListener(triggerRef, "pointerleave", () => {
150
- dismissed.current = false;
146
+ dismissedRef.current = false;
151
147
  intent.closeAfter(closeDelay);
152
148
  });
153
149
  useEventListener(triggerRef, "pointerdown", () => {
154
- pressed.current = true;
150
+ pressedRef.current = true;
155
151
  intent.cancel();
156
152
  });
157
153
  useEventListener(triggerRef, "focusin", () => {
158
- if (pressed.current) {
159
- pressed.current = false;
154
+ if (pressedRef.current) {
155
+ pressedRef.current = false;
160
156
  return;
161
157
  }
162
158
  // The focus a dismissed card hands *back* to this trigger must not reopen
163
159
  // it, which is the whole reason the flag exists: without it `Escape` closes
164
160
  // the card, focus returns here, and the card comes straight back.
165
- if (dismissed.current) {
161
+ if (dismissedRef.current) {
166
162
  return;
167
163
  }
168
164
  // A reader who tabbed here has said what they want; only the pointer is
@@ -170,8 +166,8 @@ export component HoverCardTrigger(
170
166
  intent.openAfter(0);
171
167
  });
172
168
  useEventListener(triggerRef, "focusout", () => {
173
- pressed.current = false;
174
- dismissed.current = false;
169
+ pressedRef.current = false;
170
+ dismissedRef.current = false;
175
171
  // `closeAfter` rather than a close, and this is where the delay earns its
176
172
  // keep a second time: `Tab` from the trigger *into* the card is a leave
177
173
  // followed immediately by an arrival, and the card's own `focusin` calls
@@ -183,19 +179,18 @@ export component HoverCardTrigger(
183
179
 
184
180
  // Annotated because this one is not written inside a `ref={...}`, and there
185
181
  // is nothing else here for Flow to infer the element's type from.
182
+ // React calls callback refs during commit; pointer and focus handlers read it later.
183
+ // uf-lint-disable-next-line react-compiler/refs
186
184
  const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
187
185
  triggerRef.current = element;
188
186
  });
189
187
 
188
+ const props = withProps(withoutComposed(rest, ["ref"]), { children, ref: attach });
189
+
190
190
  if (render != null) {
191
- return render(withProps(withoutComposed(rest, ["ref"]), { ref: attach }));
191
+ return render(props);
192
192
  }
193
-
194
- return (
195
- <button {...withoutComposed(rest, ["ref"])} ref={attach} type="button">
196
- {children}
197
- </button>
198
- );
193
+ return <button {...props} type="button" />;
199
194
  }
200
195
 
201
196
  /**
@@ -213,21 +208,22 @@ export component HoverCardBody(
213
208
  alignOffset?: number = 0,
214
209
  avoidCollisions?: boolean = true,
215
210
  collisionPadding?: number = 0,
216
- side?: Side = "bottom",
211
+ render?: RenderProp,
212
+ side?: LogicalSide = "bottom",
217
213
  sideOffset?: number = 0,
218
214
  ...rest: Rest
219
215
  ) {
220
216
  const card = useHoverCard("HoverCard.Body");
221
- const { closeDelay, intent, open, triggerRef } = card;
217
+ const { closeDelay, dismissedRef, intent, open, triggerRef } = card;
222
218
  const bodyRef = useRef<HTMLElement | null>(null);
223
219
  // Whether the reader is *in* the card, as opposed to over it. It decides one
224
220
  // thing and it cannot be asked afterwards: a card closed while it held focus
225
221
  // has to hand focus back, and by the time the effect below is cleaned up the
226
222
  // element is gone from the document and `activeElement` has already fallen to
227
223
  // `<body>` — so the answer is kept while it is still true.
228
- const held = useRef(false);
224
+ const heldRef = useRef(false);
229
225
  const close = useStableCallback(() => {
230
- card.dismissed.current = true;
226
+ dismissedRef.current = true;
231
227
  intent.cancel();
232
228
  card.setOpen(false);
233
229
  });
@@ -256,11 +252,11 @@ export component HoverCardBody(
256
252
  const stay = () => intent.cancel();
257
253
  const go = () => intent.closeAfter(closeDelay);
258
254
  const arrived = () => {
259
- held.current = true;
255
+ heldRef.current = true;
260
256
  stay();
261
257
  };
262
258
  const gone = () => {
263
- held.current = false;
259
+ heldRef.current = false;
264
260
  go();
265
261
  };
266
262
  body.addEventListener("pointerenter", stay);
@@ -295,8 +291,8 @@ export component HoverCardBody(
295
291
  if (open) {
296
292
  return;
297
293
  }
298
- if (held.current) {
299
- held.current = false;
294
+ if (heldRef.current) {
295
+ heldRef.current = false;
300
296
  triggerRef.current?.focus?.();
301
297
  }
302
298
  }, [open, triggerRef]);
@@ -305,8 +301,8 @@ export component HoverCardBody(
305
301
  // the reader is inside it leaves focus on a node that is gone.
306
302
  useEffect(
307
303
  () => () => {
308
- if (held.current) {
309
- held.current = false;
304
+ if (heldRef.current) {
305
+ heldRef.current = false;
310
306
  triggerRef.current?.focus?.();
311
307
  }
312
308
  },
@@ -317,18 +313,22 @@ export component HoverCardBody(
317
313
  return null;
318
314
  }
319
315
 
320
- return (
321
- <div
322
- {...withoutComposed(rest, ["ref"])}
323
- data-align={anchored.align}
324
- data-side={anchored.side}
325
- data-state="open"
326
- id={`${card.base}-body`}
327
- ref={composeRefs(rest.ref, (element) => {
328
- bodyRef.current = element;
329
- })}
330
- >
331
- {children}
332
- </div>
333
- );
316
+ const props = withProps(withoutComposed(rest, ["ref"]), {
317
+ children,
318
+ "data-align": anchored.align,
319
+ "data-side": anchored.side,
320
+ "data-state": "open",
321
+ id: `${card.base}-body`,
322
+ // React calls callback refs during commit; placement effects read it later.
323
+ // uf-lint-disable-next-line react-compiler/refs
324
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
325
+ bodyRef.current = element;
326
+ }),
327
+ });
328
+
329
+ if (render != null) {
330
+ return render(props);
331
+ }
332
+
333
+ return <div {...props} />;
334
334
  }