@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/pagination.js CHANGED
@@ -44,8 +44,8 @@
44
44
 
45
45
  import * as React from "@uniflowed/react";
46
46
 
47
- import type { Rest } from "./internal/merge-props.js";
48
- import { withoutComposed } from "./internal/merge-props.js";
47
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
48
+ import { withProps, withoutComposed } from "./internal/merge-props.js";
49
49
 
50
50
  /**
51
51
  * The pagination, as a named landmark, and the region that announces it.
@@ -62,16 +62,16 @@ export component PaginationRoot(
62
62
  page?: number | null = null,
63
63
  pageCount?: number | null = null,
64
64
  announcePage?: (page: number, pageCount: number) => string,
65
+ render?: RenderProp,
65
66
  ...rest: Rest
66
67
  ) {
67
68
  const message =
68
69
  page == null || pageCount == null ? "" : (announcePage ?? defaultAnnouncement)(page, pageCount);
70
+ const props = withProps(rest, { "aria-label": label, children });
69
71
 
70
72
  return (
71
73
  <>
72
- <nav {...rest} aria-label={label}>
73
- {children}
74
- </nav>
74
+ {render == null ? <nav {...props} /> : render(withProps(props, { role: "navigation" }))}
75
75
  {/*
76
76
  Beside the navigation rather than inside it, so a reader walking the
77
77
  landmark hears the links and not a sentence about them — and mounted
@@ -93,9 +93,14 @@ export component PaginationRoot(
93
93
  */
94
94
  export component PaginationContent(
95
95
  children: renders* (PaginationItem | PaginationPrevious | PaginationNext),
96
+ render?: RenderProp,
96
97
  ...rest: Rest
97
98
  ) {
98
- return <ul {...rest}>{children}</ul>;
99
+ const props = withProps(rest, { children });
100
+ if (render != null) {
101
+ return render(withProps(props, { role: "list" }));
102
+ }
103
+ return <ul {...props} />;
99
104
  }
100
105
 
101
106
  /**
@@ -108,10 +113,11 @@ export component PaginationItem(
108
113
  children: React.Node,
109
114
  current?: boolean = false,
110
115
  disabled?: boolean = false,
116
+ render?: RenderProp,
111
117
  ...rest: Rest
112
118
  ) {
113
119
  return (
114
- <PageLink current={current} disabled={disabled} rest={rest}>
120
+ <PageLink current={current} disabled={disabled} render={render} rest={rest}>
115
121
  {children}
116
122
  </PageLink>
117
123
  );
@@ -129,10 +135,11 @@ export component PaginationPrevious(
129
135
  children?: React.Node,
130
136
  label?: string = "Previous page",
131
137
  disabled?: boolean = false,
138
+ render?: RenderProp,
132
139
  ...rest: Rest
133
140
  ) {
134
141
  return (
135
- <PageLink disabled={disabled} label={label} rest={rest}>
142
+ <PageLink disabled={disabled} label={label} render={render} rest={rest}>
136
143
  {children}
137
144
  </PageLink>
138
145
  );
@@ -143,10 +150,11 @@ export component PaginationNext(
143
150
  children?: React.Node,
144
151
  label?: string = "Next page",
145
152
  disabled?: boolean = false,
153
+ render?: RenderProp,
146
154
  ...rest: Rest
147
155
  ) {
148
156
  return (
149
- <PageLink disabled={disabled} label={label} rest={rest}>
157
+ <PageLink disabled={disabled} label={label} render={render} rest={rest}>
150
158
  {children}
151
159
  </PageLink>
152
160
  );
@@ -162,11 +170,10 @@ export component PaginationNext(
162
170
  * say, correctly, that `disabled` might not be a boolean. Handing the bag over
163
171
  * as one value keeps it a bag until it reaches the element it was always for.
164
172
  *
165
- * `disabled` drops the `href` rather than adding an attribute, because there
166
- * is no such thing as a disabled link: an `<a>` with no `href` is not in the
167
- * tab order and is not announced as a link, which is exactly what "there is no
168
- * previous page" means. `aria-disabled` is there too, so a reader who reaches
169
- * it another way is told why it does nothing.
173
+ * `disabled` drops the `href` and keeps the link role. That is the ARIA shape
174
+ * for "this page direction exists, but cannot be taken now": it is out of the
175
+ * tab order because there is no `href`, still named as Previous or Next, and
176
+ * announced as disabled rather than as a silent generic element.
170
177
  */
171
178
  component PageLink(
172
179
  rest: Rest,
@@ -174,19 +181,24 @@ component PageLink(
174
181
  current?: boolean = false,
175
182
  disabled?: boolean = false,
176
183
  label?: string,
184
+ render?: RenderProp,
177
185
  ) {
178
186
  const passed = withoutComposed(rest, disabled ? ["href"] : []);
187
+ const props = withProps(passed, {
188
+ "aria-current": current ? "page" : undefined,
189
+ "aria-disabled": disabled ? "true" : undefined,
190
+ "aria-label": label,
191
+ children,
192
+ role: disabled ? "link" : undefined,
193
+ });
194
+
195
+ if (render != null) {
196
+ return <li>{render(withProps(props, { role: "link" }))}</li>;
197
+ }
179
198
 
180
199
  return (
181
200
  <li>
182
- <a
183
- {...passed}
184
- aria-current={current ? "page" : undefined}
185
- aria-disabled={disabled ? "true" : undefined}
186
- aria-label={label}
187
- >
188
- {children}
189
- </a>
201
+ <a {...props} />
190
202
  </li>
191
203
  );
192
204
  }
package/popover.js CHANGED
@@ -58,15 +58,22 @@ import {
58
58
  } from "@uniflowed/react";
59
59
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
60
60
 
61
- import type { Align, Side } from "./internal/anchor.js";
62
- import type { Rest } from "./internal/merge-props.js";
63
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
61
+ import { useInteractOutside } from "./interactions.js";
62
+
63
+ import type { Align, LogicalSide } from "./internal/anchor.js";
64
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
65
+ import {
66
+ composeHandlers,
67
+ composeRefs,
68
+ withProps,
69
+ withoutComposed,
70
+ } from "./internal/merge-props.js";
64
71
  import { focusable } from "./internal/focus.js";
65
72
  import { useAnchor } from "./internal/anchor.js";
66
73
  import { useControlled } from "./internal/controlled-state.js";
67
74
  import { usePresence } from "./internal/disclosure.js";
68
75
 
69
- export type { Align, Side } from "./internal/anchor.js";
76
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
70
77
 
71
78
  type PopoverState = {|
72
79
  readonly base: string,
@@ -145,30 +152,31 @@ export component PopoverRoot(
145
152
  * trigger is not "outside" for exactly this reason — closing there and letting
146
153
  * this click reopen made the press a no-op that flickered.
147
154
  */
148
- export component PopoverTrigger(children: React.Node, ...rest: Rest) {
155
+ export component PopoverTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
149
156
  const popover = usePopover("Popover.Trigger");
150
157
  const passed = withoutComposed(rest, ["onClick", "ref"]);
151
158
  usePresence(popover.registerTrigger);
159
+ const props = withProps(passed, {
160
+ // Only while it is open: an `aria-controls` naming an element that is not
161
+ // in the document tells a reader there is somewhere to go and then has
162
+ // nowhere to send them.
163
+ "aria-controls": popover.open ? `${popover.base}-body` : undefined,
164
+ "aria-expanded": popover.open ? "true" : "false",
165
+ "aria-haspopup": "dialog",
166
+ children,
167
+ id: `${popover.base}-trigger`,
168
+ onClick: composeHandlers(rest.onClick, () => popover.setOpen(!popover.open)),
169
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
170
+ // React calls callback refs during commit; focus restoration reads it later.
171
+ // uf-lint-disable-next-line react-compiler/immutability
172
+ popover.triggerRef.current = element;
173
+ }),
174
+ });
152
175
 
153
- return (
154
- <button
155
- {...passed}
156
- // Only while it is open: an `aria-controls` naming an element that is not
157
- // in the document tells a reader there is somewhere to go and then has
158
- // nowhere to send them.
159
- aria-controls={popover.open ? `${popover.base}-body` : undefined}
160
- aria-expanded={popover.open ? "true" : "false"}
161
- aria-haspopup="dialog"
162
- id={`${popover.base}-trigger`}
163
- onClick={composeHandlers(rest.onClick, () => popover.setOpen(!popover.open))}
164
- ref={composeRefs(rest.ref, (element) => {
165
- popover.triggerRef.current = element;
166
- })}
167
- type="button"
168
- >
169
- {children}
170
- </button>
171
- );
176
+ if (render != null) {
177
+ return render(props);
178
+ }
179
+ return <button {...props} type="button" />;
172
180
  }
173
181
 
174
182
  /**
@@ -188,7 +196,16 @@ export component PopoverBody(
188
196
  alignOffset?: number = 0,
189
197
  avoidCollisions?: boolean = true,
190
198
  collisionPadding?: number = 0,
191
- side?: Side = "bottom",
199
+ /**
200
+ * Where focus lands when it opens, when the first focus stop is the wrong
201
+ * answer. The same prop `Dialog.Body` takes, deliberately spelled the same
202
+ * way: `DatePicker.Calendar` fills it with the day that holds the grid's tab
203
+ * stop, because a reader who opened a date picker is looking for the date and
204
+ * not for the button that steps back a month.
205
+ */
206
+ initialFocus?: { current: HTMLElement | null },
207
+ render?: RenderProp,
208
+ side?: LogicalSide = "bottom",
192
209
  sideOffset?: number = 0,
193
210
  ...rest: Rest
194
211
  ) {
@@ -205,13 +222,19 @@ export component PopoverBody(
205
222
  const left = useRef(false);
206
223
  const triggerRef = popover.triggerRef;
207
224
 
225
+ // useAnchor accepts ref objects and reads them from layout/effects.
226
+ // uf-lint-disable-next-line react-compiler/refs
208
227
  const anchored = useAnchor({
209
228
  align,
210
229
  alignOffset,
230
+ // uf-lint-disable-next-line react-compiler/refs
211
231
  anchorRef: triggerRef,
212
232
  avoidCollisions,
213
233
  collisionPadding,
234
+ // `open` is popover metadata; no ref value is read during render.
235
+ // uf-lint-disable-next-line react-compiler/refs
214
236
  open: popover.open,
237
+ // uf-lint-disable-next-line react-compiler/refs
215
238
  overlayRef: bodyRef,
216
239
  side,
217
240
  sideOffset,
@@ -234,17 +257,6 @@ export component PopoverBody(
234
257
  return node != null && !body.contains(node) && !(trigger?.contains(node) ?? false);
235
258
  };
236
259
 
237
- const onOutsidePress = (event: Event) => {
238
- if (!outside(event.target)) {
239
- return;
240
- }
241
- left.current = true;
242
- close();
243
- };
244
- // Capture, so a press is seen even where something below it stops the
245
- // event — a menu inside the popover, for instance.
246
- document.addEventListener("pointerdown", onOutsidePress, true);
247
-
248
260
  // Tab out is a dismissal, not an escape hatch that leaves a popover open
249
261
  // behind the reader: a non-modal overlay whose reader has gone is one they
250
262
  // can no longer press Escape at, because Escape is handled where focus is.
@@ -257,13 +269,17 @@ export component PopoverBody(
257
269
  };
258
270
  document.addEventListener("focusin", onFocusMoved, true);
259
271
 
260
- // The first thing worth acting on, and the popover itself when it holds
261
- // nothing focusable, so focus is inside it either way and Escape reaches
262
- // the handler below.
263
- (focusable(body)[0] ?? body).focus();
272
+ // Where the caller said, then the first thing worth acting on, then the
273
+ // popover itself when it holds nothing focusable - so focus is inside it
274
+ // whichever of the three answers, and Escape reaches the handler below.
275
+ //
276
+ // The named element has to still be *in* this popover, for the reason
277
+ // `dialog.js` gives at the same line: a ref left from a previous opening
278
+ // would move focus somewhere the reader did not open.
279
+ const named = initialFocus?.current ?? null;
280
+ ((named != null && body.contains(named) ? named : focusable(body)[0]) ?? body).focus();
264
281
 
265
282
  return () => {
266
- document.removeEventListener("pointerdown", onOutsidePress, true);
267
283
  document.removeEventListener("focusin", onFocusMoved, true);
268
284
  if (left.current) {
269
285
  left.current = false;
@@ -278,8 +294,28 @@ export component PopoverBody(
278
294
  opener?.focus?.();
279
295
  }
280
296
  };
281
- }, [popover.open, triggerRef, close]);
297
+ // The ref objects are stable; this effect reads them after the opening commit.
298
+ // uf-lint-disable-next-line react-compiler/refs
299
+ }, [popover.open, triggerRef, close, initialFocus]);
282
300
 
301
+ // A press outside is the other way a reader leaves, and the trigger is not
302
+ // "outside" for the reason the module header gives.
303
+ // The outside-interaction hook accepts refs; it reads them from event handlers.
304
+ // uf-lint-disable-next-line react-compiler/refs
305
+ useInteractOutside({
306
+ // `open` is popover metadata; the hook reads refs from event handlers.
307
+ // uf-lint-disable-next-line react-compiler/refs
308
+ isDisabled: !popover.open,
309
+ onInteractOutside: () => {
310
+ left.current = true;
311
+ close();
312
+ },
313
+ // uf-lint-disable-next-line react-compiler/refs
314
+ refs: [bodyRef, triggerRef],
315
+ });
316
+
317
+ // `open` is popover metadata; no ref value is read during render.
318
+ // uf-lint-disable-next-line react-compiler/refs
283
319
  if (!popover.open) {
284
320
  return null;
285
321
  }
@@ -290,37 +326,42 @@ export component PopoverBody(
290
326
  // leave the popover announced as its button rather than as itself.
291
327
  const named = rest["aria-label"] != null || rest["aria-labelledby"] != null;
292
328
 
293
- return (
294
- <div
295
- // `passed` first, then this component's semantics; see
296
- // `internal/merge-props.js` for the three bugs that rule is made of.
297
- {...passed}
298
- aria-labelledby={named || !popover.triggered ? undefined : `${popover.base}-trigger`}
299
- data-align={anchored.align}
300
- data-side={anchored.side}
301
- data-state="open"
302
- id={`${popover.base}-body`}
303
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
304
- if (event.key !== "Escape") {
305
- return;
306
- }
307
- event.preventDefault();
308
- // This popover, not the dialog around it. Two overlays nest in the
309
- // DOM, so without this one Escape closed both.
310
- event.stopPropagation();
311
- close();
312
- })}
313
- ref={composeRefs(rest.ref, (element) => {
314
- bodyRef.current = element;
315
- })}
316
- // No `aria-modal`. The page behind a popover is still available, and
317
- // saying otherwise is the one lie a screen reader cannot see through.
318
- role="dialog"
319
- // So the popover can hold focus itself when it contains nothing
320
- // focusable, and so Escape has somewhere to be heard.
321
- tabIndex={-1}
322
- >
323
- {children}
324
- </div>
325
- );
329
+ const props = withProps(passed, {
330
+ // `triggered` and `base` are popover metadata, not ref values.
331
+ // uf-lint-disable-next-line react-compiler/refs
332
+ "aria-labelledby": named || !popover.triggered ? undefined : `${popover.base}-trigger`,
333
+ children,
334
+ "data-align": anchored.align,
335
+ "data-side": anchored.side,
336
+ "data-state": "open",
337
+ // `base` is popover metadata, not a ref value.
338
+ // uf-lint-disable-next-line react-compiler/refs
339
+ id: `${popover.base}-body`,
340
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
341
+ if (event.key !== "Escape") {
342
+ return;
343
+ }
344
+ event.preventDefault();
345
+ // This popover, not the dialog around it. Two overlays nest in the
346
+ // DOM, so without this one Escape closed both.
347
+ event.stopPropagation();
348
+ close();
349
+ }),
350
+ // React calls callback refs during commit; placement effects and traps read it later.
351
+ // uf-lint-disable-next-line react-compiler/refs
352
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
353
+ bodyRef.current = element;
354
+ }),
355
+ // No `aria-modal`. The page behind a popover is still available, and
356
+ // saying otherwise is the one lie a screen reader cannot see through.
357
+ role: "dialog",
358
+ // So the popover can hold focus itself when it contains nothing focusable,
359
+ // and so Escape has somewhere to be heard.
360
+ tabIndex: -1,
361
+ });
362
+
363
+ if (render != null) {
364
+ return render(props);
365
+ }
366
+ return <div {...props} />;
326
367
  }
package/progress.js CHANGED
@@ -38,7 +38,8 @@
38
38
 
39
39
  import * as React from "@uniflowed/react";
40
40
 
41
- import type { Rest } from "./internal/merge-props.js";
41
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
42
+ import { withProps } from "./internal/merge-props.js";
42
43
  import { clamp } from "./internal/range.js";
43
44
 
44
45
  /**
@@ -57,6 +58,9 @@ import { clamp } from "./internal/range.js";
57
58
  * `Slider`, whose value may be its own, a progress bar's value arrived as a
58
59
  * prop, so the caller already has everything they need to size a bar with and
59
60
  * a helpful custom property would only be their own arithmetic handed back.
61
+ *
62
+ * `render` changes the element and not the accessibility contract: the
63
+ * progressbar role and value attributes are the props handed to the caller.
60
64
  */
61
65
  export component Progress(
62
66
  value?: number | null = null,
@@ -64,23 +68,24 @@ export component Progress(
64
68
  max?: number = 100,
65
69
  valueText?: string,
66
70
  children?: React.Node,
71
+ render?: RenderProp,
67
72
  ...rest: Rest
68
73
  ) {
69
74
  const known = value == null ? null : clamp(value, min, max);
75
+ const props = withProps(rest, {
76
+ "aria-valuemax": max,
77
+ "aria-valuemin": min,
78
+ // Omitted, not zeroed. `aria-valuenow="0"` tells a reader that nothing
79
+ // has happened; leaving it out tells them the amount is unknown, which
80
+ // is the true one and the one a spinner means.
81
+ "aria-valuenow": known ?? undefined,
82
+ "aria-valuetext": valueText,
83
+ children,
84
+ role: "progressbar",
85
+ });
70
86
 
71
- return (
72
- <div
73
- {...rest}
74
- aria-valuemax={max}
75
- aria-valuemin={min}
76
- // Omitted, not zeroed. `aria-valuenow="0"` tells a reader that nothing
77
- // has happened; leaving it out tells them the amount is unknown, which
78
- // is the true one and the one a spinner means.
79
- aria-valuenow={known ?? undefined}
80
- aria-valuetext={valueText}
81
- role="progressbar"
82
- >
83
- {children}
84
- </div>
85
- );
87
+ if (render != null) {
88
+ return render(props);
89
+ }
90
+ return <div {...props} />;
86
91
  }
package/radio-group.js CHANGED
@@ -76,8 +76,13 @@
76
76
  import * as React from "@uniflowed/react";
77
77
  import { createContext, useCallback, useContext, useId, useMemo, useRef } from "@uniflowed/react";
78
78
 
79
- import type { Rest } from "./internal/merge-props.js";
80
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
79
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
80
+ import {
81
+ composeHandlers,
82
+ composeRefs,
83
+ withoutComposed,
84
+ withProps,
85
+ } from "./internal/merge-props.js";
81
86
  import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
82
87
  import type { Orientation, RovingSet } from "./internal/roving-focus.js";
83
88
  import { useControlled } from "./internal/controlled-state.js";
@@ -146,6 +151,7 @@ export component RadioGroupRoot(
146
151
  onValueChange?: (value: string) => void,
147
152
  orientation?: Orientation = "vertical",
148
153
  name?: string,
154
+ render?: RenderProp,
149
155
  ...rest: Rest
150
156
  ) {
151
157
  // `onValueChange` promises a `string` while the group's *state* is
@@ -171,44 +177,48 @@ export component RadioGroupRoot(
171
177
 
172
178
  const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
173
179
  const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
180
+ const content = (
181
+ <>
182
+ {children}
183
+ {/*
184
+ A form submits `<input>` elements, and none of the parts above is one.
185
+ Without this the group is a control a reader can operate and a form
186
+ cannot read, which is the same hole `Combobox` still has.
187
+
188
+ `type="hidden"` rather than a visually hidden real radio, because the
189
+ buttons above already carry the whole of the accessible semantics: a
190
+ second set of native radios would be announced as a second set of
191
+ answers, and hiding them from the accessibility tree to stop that
192
+ leaves elements a form's own validation would then point its
193
+ "please choose one" at.
194
+ */}
195
+ {name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
196
+ </>
197
+ );
198
+ const props = withProps(passed, {
199
+ "aria-orientation": orientation,
200
+ children: content,
201
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
202
+ const group: $FlowFixMe = event.currentTarget;
203
+ const next = moveOnKey(event, group, radioSet(orientation));
204
+ if (next != null) {
205
+ // Checking in the same key press is not a shortcut, it is the
206
+ // pattern: a radio group whose arrows moved focus without checking
207
+ // leaves a reader believing they have answered when they have not.
208
+ select(next.getAttribute("data-value") ?? "");
209
+ }
210
+ }),
211
+ // React calls callback refs during commit; keyboard handlers read it later.
212
+ // uf-lint-disable-next-line react-compiler/refs
213
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
214
+ rootRef.current = element;
215
+ }),
216
+ role: "radiogroup",
217
+ });
174
218
 
175
219
  return (
176
220
  <RadioGroupContext.Provider value={state}>
177
- <div
178
- {...passed}
179
- // A reader is told which axis this runs along, and it is also what says
180
- // which pair of arrow keys is live.
181
- aria-orientation={orientation}
182
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
183
- const group: $FlowFixMe = event.currentTarget;
184
- const next = moveOnKey(event, group, radioSet(orientation));
185
- if (next != null) {
186
- // Checking in the same key press is not a shortcut, it is the
187
- // pattern: a radio group whose arrows moved focus without checking
188
- // leaves a reader believing they have answered when they have not.
189
- select(next.getAttribute("data-value") ?? "");
190
- }
191
- })}
192
- ref={composeRefs(rest.ref, (element) => {
193
- rootRef.current = element;
194
- })}
195
- role="radiogroup"
196
- >
197
- {children}
198
- {/*
199
- A form submits `<input>` elements, and none of the parts above is one.
200
- Without this the group is a control a reader can operate and a form
201
- cannot read, which is the same hole `Combobox` still has.
202
-
203
- `type="hidden"` rather than a visually hidden real radio, because the
204
- buttons above already carry the whole of the accessible semantics: a
205
- second set of native radios would be announced as a second set of
206
- answers, and hiding them from the accessibility tree to stop that
207
- leaves elements a form's own validation would then point its
208
- "please choose one" at.
209
- */}
210
- {name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
211
- </div>
221
+ {render == null ? <div {...props} /> : render(props)}
212
222
  </RadioGroupContext.Provider>
213
223
  );
214
224
  }
@@ -226,6 +236,7 @@ export component RadioGroupItem(
226
236
  value: string,
227
237
  children?: React.Node,
228
238
  disabled?: boolean = false,
239
+ render?: RenderProp,
229
240
  ...rest: Rest
230
241
  ) {
231
242
  const group = useRadioGroup("RadioGroup.Item");
@@ -233,41 +244,39 @@ export component RadioGroupItem(
233
244
  const checked = group.selected === value;
234
245
  const item = useMemo(() => ({ checked }), [checked]);
235
246
  const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
247
+ const props = withProps(passed, {
248
+ "aria-checked": checked ? "true" : "false",
249
+ "aria-disabled": disabled ? "true" : undefined,
250
+ // Read by the group's key handler, which finds items in the document
251
+ // rather than in a registry and so needs each one to carry its value.
252
+ "data-value": value,
253
+ children,
254
+ id,
255
+ onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
256
+ if (!disabled) {
257
+ group.select(value);
258
+ }
259
+ }),
260
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
261
+ if (disabled || event.key !== " ") {
262
+ return;
263
+ }
264
+ // Stops `Space` scrolling the page — which is what makes a
265
+ // hand-written radio feel broken even when it works — and stops the
266
+ // browser's own click arriving afterwards to check this again.
267
+ event.preventDefault();
268
+ group.select(value);
269
+ }),
270
+ role: "radio",
271
+ // The roving tab stop: the chosen answer, or the first one while there
272
+ // is no answer, so `Tab` reaches the group in either state and leaves
273
+ // it in one press.
274
+ tabIndex: checked || (group.selected == null && group.firstId === id) ? 0 : -1,
275
+ });
236
276
 
237
277
  return (
238
278
  <RadioItemContext.Provider value={item}>
239
- <button
240
- {...passed}
241
- aria-checked={checked ? "true" : "false"}
242
- aria-disabled={disabled ? "true" : undefined}
243
- // Read by the group's key handler, which finds items in the document
244
- // rather than in a registry and so needs each one to carry its value.
245
- data-value={value}
246
- id={id}
247
- onClick={composeHandlers(rest.onClick, () => {
248
- if (!disabled) {
249
- group.select(value);
250
- }
251
- })}
252
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
253
- if (disabled || event.key !== " ") {
254
- return;
255
- }
256
- // Stops `Space` scrolling the page — which is what makes a
257
- // hand-written radio feel broken even when it works — and stops the
258
- // browser's own click arriving afterwards to check this again.
259
- event.preventDefault();
260
- group.select(value);
261
- })}
262
- role="radio"
263
- // The roving tab stop: the chosen answer, or the first one while there
264
- // is no answer, so `Tab` reaches the group in either state and leaves
265
- // it in one press.
266
- tabIndex={checked || (group.selected == null && group.firstId === id) ? 0 : -1}
267
- type="button"
268
- >
269
- {children}
270
- </button>
279
+ {render == null ? <button {...props} type="button" /> : render(props)}
271
280
  </RadioItemContext.Provider>
272
281
  );
273
282
  }
@@ -282,7 +291,7 @@ export component RadioGroupItem(
282
291
  * `[aria-checked="true"] > *`, and so the "only while chosen" part is not
283
292
  * something each caller reimplements.
284
293
  */
285
- export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
294
+ export component RadioGroupIndicator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
286
295
  const item = useContext(RadioItemContext);
287
296
  if (item == null) {
288
297
  throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
@@ -290,9 +299,6 @@ export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
290
299
  if (!item.checked) {
291
300
  return null;
292
301
  }
293
- return (
294
- <span {...rest} aria-hidden="true">
295
- {children}
296
- </span>
297
- );
302
+ const props = withProps(rest, { "aria-hidden": "true", children });
303
+ return render == null ? <span {...props} /> : render(props);
298
304
  }