@uniflowed/ui 0.0.0-alpha.18 → 0.0.0-alpha.37

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/drawer.js CHANGED
@@ -53,11 +53,12 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState } from
53
53
  import { usePrefersReducedMotion } from "@uniflowed/hooks/browser";
54
54
 
55
55
  import type { Edge } from "./sheet.js";
56
- import type { Rest } from "./internal/merge-props.js";
56
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
57
57
  import {
58
58
  composeHandlers,
59
59
  composeRefs,
60
60
  forwarded,
61
+ withProps,
61
62
  withoutComposed,
62
63
  } from "./internal/merge-props.js";
63
64
  import {
@@ -172,13 +173,17 @@ export component DrawerRoot(
172
173
  }
173
174
 
174
175
  /** What opens it, and what focus comes back to when it closes. */
175
- export component DrawerTrigger(children: React.Node, ...rest: Rest) {
176
- return <SheetTrigger {...forwarded(rest)}>{children}</SheetTrigger>;
176
+ export component DrawerTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
177
+ return (
178
+ <SheetTrigger {...forwarded(rest)} render={render}>
179
+ {children}
180
+ </SheetTrigger>
181
+ );
177
182
  }
178
183
 
179
184
  /** The backdrop. It carries the edge, the same as a sheet's. */
180
- export component DrawerOverlay(...rest: Rest) {
181
- return <SheetOverlay {...forwarded(rest)} />;
185
+ export component DrawerOverlay(render?: RenderProp, ...rest: Rest) {
186
+ return <SheetOverlay {...forwarded(rest)} render={render} />;
182
187
  }
183
188
 
184
189
  /**
@@ -189,7 +194,7 @@ export component DrawerOverlay(...rest: Rest) {
189
194
  * no drag to provide an alternative to — and a drawer with a handle and no
190
195
  * `Drawer.Close` has a gesture that is the only way out.
191
196
  */
192
- export component DrawerBody(children: React.Node, ...rest: Rest) {
197
+ export component DrawerBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
193
198
  const drawer = useDrawer("Drawer.Body");
194
199
  const { bodyRef, snapIndex, snapPoints } = drawer;
195
200
  const reducedMotion = usePrefersReducedMotion();
@@ -215,6 +220,7 @@ export component DrawerBody(children: React.Node, ...rest: Rest) {
215
220
  ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
216
221
  bodyRef.current = element;
217
222
  })}
223
+ render={render}
218
224
  >
219
225
  {children}
220
226
  <RequireCloseForTheDrag />
@@ -254,23 +260,39 @@ component RequireCloseForTheDrag() {
254
260
  }
255
261
 
256
262
  /** The top of the drawer, where the handle usually goes. */
257
- export component DrawerHeader(children: React.Node, ...rest: Rest) {
258
- return <SheetHeader {...forwarded(rest)}>{children}</SheetHeader>;
263
+ export component DrawerHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
264
+ return (
265
+ <SheetHeader {...forwarded(rest)} render={render}>
266
+ {children}
267
+ </SheetHeader>
268
+ );
259
269
  }
260
270
 
261
271
  /** The bottom of the drawer, where the actions go. */
262
- export component DrawerFooter(children: React.Node, ...rest: Rest) {
263
- return <SheetFooter {...forwarded(rest)}>{children}</SheetFooter>;
272
+ export component DrawerFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
273
+ return (
274
+ <SheetFooter {...forwarded(rest)} render={render}>
275
+ {children}
276
+ </SheetFooter>
277
+ );
264
278
  }
265
279
 
266
280
  /** The drawer's accessible name. */
267
- export component DrawerTitle(children: React.Node, ...rest: Rest) {
268
- return <SheetTitle {...forwarded(rest)}>{children}</SheetTitle>;
281
+ export component DrawerTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
282
+ return (
283
+ <SheetTitle {...forwarded(rest)} render={render}>
284
+ {children}
285
+ </SheetTitle>
286
+ );
269
287
  }
270
288
 
271
289
  /** What the drawer is for, announced after its name. */
272
- export component DrawerDescription(children: React.Node, ...rest: Rest) {
273
- return <SheetDescription {...forwarded(rest)}>{children}</SheetDescription>;
290
+ export component DrawerDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
291
+ return (
292
+ <SheetDescription {...forwarded(rest)} render={render}>
293
+ {children}
294
+ </SheetDescription>
295
+ );
274
296
  }
275
297
 
276
298
  /**
@@ -279,7 +301,7 @@ export component DrawerDescription(children: React.Node, ...rest: Rest) {
279
301
  *
280
302
  * It registers itself so `Drawer.Body` can tell whether the gesture has one.
281
303
  */
282
- export component DrawerClose(children: React.Node, ...rest: Rest) {
304
+ export component DrawerClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
283
305
  const drawer = useDrawer("Drawer.Close");
284
306
  const closes = drawer.closes;
285
307
 
@@ -290,7 +312,11 @@ export component DrawerClose(children: React.Node, ...rest: Rest) {
290
312
  };
291
313
  }, [closes]);
292
314
 
293
- return <SheetClose {...forwarded(rest)}>{children}</SheetClose>;
315
+ return (
316
+ <SheetClose {...forwarded(rest)} render={render}>
317
+ {children}
318
+ </SheetClose>
319
+ );
294
320
  }
295
321
 
296
322
  /**
@@ -305,7 +331,11 @@ export component DrawerClose(children: React.Node, ...rest: Rest) {
305
331
  * `label` because a slider with no accessible name is announced as "slider",
306
332
  * which is the same failure `Resizable.Handle` names.
307
333
  */
308
- export component DrawerHandle(label?: string = "Resize the drawer", ...rest: Rest) {
334
+ export component DrawerHandle(
335
+ label?: string = "Resize the drawer",
336
+ render?: RenderProp,
337
+ ...rest: Rest
338
+ ) {
309
339
  const drawer = useDrawer("Drawer.Handle");
310
340
  const { bodyRef, close, handles, setSnapIndex, side, snapIndex, snapPoints } = drawer;
311
341
  const passed = withoutComposed(rest, [
@@ -360,78 +390,80 @@ export component DrawerHandle(label?: string = "Resize the drawer", ...rest: Res
360
390
  return side === "top" || side === "left" ? from - now : now - from;
361
391
  };
362
392
 
363
- return (
364
- <div
365
- {...passed}
366
- aria-label={label}
367
- // The axis the drag runs along, which is the axis the snap points are
368
- // measured on: a bottom sheet grows upwards, so its slider is vertical.
369
- aria-orientation={vertical ? "vertical" : "horizontal"}
370
- aria-valuemax={last}
371
- aria-valuemin={0}
372
- aria-valuenow={snapIndex}
373
- // The number a reader can act on. `aria-valuenow` is an index into a list
374
- // nobody outside this component has seen, and "2" says nothing.
375
- aria-valuetext={`${String(Math.round((snapPoints[snapIndex] ?? 1) * 100))}%`}
376
- data-dragging={dragging ? "true" : undefined}
377
- onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
378
- if (event.key === "Home" || event.key === "End") {
379
- event.preventDefault();
380
- setSnapIndex(event.key === "Home" ? 0 : last);
381
- return;
382
- }
383
- const towardsOpen = OPENS_WITH[side];
384
- const towardsClosed = CLOSES_WITH[side];
385
- if (event.key === towardsOpen) {
386
- event.preventDefault();
387
- step(true);
388
- return;
389
- }
390
- if (event.key === towardsClosed) {
391
- event.preventDefault();
392
- step(false);
393
- }
394
- })}
395
- onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
396
- dragFrom.current = vertical ? event.clientY : event.clientX;
397
- setDragging(true);
398
- // So the drag survives the pointer leaving the handle, which it does
399
- // immediately: the handle moves with the drawer.
400
- event.currentTarget?.setPointerCapture?.(event.pointerId);
401
- })}
402
- onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
403
- const body = bodyRef.current;
404
- if (dragFrom.current == null || body == null) {
405
- return;
406
- }
407
- // Only away from the edge: dragging a drawer *past* fully open would
408
- // otherwise lift it off the edge it is attached to.
409
- body.style.setProperty("--uf-drawer-drag", `${String(Math.max(0, travelled(event)))}px`);
410
- })}
411
- onPointerUp={composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
412
- const body = bodyRef.current;
413
- const moved = travelled(event);
414
- dragFrom.current = null;
415
- setDragging(false);
416
- body?.style.setProperty("--uf-drawer-drag", "0px");
417
- if (body == null) {
418
- return;
419
- }
420
- const box = body.getBoundingClientRect();
421
- const size = vertical ? box.height : box.width;
422
- // A zero-sized box — a document that computes no layout — must not turn
423
- // every release into a dismissal.
424
- if (size <= 0 || Math.abs(moved) < size * DRAG_THRESHOLD) {
425
- return;
426
- }
427
- step(moved < 0);
428
- })}
429
- role="slider"
430
- // A drag handle that is not in the tab sequence is the WCAG 2.1.1
431
- // failure this part exists to avoid.
432
- tabIndex={0}
433
- />
434
- );
393
+ const props = withProps(passed, {
394
+ "aria-label": label,
395
+ // The axis the drag runs along, which is the axis the snap points are
396
+ // measured on: a bottom sheet grows upwards, so its slider is vertical.
397
+ "aria-orientation": vertical ? "vertical" : "horizontal",
398
+ "aria-valuemax": last,
399
+ "aria-valuemin": 0,
400
+ "aria-valuenow": snapIndex,
401
+ // The number a reader can act on. `aria-valuenow` is an index into a list
402
+ // nobody outside this component has seen, and "2" says nothing.
403
+ "aria-valuetext": `${String(Math.round((snapPoints[snapIndex] ?? 1) * 100))}%`,
404
+ "data-dragging": dragging ? "true" : undefined,
405
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
406
+ if (event.key === "Home" || event.key === "End") {
407
+ event.preventDefault();
408
+ setSnapIndex(event.key === "Home" ? 0 : last);
409
+ return;
410
+ }
411
+ const towardsOpen = OPENS_WITH[side];
412
+ const towardsClosed = CLOSES_WITH[side];
413
+ if (event.key === towardsOpen) {
414
+ event.preventDefault();
415
+ step(true);
416
+ return;
417
+ }
418
+ if (event.key === towardsClosed) {
419
+ event.preventDefault();
420
+ step(false);
421
+ }
422
+ }),
423
+ onPointerDown: composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
424
+ dragFrom.current = vertical ? event.clientY : event.clientX;
425
+ setDragging(true);
426
+ // So the drag survives the pointer leaving the handle, which it does
427
+ // immediately: the handle moves with the drawer.
428
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
429
+ }),
430
+ onPointerMove: composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
431
+ const body = bodyRef.current;
432
+ if (dragFrom.current == null || body == null) {
433
+ return;
434
+ }
435
+ // Only away from the edge: dragging a drawer *past* fully open would
436
+ // otherwise lift it off the edge it is attached to.
437
+ body.style.setProperty("--uf-drawer-drag", `${String(Math.max(0, travelled(event)))}px`);
438
+ }),
439
+ onPointerUp: composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
440
+ const body = bodyRef.current;
441
+ const moved = travelled(event);
442
+ dragFrom.current = null;
443
+ setDragging(false);
444
+ body?.style.setProperty("--uf-drawer-drag", "0px");
445
+ if (body == null) {
446
+ return;
447
+ }
448
+ const box = body.getBoundingClientRect();
449
+ const size = vertical ? box.height : box.width;
450
+ // A zero-sized box — a document that computes no layout — must not turn
451
+ // every release into a dismissal.
452
+ if (size <= 0 || Math.abs(moved) < size * DRAG_THRESHOLD) {
453
+ return;
454
+ }
455
+ step(moved < 0);
456
+ }),
457
+ role: "slider",
458
+ // A drag handle that is not in the tab sequence is the WCAG 2.1.1
459
+ // failure this part exists to avoid.
460
+ tabIndex: 0,
461
+ });
462
+
463
+ if (render != null) {
464
+ return render(props);
465
+ }
466
+ return <div {...props} />;
435
467
  }
436
468
 
437
469
  /**
package/field.js CHANGED
@@ -96,7 +96,7 @@
96
96
  import * as React from "@uniflowed/react";
97
97
  import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
98
98
 
99
- import type { Rest } from "./internal/merge-props.js";
99
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
100
100
  import { withProps } from "./internal/merge-props.js";
101
101
 
102
102
  /**
@@ -129,6 +129,7 @@ export type FieldSource = {|
129
129
  readonly invalid: boolean,
130
130
  readonly required: boolean,
131
131
  readonly disabled: boolean,
132
+ readonly busy: boolean,
132
133
  /** What is wrong, or null while the field is valid. */
133
134
  readonly message: string | null,
134
135
  readonly control: Rest,
@@ -141,14 +142,17 @@ type FieldState = {|
141
142
  readonly controlId: string,
142
143
  readonly labelId: string,
143
144
  readonly descriptionId: string,
145
+ readonly statusId: string,
144
146
  readonly errorId: string,
145
147
  readonly invalid: boolean,
146
148
  readonly required: boolean,
149
+ readonly busy: boolean,
147
150
  readonly group: boolean,
148
151
  readonly message: string | null,
149
152
  readonly control: Rest,
150
153
  readonly describedBy: string | void,
151
154
  readonly registerDescription: (present: boolean) => void,
155
+ readonly registerStatus: (present: boolean) => void,
152
156
  readonly registerError: (present: boolean) => void,
153
157
  |};
154
158
 
@@ -187,26 +191,32 @@ export component FieldRoot(
187
191
  children: React.Node,
188
192
  invalid?: boolean = false,
189
193
  required?: boolean = false,
194
+ busy?: boolean = false,
190
195
  field?: FieldSource,
191
196
  group?: boolean = false,
197
+ render?: RenderProp,
192
198
  ...rest: Rest
193
199
  ) {
194
200
  const base = useId();
195
201
  const [hasDescription, setHasDescription] = useState(false);
202
+ const [hasStatus, setHasStatus] = useState(false);
196
203
  const [hasError, setHasError] = useState(false);
197
204
  const sourceInvalid = field?.invalid ?? false;
198
205
  const sourceRequired = field?.required ?? false;
206
+ const sourceBusy = field?.busy ?? false;
199
207
  const message = field?.message ?? null;
200
208
  const control = field?.control ?? NO_CONTROL;
201
209
 
202
210
  const state = useMemo(() => {
203
211
  const descriptionId = `${base}-description`;
212
+ const statusId = `${base}-status`;
204
213
  const errorId = `${base}-error`;
205
214
  const wrong = invalid || sourceInvalid;
206
215
  // Only ids that are in the document. `aria-describedby` naming a missing
207
216
  // element makes a screen reader announce nothing rather than skipping it.
208
217
  const described = [
209
218
  hasDescription ? descriptionId : null,
219
+ hasStatus ? statusId : null,
210
220
  wrong && hasError ? errorId : null,
211
221
  ].filter(Boolean);
212
222
 
@@ -214,14 +224,17 @@ export component FieldRoot(
214
224
  controlId: `${base}-control`,
215
225
  labelId: `${base}-label`,
216
226
  descriptionId,
227
+ statusId,
217
228
  errorId,
218
229
  invalid: wrong,
219
230
  required: required || sourceRequired,
231
+ busy: busy || sourceBusy,
220
232
  group,
221
233
  message,
222
234
  control,
223
235
  describedBy: described.length === 0 ? undefined : described.join(" "),
224
236
  registerDescription: setHasDescription,
237
+ registerStatus: setHasStatus,
225
238
  registerError: setHasError,
226
239
  };
227
240
  }, [
@@ -230,27 +243,31 @@ export component FieldRoot(
230
243
  sourceInvalid,
231
244
  required,
232
245
  sourceRequired,
246
+ busy,
247
+ sourceBusy,
233
248
  group,
234
249
  message,
235
250
  control,
236
251
  hasDescription,
252
+ hasStatus,
237
253
  hasError,
238
254
  ]);
239
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
+ });
267
+
240
268
  return (
241
269
  <FieldContext.Provider value={state}>
242
- <div
243
- {...rest}
244
- // A group names itself, describes itself and reports its own validity,
245
- // because there is no one control inside it to carry any of the three.
246
- // See the module header.
247
- aria-describedby={group ? state.describedBy : undefined}
248
- aria-invalid={group && state.invalid ? "true" : undefined}
249
- aria-labelledby={group ? state.labelId : undefined}
250
- role={group ? "group" : undefined}
251
- >
252
- {children}
253
- </div>
270
+ {render != null ? render(props) : <div {...props} />}
254
271
  </FieldContext.Provider>
255
272
  );
256
273
  }
@@ -262,22 +279,16 @@ export component FieldRoot(
262
279
  * `<label for>` naming something that is not a form control is ignored by every
263
280
  * browser, and ignored silently. The module header says more.
264
281
  */
265
- export component FieldLabel(children: React.Node, ...rest: Rest) {
282
+ export component FieldLabel(children: React.Node, render?: RenderProp, ...rest: Rest) {
266
283
  const field = useField("Field.Label");
267
284
  // `rest` first: a caller `id` here would break the relationship the control
268
285
  // points at, and it would break it silently.
269
286
  if (field.group) {
270
- return (
271
- <span {...rest} id={field.labelId}>
272
- {children}
273
- </span>
274
- );
287
+ const props = withProps(rest, { children, id: field.labelId });
288
+ return render != null ? render(props) : <span {...props} />;
275
289
  }
276
- return (
277
- <label {...rest} htmlFor={field.controlId} id={field.labelId}>
278
- {children}
279
- </label>
280
- );
290
+ const props = withProps(rest, { children, htmlFor: field.controlId, id: field.labelId });
291
+ return render != null ? render(props) : <label {...props} />;
281
292
  }
282
293
 
283
294
  /**
@@ -289,7 +300,7 @@ export component FieldLabel(children: React.Node, ...rest: Rest) {
289
300
  * `ref` and handlers *and* the field's attributes from one spread instead of
290
301
  * two that overwrite each other.
291
302
  */
292
- export component FieldControl(render: (props: Rest) => React.Node) {
303
+ export component FieldControl(render: RenderProp) {
293
304
  const field = useField("Field.Control");
294
305
  // A group already carries the name, the description and the validity, and a
295
306
  // control repeating them makes a reader hear the error once for the set and
@@ -297,6 +308,7 @@ export component FieldControl(render: (props: Rest) => React.Node) {
297
308
  const own: Rest = field.group
298
309
  ? { "aria-required": field.required ? "true" : undefined }
299
310
  : {
311
+ "aria-busy": field.busy ? "true" : undefined,
300
312
  id: field.controlId,
301
313
  "aria-labelledby": field.labelId,
302
314
  "aria-describedby": field.describedBy,
@@ -307,7 +319,7 @@ export component FieldControl(render: (props: Rest) => React.Node) {
307
319
  }
308
320
 
309
321
  /** Help text, which the control points at while it is rendered. */
310
- export component FieldDescription(children: React.Node, ...rest: Rest) {
322
+ export component FieldDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
311
323
  const field = useField("Field.Description");
312
324
  const register = field.registerDescription;
313
325
  useEffect(() => {
@@ -315,11 +327,32 @@ export component FieldDescription(children: React.Node, ...rest: Rest) {
315
327
  return () => register(false);
316
328
  }, [register]);
317
329
 
318
- return (
319
- <p {...rest} id={field.descriptionId}>
320
- {children}
321
- </p>
322
- );
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} />;
323
356
  }
324
357
 
325
358
  /**
@@ -334,7 +367,7 @@ export component FieldDescription(children: React.Node, ...rest: Rest) {
334
367
  * store does not need the caller to reach back into `formState.errors` for a
335
368
  * string the source is already carrying.
336
369
  */
337
- export component FieldError(children?: React.Node, ...rest: Rest) {
370
+ export component FieldError(children?: React.Node, render?: RenderProp, ...rest: Rest) {
338
371
  const field = useField("Field.Error");
339
372
  const register = field.registerError;
340
373
  useEffect(() => {
@@ -345,9 +378,10 @@ export component FieldError(children?: React.Node, ...rest: Rest) {
345
378
  if (!field.invalid) {
346
379
  return null;
347
380
  }
348
- return (
349
- <p {...rest} id={field.errorId} role="alert">
350
- {children ?? field.message}
351
- </p>
352
- );
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} />;
353
387
  }
package/hover-card.js CHANGED
@@ -45,7 +45,7 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
45
45
 
46
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,
@@ -127,11 +127,7 @@ 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
132
  const { closeDelay, dismissed, intent, openDelay, triggerRef } = card;
137
133
  useFocusableTrigger(triggerRef, "HoverCard.Trigger");
@@ -187,15 +183,12 @@ export component HoverCardTrigger(
187
183
  triggerRef.current = element;
188
184
  });
189
185
 
186
+ const props = withProps(withoutComposed(rest, ["ref"]), { children, ref: attach });
187
+
190
188
  if (render != null) {
191
- return render(withProps(withoutComposed(rest, ["ref"]), { ref: attach }));
189
+ return render(props);
192
190
  }
193
-
194
- return (
195
- <button {...withoutComposed(rest, ["ref"])} ref={attach} type="button">
196
- {children}
197
- </button>
198
- );
191
+ return <button {...props} type="button" />;
199
192
  }
200
193
 
201
194
  /**
@@ -213,6 +206,7 @@ export component HoverCardBody(
213
206
  alignOffset?: number = 0,
214
207
  avoidCollisions?: boolean = true,
215
208
  collisionPadding?: number = 0,
209
+ render?: RenderProp,
216
210
  side?: LogicalSide = "bottom",
217
211
  sideOffset?: number = 0,
218
212
  ...rest: Rest
@@ -317,18 +311,20 @@ export component HoverCardBody(
317
311
  return null;
318
312
  }
319
313
 
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
- );
314
+ const props = withProps(withoutComposed(rest, ["ref"]), {
315
+ children,
316
+ "data-align": anchored.align,
317
+ "data-side": anchored.side,
318
+ "data-state": "open",
319
+ id: `${card.base}-body`,
320
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
321
+ bodyRef.current = element;
322
+ }),
323
+ });
324
+
325
+ if (render != null) {
326
+ return render(props);
327
+ }
328
+
329
+ return <div {...props} />;
334
330
  }