@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/tabs.js CHANGED
@@ -50,8 +50,8 @@ import {
50
50
  useState,
51
51
  } from "@uniflowed/react";
52
52
 
53
- import type { Rest } from "./internal/merge-props.js";
54
- import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
53
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
54
+ import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
55
55
  import { moveOnKey } from "./internal/roving-focus.js";
56
56
  import { useControlled } from "./internal/controlled-state.js";
57
57
  import type { Orientation } from "./internal/roving-focus.js";
@@ -94,6 +94,7 @@ export component TabsRoot(
94
94
  onValueChange?: (value: string) => void,
95
95
  activationMode?: ActivationMode = "automatic",
96
96
  orientation?: Orientation = "horizontal",
97
+ render?: RenderProp,
97
98
  ...rest: Rest
98
99
  ) {
99
100
  const base = useId();
@@ -125,9 +126,11 @@ export component TabsRoot(
125
126
  [base, selected, select, orientation, activationMode, mounted, registerPanel],
126
127
  );
127
128
 
129
+ const props = withProps(rest, { children });
130
+
128
131
  return (
129
132
  <TabsContext.Provider value={state}>
130
- <div {...rest}>{children}</div>
133
+ {render == null ? <div {...props} /> : render(props)}
131
134
  </TabsContext.Provider>
132
135
  );
133
136
  }
@@ -142,38 +145,37 @@ export component TabsRoot(
142
145
  * tabs push themselves into as they mount answers with mount order, which stops
143
146
  * being document order the first time a tab is conditional.
144
147
  */
145
- export component TabsList(children: renders* TabsTab, ...rest: Rest) {
148
+ export component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Rest) {
146
149
  const tabs = useTabs("Tabs.List");
147
- const passed = withoutComposed(rest, ["onKeyDown"]);
150
+ const props = withProps(withoutComposed(rest, ["onKeyDown"]), {
151
+ // A screen reader announces the axis, and it is also what tells a reader
152
+ // which arrow keys to try.
153
+ "aria-orientation": tabs.orientation,
154
+ children,
155
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
156
+ const list: $FlowFixMe = event.currentTarget;
157
+ // The list the key arrived on carries the answer to both halves of
158
+ // this: which items there are, and which way the page reads — so a tab
159
+ // set inside somebody else's `dir="rtl"` walks the right way without
160
+ // the caller having had to know it needed to say so.
161
+ const next = moveOnKey(event, list, {
162
+ item: '[role="tab"]',
163
+ owner: '[role="tablist"]',
164
+ orientation: tabs.orientation,
165
+ wrap: true,
166
+ skipDisabled: true,
167
+ });
168
+ if (next != null && tabs.activation === "automatic") {
169
+ tabs.select(next.getAttribute("data-value") ?? "");
170
+ }
171
+ }),
172
+ role: "tablist",
173
+ });
148
174
 
149
- return (
150
- <div
151
- {...passed}
152
- // A screen reader announces the axis, and it is also what tells a reader
153
- // which arrow keys to try.
154
- aria-orientation={tabs.orientation}
155
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
156
- const list: $FlowFixMe = event.currentTarget;
157
- // The list the key arrived on carries the answer to both halves of
158
- // this: which items there are, and which way the page reads — so a tab
159
- // set inside somebody else's `dir="rtl"` walks the right way without
160
- // the caller having had to know it needed to say so.
161
- const next = moveOnKey(event, list, {
162
- item: '[role="tab"]',
163
- owner: '[role="tablist"]',
164
- orientation: tabs.orientation,
165
- wrap: true,
166
- skipDisabled: true,
167
- });
168
- if (next != null && tabs.activation === "automatic") {
169
- tabs.select(next.getAttribute("data-value") ?? "");
170
- }
171
- })}
172
- role="tablist"
173
- >
174
- {children}
175
- </div>
176
- );
175
+ if (render != null) {
176
+ return render(props);
177
+ }
178
+ return <div {...props} />;
177
179
  }
178
180
 
179
181
  /**
@@ -188,54 +190,55 @@ export component TabsTab(
188
190
  value: string,
189
191
  children: React.Node,
190
192
  disabled?: boolean = false,
193
+ render?: RenderProp,
191
194
  ...rest: Rest
192
195
  ) {
193
196
  const tabs = useTabs("Tabs.Tab");
194
197
  const active = tabs.selected === value;
195
- const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
198
+ // The caller's props first, and everything this component owns after them. A
199
+ // caller `onClick` used to replace the selection handler, so clicking a tab
200
+ // did nothing at all. `render` is handed this same object in this same
201
+ // order, which is why a tab rendered as somebody else's element is not a
202
+ // weaker tab.
203
+ const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown"]), {
204
+ "aria-disabled": disabled ? "true" : undefined,
205
+ // Only when the panel is actually mounted. Panels are rendered on demand,
206
+ // and a tab pointing `aria-controls` at an id that is not in the document
207
+ // tells a reader there is somewhere to go and then has nowhere to send
208
+ // them.
209
+ "aria-controls": tabs.mounted.includes(value) ? `${tabs.base}-panel-${value}` : undefined,
210
+ "aria-selected": active ? "true" : "false",
211
+ children,
212
+ // Read by the list's key handler, which finds tabs in the document rather
213
+ // than in a registry and so needs each one to carry its own value.
214
+ "data-value": value,
215
+ id: `${tabs.base}-tab-${value}`,
216
+ onClick: composeHandlers(rest.onClick, () => {
217
+ if (!disabled) {
218
+ tabs.select(value);
219
+ }
220
+ }),
221
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
222
+ // Manual activation's other half: the arrows moved focus here without
223
+ // selecting, and this is how the reader says they meant it.
224
+ if (event.key !== "Enter" && event.key !== " ") {
225
+ return;
226
+ }
227
+ event.preventDefault();
228
+ if (!disabled) {
229
+ tabs.select(value);
230
+ }
231
+ }),
232
+ role: "tab",
233
+ // The roving tabindex: Tab reaches the selected tab and nothing else in
234
+ // the list, so it moves past the whole set in one press.
235
+ tabIndex: active ? 0 : -1,
236
+ });
196
237
 
197
- return (
198
- <button
199
- // `passed` first, and everything this component owns after it. A caller
200
- // `onClick` used to replace the selection handler, so clicking a tab did
201
- // nothing at all.
202
- {...passed}
203
- aria-disabled={disabled ? "true" : undefined}
204
- // Only when the panel is actually mounted. Panels are rendered on demand,
205
- // and a tab pointing `aria-controls` at an id that is not in the document
206
- // tells a reader there is somewhere to go and then has nowhere to send
207
- // them.
208
- aria-controls={tabs.mounted.includes(value) ? `${tabs.base}-panel-${value}` : undefined}
209
- aria-selected={active ? "true" : "false"}
210
- // Read by the list's key handler, which finds tabs in the document rather
211
- // than in a registry and so needs each one to carry its own value.
212
- data-value={value}
213
- id={`${tabs.base}-tab-${value}`}
214
- onClick={composeHandlers(rest.onClick, () => {
215
- if (!disabled) {
216
- tabs.select(value);
217
- }
218
- })}
219
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
220
- // Manual activation's other half: the arrows moved focus here without
221
- // selecting, and this is how the reader says they meant it.
222
- if (event.key !== "Enter" && event.key !== " ") {
223
- return;
224
- }
225
- event.preventDefault();
226
- if (!disabled) {
227
- tabs.select(value);
228
- }
229
- })}
230
- role="tab"
231
- // The roving tabindex: Tab reaches the selected tab and nothing else in
232
- // the list, so it moves past the whole set in one press.
233
- tabIndex={active ? 0 : -1}
234
- type="button"
235
- >
236
- {children}
237
- </button>
238
- );
238
+ if (render != null) {
239
+ return render(props);
240
+ }
241
+ return <button {...props} type="button" />;
239
242
  }
240
243
 
241
244
  /**
@@ -246,7 +249,12 @@ export component TabsTab(
246
249
  * subscription rather than "the selected value equals mine", because a caller
247
250
  * may render a subset of panels, or none at all until data arrives.
248
251
  */
249
- export component TabsPanel(value: string, children: React.Node, ...rest: Rest) {
252
+ export component TabsPanel(
253
+ value: string,
254
+ children: React.Node,
255
+ render?: RenderProp,
256
+ ...rest: Rest
257
+ ) {
250
258
  const tabs = useTabs("Tabs.Panel");
251
259
  const register = tabs.registerPanel;
252
260
  const selected = tabs.selected === value;
@@ -263,18 +271,19 @@ export component TabsPanel(value: string, children: React.Node, ...rest: Rest) {
263
271
  return null;
264
272
  }
265
273
 
266
- return (
267
- <div
268
- {...rest}
269
- aria-labelledby={`${tabs.base}-tab-${value}`}
270
- id={`${tabs.base}-panel-${value}`}
271
- role="tabpanel"
272
- // The panel itself is focusable so that Tab out of the tab list lands on
273
- // the content the tab describes, which is where the reader expects to go
274
- // and where a panel of plain prose has nothing else to offer.
275
- tabIndex={0}
276
- >
277
- {children}
278
- </div>
279
- );
274
+ const props = withProps(rest, {
275
+ "aria-labelledby": `${tabs.base}-tab-${value}`,
276
+ children,
277
+ id: `${tabs.base}-panel-${value}`,
278
+ role: "tabpanel",
279
+ // The panel itself is focusable so that Tab out of the tab list lands on
280
+ // the content the tab describes, which is where the reader expects to go
281
+ // and where a panel of plain prose has nothing else to offer.
282
+ tabIndex: 0,
283
+ });
284
+
285
+ if (render != null) {
286
+ return render(props);
287
+ }
288
+ return <div {...props} />;
280
289
  }
package/tag-group.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 TagGroup(...props: CollectionProps) {
7
+ return <CollectionRoot options={props} kind="tags" />;
8
+ }
package/time-field.js ADDED
@@ -0,0 +1,8 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { SegmentedField } from "./internal/segmented-field.js";
5
+ import type { DateFieldProps } from "./internal/segmented-field.js";
6
+ export component TimeField(...props: DateFieldProps) {
7
+ return <SegmentedField options={props} time={true} />;
8
+ }
package/toast.js CHANGED
@@ -92,16 +92,18 @@
92
92
  //
93
93
  // `toast("Saved")` is called from an event handler, from a `catch`, from a
94
94
  // Server Action's error path — none of which have a component to put state in.
95
- // So the queue is a store in this module, read through `useSyncExternalStore`,
96
- // which is what `ubugeeei-redundancy.md` requires of an external store: cached
97
- // immutable snapshots, and a server snapshot consistent with them. Nothing here
98
- // is a mutable array a render reads.
95
+ // So the queue is an atom in `@uniflowed/state`, declared at module scope and
96
+ // written by the four module functions below. The live region reads it through
97
+ // React's external-store hook via `read` and `subscribe`, deliberately against
98
+ // the default store: a function in a module has no context with which to find a
99
+ // scoped store, and letting a nearby `<Provider>` redirect the region would
100
+ // split `toast()` from the place that displays it.
99
101
  //
100
- // Three properties are what that hook is actually asking for, and the store
101
- // below has those three and nothing more:
102
+ // Three properties are what the hook is actually asking for, and the atom gives
103
+ // this module those three without a second store implementation:
102
104
  //
103
105
  // - *An immutable snapshot whose reference changes only on a write.* The
104
- // getter hands back the array it is holding rather than building one, and a
106
+ // atom holds the array itself rather than building one on every read, and a
105
107
  // write that computes the value already there is dropped rather than
106
108
  // announced. A getter that returns a fresh `[]` is a new identity every time
107
109
  // React asks, which renders, which asks again — the infinite loop React
@@ -117,38 +119,10 @@
117
119
  // of notifications. A queue something could scope to a subtree would leave a
118
120
  // region showing an empty stack while `toast()` filled up a store it had no
119
121
  // way to find.
120
- //
121
- // # Why this is not an atom in `@uniflowed/state`
122
- //
123
- // It was one, and it should be one again. A queue read through
124
- // `useSyncExternalStore` is exactly what an atom is for, `@uniflowed/state` is
125
- // what this project offers instead of Jotai, and the version of this file that
126
- // imported `atom`, `read`, `subscribe` and `write` was not making a mistake.
127
- //
128
- // It cannot ship that way today. `@uniflowed/ui` is published to npm and
129
- // `@uniflowed/state` is not: its name has never been bound, binding it takes a
130
- // person with an `npm login` session and a 2FA prompt, and that is
131
- // ubugeeei-prod/uf#210. Until it happens the package waits in
132
- // `tools/release/pending-packages.txt`. A published package whose dependency
133
- // is missing installs as nothing — `ETARGET` on the first thing a user types —
134
- // so `tools/release/verify-npm.sh` refuses to release `@uniflowed/ui` while it
135
- // declares that dependency, and it is right to refuse.
136
- //
137
- // So the store below is the shippable design rather than the better one. It is
138
- // a second implementation of something this repository already has, written
139
- // out by hand because the first one cannot be installed. When #210 binds the
140
- // name and `state` moves into `published-packages.txt`, this goes back to an
141
- // atom and this section goes with it.
142
- //
143
- // This is the first `useSyncExternalStore` in this package, and `index.js` says
144
- // the package deliberately does not use one. That sentence is about reading
145
- // *layout* during a render, which is what the DOM-measuring components here
146
- // avoid by writing what they measured into state from an effect. A queue that
147
- // lives outside React is the case the API is for, and `index.js` now says both
148
- // things.
149
122
 
150
123
  "use client";
151
124
 
125
+ import { atom, read, subscribe, write } from "@uniflowed/state";
152
126
  import * as React from "@uniflowed/react";
153
127
  import {
154
128
  createContext,
@@ -215,15 +189,16 @@ const DEFAULT_DURATION = 5000;
215
189
  const NONE: $ReadOnlyArray<Notification> = Object.freeze([]);
216
190
 
217
191
  /**
218
- * The queue, and what is watching it.
192
+ * The queue atom.
219
193
  *
220
194
  * Module scope is the requirement rather than a convenience: `toast()` has
221
- * nowhere else to put this. It costs a server nothing — importing this file
222
- * allocates one frozen array and one empty `Set` and starts no work, and
223
- * nothing on a server calls `toast()`, because this module is `"use client"`.
195
+ * nowhere else to put this. It costs a server little — importing this file
196
+ * declares one atom over one frozen array and starts no work, and nothing on a
197
+ * server calls `toast()`, because this module is `"use client"`.
224
198
  */
225
- let queue: $ReadOnlyArray<Notification> = NONE;
226
- const watchers: Set<() => void> = new Set();
199
+ const queueAtom = atom<$ReadOnlyArray<Notification>>(NONE, {
200
+ debugLabel: "ui.toastQueue",
201
+ });
227
202
 
228
203
  /**
229
204
  * Replace the queue, and wake what is watching it.
@@ -244,16 +219,12 @@ const watchers: Set<() => void> = new Set();
244
219
  function writeQueue(
245
220
  change: (current: $ReadOnlyArray<Notification>) => $ReadOnlyArray<Notification>,
246
221
  ): void {
247
- const next = change(queue);
248
- if (next === queue) {
222
+ const current = read(queueAtom);
223
+ const next = change(current);
224
+ if (next === current) {
249
225
  return;
250
226
  }
251
- queue = next;
252
- for (const watcher of Array.from(watchers)) {
253
- if (watchers.has(watcher)) {
254
- watcher();
255
- }
256
- }
227
+ write(queueAtom, next);
257
228
  }
258
229
 
259
230
  /** Ids are this module's, because `useId` needs a component and `toast()` is not one. */
@@ -330,10 +301,7 @@ export function dismissAllToasts(): void {
330
301
 
331
302
  /** Module-level and therefore stable, which is what stops React re-subscribing. */
332
303
  function subscribeToQueue(listener: () => void): () => void {
333
- watchers.add(listener);
334
- return () => {
335
- watchers.delete(listener);
336
- };
304
+ return subscribe(queueAtom, listener);
337
305
  }
338
306
 
339
307
  /**
@@ -346,7 +314,7 @@ function subscribeToQueue(listener: () => void): () => void {
346
314
  * `"use client"`.
347
315
  */
348
316
  function readQueue(): $ReadOnlyArray<Notification> {
349
- return queue;
317
+ return read(queueAtom);
350
318
  }
351
319
 
352
320
  const NotificationContext: React.Context<Notification | null> = createContext(null);
@@ -388,26 +356,26 @@ export component ToastRegion(
388
356
  ...rest: Rest
389
357
  ) {
390
358
  const queued = useSyncExternalStore(subscribeToQueue, readQueue, readQueue);
391
- const region = useElementRef<HTMLElement>();
359
+ const regionRef = useElementRef<HTMLElement>();
392
360
  // Where `F6` came from, so pressing it again gives focus back.
393
- const cameFrom = useRef<HTMLElement | null>(null);
361
+ const cameFromRef = useRef<HTMLElement | null>(null);
394
362
  const passed = withoutComposed(rest, ["ref"]);
395
363
 
396
364
  useKeyCombo(
397
365
  "f6",
398
366
  () => {
399
- const element = region.current;
367
+ const element = regionRef.current;
400
368
  if (element == null) {
401
369
  return;
402
370
  }
403
371
  const active: $FlowFixMe = element.ownerDocument.activeElement;
404
372
  if (active != null && element.contains(active)) {
405
- const back = cameFrom.current;
406
- cameFrom.current = null;
373
+ const back = cameFromRef.current;
374
+ cameFromRef.current = null;
407
375
  back?.focus?.();
408
376
  return;
409
377
  }
410
- cameFrom.current = active;
378
+ cameFromRef.current = active;
411
379
  element.focus();
412
380
  },
413
381
  // The one case that matters is a notification arriving while the reader is
@@ -432,7 +400,7 @@ export component ToastRegion(
432
400
  {...passed}
433
401
  aria-label={label}
434
402
  ref={composeRefs(rest.ref, (element) => {
435
- region.current = element;
403
+ regionRef.current = element;
436
404
  })}
437
405
  role="region"
438
406
  // So `F6` can put focus on the region itself, including while it is
@@ -462,13 +430,13 @@ export component ToastRegion(
462
430
  export component ToastRoot(children: React.Node, ...rest: Rest) {
463
431
  const notification = useNotification("Toast.Root");
464
432
  const base = useId();
465
- const element = useElementRef<HTMLElement>();
433
+ const elementRef = useElementRef<HTMLElement>();
466
434
  const [titled, setTitled] = useState(false);
467
435
  const [described, setDescribed] = useState(false);
468
436
  const passed = withoutComposed(rest, ["ref"]);
469
437
 
470
- const hovered = useHover(element);
471
- const focusInside = useFocusWithin(element);
438
+ const hovered = useHover(elementRef);
439
+ const focusInside = useFocusWithin(elementRef);
472
440
  const documentVisible = useDocumentVisible();
473
441
  const paused = hovered || focusInside || !documentVisible;
474
442
 
@@ -497,6 +465,8 @@ export component ToastRoot(children: React.Node, ...rest: Rest) {
497
465
  // A notification whose duration was changed under it — "Uploading…" with no
498
466
  // duration becoming "Uploaded" with one — starts its countdown from there.
499
467
  useEffect(() => {
468
+ // The timeout duration is state because render hands it to useTimeout.
469
+ // uf-lint-disable-next-line react-compiler/set-state-in-effect
500
470
  setLeft(duration);
501
471
  startedAt.current = Date.now();
502
472
  }, [duration]);
@@ -518,7 +488,7 @@ export component ToastRoot(children: React.Node, ...rest: Rest) {
518
488
  aria-describedby={described ? parts.descriptionId : undefined}
519
489
  aria-labelledby={titled ? parts.titleId : undefined}
520
490
  ref={composeRefs(rest.ref, (node) => {
521
- element.current = node;
491
+ elementRef.current = node;
522
492
  })}
523
493
  role="group"
524
494
  >
package/toggle-group.js CHANGED
@@ -60,11 +60,12 @@ import {
60
60
  useState,
61
61
  } from "@uniflowed/react";
62
62
 
63
- import type { Rest } from "./internal/merge-props.js";
63
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
64
64
  import {
65
65
  composeHandlers,
66
66
  composeRefs,
67
67
  forwarded,
68
+ withProps,
68
69
  withoutComposed,
69
70
  } from "./internal/merge-props.js";
70
71
  import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
@@ -134,6 +135,7 @@ export component ToggleGroupRoot(
134
135
  value?: $ReadOnlyArray<string>,
135
136
  onValueChange?: (value: $ReadOnlyArray<string>) => void,
136
137
  orientation?: Orientation = "horizontal",
138
+ render?: RenderProp,
137
139
  ...rest: Rest
138
140
  ) {
139
141
  const [pressed, setPressed] = useControlled<$ReadOnlyArray<string>>(
@@ -180,6 +182,7 @@ export component ToggleGroupRoot(
180
182
  {...forwarded(rest)}
181
183
  onValueChange={chooseOne}
182
184
  orientation={orientation}
185
+ render={render}
183
186
  value={pressed[0] ?? null}
184
187
  >
185
188
  {children}
@@ -189,26 +192,26 @@ export component ToggleGroupRoot(
189
192
  }
190
193
 
191
194
  const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
195
+ const rootProps = withProps(passed, {
196
+ children,
197
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
198
+ const group: $FlowFixMe = event.currentTarget;
199
+ // Moves and nothing else. Pressing every button the arrows pass over
200
+ // is what a `single` group does, and doing it here would apply half a
201
+ // dozen commands on the way to the one the reader wanted.
202
+ moveOnKey(event, group, toggleSet(orientation));
203
+ }),
204
+ // React calls callback refs during commit; keyboard handlers read it later.
205
+ // uf-lint-disable-next-line react-compiler/refs
206
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
207
+ rootRef.current = element;
208
+ }),
209
+ role: "group",
210
+ });
192
211
 
193
212
  return (
194
213
  <ToggleGroupContext.Provider value={state}>
195
- <div
196
- {...passed}
197
- aria-orientation={orientation}
198
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
199
- const group: $FlowFixMe = event.currentTarget;
200
- // Moves and nothing else. Pressing every button the arrows pass over
201
- // is what a `single` group does, and doing it here would apply half a
202
- // dozen commands on the way to the one the reader wanted.
203
- moveOnKey(event, group, toggleSet(orientation));
204
- })}
205
- ref={composeRefs(rest.ref, (element) => {
206
- rootRef.current = element;
207
- })}
208
- role="group"
209
- >
210
- {children}
211
- </div>
214
+ {render != null ? render(rootProps) : <div {...rootProps} />}
212
215
  </ToggleGroupContext.Provider>
213
216
  );
214
217
  }
@@ -225,6 +228,7 @@ export component ToggleGroupItem(
225
228
  value: string,
226
229
  children?: React.Node,
227
230
  disabled?: boolean = false,
231
+ render?: RenderProp,
228
232
  ...rest: Rest
229
233
  ) {
230
234
  const group = useToggleGroup("ToggleGroup.Item");
@@ -237,7 +241,7 @@ export component ToggleGroupItem(
237
241
  // arrow keys that check as they move all belong to the radio group that
238
242
  // `ToggleGroup.Root` rendered around this.
239
243
  return (
240
- <RadioGroupItem {...forwarded(rest)} disabled={disabled} value={value}>
244
+ <RadioGroupItem {...forwarded(rest)} disabled={disabled} render={render} value={value}>
241
245
  {children}
242
246
  </RadioGroupItem>
243
247
  );
@@ -246,35 +250,35 @@ export component ToggleGroupItem(
246
250
  const on = group.pressed.includes(value);
247
251
  const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
248
252
  const setActiveId = group.setActiveId;
249
-
250
- return (
251
- <button
252
- {...passed}
253
- aria-disabled={disabled ? "true" : undefined}
254
- aria-pressed={on ? "true" : "false"}
255
- id={id}
256
- onClick={composeHandlers(rest.onClick, () => {
257
- if (!disabled) {
258
- group.toggle(value);
259
- }
260
- })}
261
- // The roving tab stop follows real focus rather than leading it, so a
262
- // pointer that moves focus and an arrow key that moves focus agree
263
- // without the two having to be kept in step by hand.
264
- onFocus={composeHandlers(rest.onFocus, () => setActiveId(id))}
265
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
266
- if (disabled || (event.key !== " " && event.key !== "Enter")) {
267
- return;
268
- }
269
- // Stops `Space` scrolling the page, and stops the browser's own click
270
- // arriving afterwards and pressing this back to where it started.
271
- event.preventDefault();
253
+ const itemProps = withProps(passed, {
254
+ "aria-disabled": disabled ? "true" : undefined,
255
+ "aria-pressed": on ? "true" : "false",
256
+ children,
257
+ id,
258
+ onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
259
+ if (!disabled) {
272
260
  group.toggle(value);
273
- })}
274
- tabIndex={group.activeId === id || (group.activeId == null && group.firstId === id) ? 0 : -1}
275
- type="button"
276
- >
277
- {children}
278
- </button>
279
- );
261
+ }
262
+ }),
263
+ // The roving tab stop follows real focus rather than leading it, so a
264
+ // pointer that moves focus and an arrow key that moves focus agree
265
+ // without the two having to be kept in step by hand.
266
+ onFocus: composeHandlers(rest.onFocus, (_event: PartEvent) => setActiveId(id)),
267
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
268
+ if (disabled || (event.key !== " " && event.key !== "Enter")) {
269
+ return;
270
+ }
271
+ // Stops `Space` scrolling the page, and stops the browser's own click
272
+ // arriving afterwards and pressing this back to where it started.
273
+ event.preventDefault();
274
+ group.toggle(value);
275
+ }),
276
+ tabIndex: group.activeId === id || (group.activeId == null && group.firstId === id) ? 0 : -1,
277
+ });
278
+
279
+ if (render != null) {
280
+ return render(withProps(itemProps, { role: "button" }));
281
+ }
282
+
283
+ return <button {...itemProps} type="button" />;
280
284
  }