@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/slider.js CHANGED
@@ -76,8 +76,13 @@ import * as React from "@uniflowed/react";
76
76
  import { createContext, useContext, useMemo, useRef } from "@uniflowed/react";
77
77
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
78
78
 
79
- import type { Rest } from "./internal/merge-props.js";
80
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
79
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
80
+ import {
81
+ composeHandlers,
82
+ composeRefs,
83
+ withProps,
84
+ withoutComposed,
85
+ } from "./internal/merge-props.js";
81
86
  import type { Orientation } from "./internal/roving-focus.js";
82
87
  import { clamp, fraction, isReversed, snap } from "./internal/range.js";
83
88
  import { useControlled } from "./internal/controlled-state.js";
@@ -128,6 +133,7 @@ export component SliderRoot(
128
133
  orientation?: Orientation = "horizontal",
129
134
  disabled?: boolean = false,
130
135
  valueText?: (value: number, index: number) => string,
136
+ render?: RenderProp,
131
137
  ...rest: Rest
132
138
  ) {
133
139
  const [values, setValues] = useControlled(value, defaultValue, onValueChange);
@@ -190,7 +196,7 @@ export component SliderRoot(
190
196
 
191
197
  return (
192
198
  <SliderContext.Provider value={state}>
193
- <div {...rest}>{children}</div>
199
+ {render == null ? <div {...rest}>{children}</div> : render(withProps(rest, { children }))}
194
200
  </SliderContext.Provider>
195
201
  );
196
202
  }
@@ -203,7 +209,7 @@ export component SliderRoot(
203
209
  * wanders off the track — which every drag does — keeps arriving here instead
204
210
  * of being lost to whatever it wandered over.
205
211
  */
206
- export component SliderTrack(children: React.Node, ...rest: Rest) {
212
+ export component SliderTrack(children: React.Node, render?: RenderProp, ...rest: Rest) {
207
213
  const slider = useSlider("Slider.Track");
208
214
  const passed = withoutComposed(rest, ["onPointerDown", "onPointerMove", "onPointerUp", "ref"]);
209
215
  const dragging = useRef<number | null>(null);
@@ -239,35 +245,39 @@ export component SliderTrack(children: React.Node, ...rest: Rest) {
239
245
  slider.setAt(at, target);
240
246
  };
241
247
 
242
- return (
243
- <div
244
- {...passed}
245
- onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
246
- if (slider.disabled) {
247
- return;
248
- }
249
- // Otherwise the press selects the page's text on the way past, which
250
- // makes a drag paint everything blue.
251
- event.preventDefault();
252
- event.currentTarget?.setPointerCapture?.(event.pointerId);
253
- moveTo(event, null);
254
- })}
255
- onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
256
- if (dragging.current != null) {
257
- moveTo(event, dragging.current);
258
- }
259
- })}
260
- onPointerUp={composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
261
- dragging.current = null;
262
- event.currentTarget?.releasePointerCapture?.(event.pointerId);
263
- })}
264
- ref={composeRefs(rest.ref, (element) => {
265
- slider.trackRef.current = element;
266
- })}
267
- >
268
- {children}
269
- </div>
270
- );
248
+ const props = withProps(passed, {
249
+ children,
250
+ // Pointer handlers keep the active thumb in a ref between events.
251
+ // uf-lint-disable-next-line react-compiler/refs
252
+ onPointerDown: composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
253
+ if (slider.disabled) {
254
+ return;
255
+ }
256
+ // Otherwise the press selects the page's text on the way past, which
257
+ // makes a drag paint everything blue.
258
+ event.preventDefault();
259
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
260
+ moveTo(event, null);
261
+ }),
262
+ // uf-lint-disable-next-line react-compiler/refs
263
+ onPointerMove: composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
264
+ if (dragging.current != null) {
265
+ moveTo(event, dragging.current);
266
+ }
267
+ }),
268
+ // uf-lint-disable-next-line react-compiler/refs
269
+ onPointerUp: composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
270
+ dragging.current = null;
271
+ event.currentTarget?.releasePointerCapture?.(event.pointerId);
272
+ }),
273
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
274
+ // React calls callback refs during commit; pointer handlers read the track later.
275
+ // uf-lint-disable-next-line react-compiler/immutability
276
+ slider.trackRef.current = element;
277
+ }),
278
+ });
279
+
280
+ return render == null ? <div {...props} /> : render(props);
271
281
  }
272
282
 
273
283
  /**
@@ -281,24 +291,22 @@ export component SliderTrack(children: React.Node, ...rest: Rest) {
281
291
  * is told the value by the thumb and telling them again here would be telling
282
292
  * them twice.
283
293
  */
284
- export component SliderRange(...rest: Rest) {
294
+ export component SliderRange(render?: RenderProp, ...rest: Rest) {
285
295
  const slider = useSlider("Slider.Range");
286
296
  const passed = withoutComposed(rest, ["style"]);
287
297
  const ends = [...slider.values].sort((first, second) => first - second);
288
298
  const start = slider.values.length > 1 ? (ends[0] ?? slider.min) : slider.min;
289
299
  const end = ends[ends.length - 1] ?? slider.min;
300
+ const props = withProps(passed, {
301
+ "aria-hidden": "true",
302
+ style: {
303
+ ...(rest.style as $FlowFixMe),
304
+ "--uf-slider-start": fraction(start, slider.min, slider.max),
305
+ "--uf-slider-end": fraction(end, slider.min, slider.max),
306
+ },
307
+ });
290
308
 
291
- return (
292
- <div
293
- {...passed}
294
- aria-hidden="true"
295
- style={{
296
- ...(rest.style as $FlowFixMe),
297
- "--uf-slider-start": fraction(start, slider.min, slider.max),
298
- "--uf-slider-end": fraction(end, slider.min, slider.max),
299
- }}
300
- />
301
- );
309
+ return render == null ? <div {...props} /> : render(props);
302
310
  }
303
311
 
304
312
  /**
@@ -315,55 +323,53 @@ export component SliderRange(...rest: Rest) {
315
323
  * "Maximum" told apart is the whole difference between a control a reader can
316
324
  * operate and two identical "slider"s.
317
325
  */
318
- export component SliderThumb(index?: number = 0, ...rest: Rest) {
326
+ export component SliderThumb(index?: number = 0, render?: RenderProp, ...rest: Rest) {
319
327
  const slider = useSlider("Slider.Thumb");
320
328
  const passed = withoutComposed(rest, ["onKeyDown", "style"]);
321
329
  const value = slider.values[index] ?? slider.min;
322
330
  const [lower, upper] = boundsOf(slider.values, index, slider.min, slider.max);
331
+ const props = withProps(passed, {
332
+ "aria-disabled": slider.disabled ? "true" : undefined,
333
+ "aria-orientation": slider.orientation,
334
+ // The neighbour's value, not the slider's end. A reader told they may
335
+ // set this thumb to 90 while the control refuses at 60 has been told
336
+ // something the control disagrees with.
337
+ "aria-valuemax": upper,
338
+ "aria-valuemin": lower,
339
+ "aria-valuenow": value,
340
+ "aria-valuetext": slider.valueText?.(value, index),
341
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
342
+ if (slider.disabled) {
343
+ return;
344
+ }
345
+ const reversed = isReversed(event.currentTarget, slider.orientation);
346
+ const move = stepFor(event.key, slider.step, slider.largeStep, reversed);
347
+ if (move != null) {
348
+ // Before moving: the arrow keys scroll the page, and a slider that
349
+ // moves the page under the reader as it moves the value is a control
350
+ // they cannot watch.
351
+ event.preventDefault();
352
+ slider.setAt(index, value + move);
353
+ return;
354
+ }
355
+ if (event.key === "Home" || event.key === "End") {
356
+ event.preventDefault();
357
+ // This thumb's own ends, which for the lower thumb of a range is its
358
+ // neighbour rather than the slider's maximum.
359
+ slider.setAt(index, event.key === "Home" ? lower : upper);
360
+ }
361
+ }),
362
+ role: "slider",
363
+ style: {
364
+ ...(rest.style as $FlowFixMe),
365
+ "--uf-slider-fraction": fraction(value, slider.min, slider.max),
366
+ },
367
+ // In the tab sequence, and out of it while disabled — the browser does
368
+ // this for a real control and there is no real control here to do it.
369
+ tabIndex: slider.disabled ? -1 : 0,
370
+ });
323
371
 
324
- return (
325
- <span
326
- {...passed}
327
- aria-disabled={slider.disabled ? "true" : undefined}
328
- aria-orientation={slider.orientation}
329
- // The neighbour's value, not the slider's end. A reader told they may
330
- // set this thumb to 90 while the control refuses at 60 has been told
331
- // something the control disagrees with.
332
- aria-valuemax={upper}
333
- aria-valuemin={lower}
334
- aria-valuenow={value}
335
- aria-valuetext={slider.valueText?.(value, index)}
336
- onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
337
- if (slider.disabled) {
338
- return;
339
- }
340
- const reversed = isReversed(event.currentTarget, slider.orientation);
341
- const move = stepFor(event.key, slider.step, slider.largeStep, reversed);
342
- if (move != null) {
343
- // Before moving: the arrow keys scroll the page, and a slider that
344
- // moves the page under the reader as it moves the value is a control
345
- // they cannot watch.
346
- event.preventDefault();
347
- slider.setAt(index, value + move);
348
- return;
349
- }
350
- if (event.key === "Home" || event.key === "End") {
351
- event.preventDefault();
352
- // This thumb's own ends, which for the lower thumb of a range is its
353
- // neighbour rather than the slider's maximum.
354
- slider.setAt(index, event.key === "Home" ? lower : upper);
355
- }
356
- })}
357
- role="slider"
358
- style={{
359
- ...(rest.style as $FlowFixMe),
360
- "--uf-slider-fraction": fraction(value, slider.min, slider.max),
361
- }}
362
- // In the tab sequence, and out of it while disabled — the browser does
363
- // this for a real control and there is no real control here to do it.
364
- tabIndex={slider.disabled ? -1 : 0}
365
- />
366
- );
372
+ return render == null ? <span {...props} /> : render(props);
367
373
  }
368
374
 
369
375
  /**
package/switch.js CHANGED
@@ -19,55 +19,63 @@
19
19
  //
20
20
  // The keyboard follows from the same distinction. `Space` toggles both. `Enter`
21
21
  // toggles a *switch*, because a switch is an operation and pressing Enter on
22
- // something that operates is what a reader expects — while `checkbox.js`
23
- // deliberately leaves `Enter` alone so that a checkbox inside a form still
24
- // submits it. That is the whole reason these are not one file with a flag.
22
+ // something that operates is what a reader expects — while `checkbox.js` turns
23
+ // `Enter` into the submission of the form the checkbox is in, which is what a
24
+ // native `<input type="checkbox">` does with the key and what a reader
25
+ // answering a question on their way to a submit button is asking for. That is
26
+ // the whole reason these are not one file with a flag.
25
27
 
26
28
  "use client";
27
29
 
28
30
  import * as React from "@uniflowed/react";
29
31
 
30
- import type { Rest } from "./internal/merge-props.js";
31
- import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
32
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
33
+ import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
32
34
  import { useControlled } from "./internal/controlled-state.js";
33
35
 
34
- /** A two-state switch: on or off. */
36
+ /**
37
+ * A two-state switch: on or off.
38
+ *
39
+ * `render` is the escape hatch, and on a switch it is what a caller with their
40
+ * own `<Pressable>` or a `<div role="switch">` in a design system reaches for.
41
+ * `type="button"` is the one thing it does not hand over, because that is true
42
+ * of the element rather than of the switch.
43
+ */
35
44
  export component Switch(
36
45
  checked?: boolean,
37
46
  defaultChecked?: boolean = false,
38
47
  onCheckedChange?: (checked: boolean) => void,
39
48
  disabled?: boolean = false,
40
49
  children?: React.Node,
50
+ render?: RenderProp,
41
51
  ...rest: Rest
42
52
  ) {
43
53
  const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
44
- const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
45
-
46
- return (
47
- <button
48
- {...passed}
49
- aria-checked={on ? "true" : "false"}
50
- disabled={disabled}
51
- onClick={composeHandlers(rest.onClick, () => {
52
- if (!disabled) {
53
- setOn(!on);
54
- }
55
- })}
56
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
57
- if (disabled || (event.key !== " " && event.key !== "Enter")) {
58
- return;
59
- }
60
- // Preventing the default is not decoration. It stops `Space` scrolling
61
- // the page — which is what makes a hand-written toggle feel broken even
62
- // when it works — and it stops the browser's own click from arriving
63
- // after this handler and toggling the switch a second time.
64
- event.preventDefault();
54
+ const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown"]), {
55
+ "aria-checked": on ? "true" : "false",
56
+ children,
57
+ disabled,
58
+ onClick: composeHandlers(rest.onClick, () => {
59
+ if (!disabled) {
65
60
  setOn(!on);
66
- })}
67
- role="switch"
68
- type="button"
69
- >
70
- {children}
71
- </button>
72
- );
61
+ }
62
+ }),
63
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
64
+ if (disabled || (event.key !== " " && event.key !== "Enter")) {
65
+ return;
66
+ }
67
+ // Preventing the default is not decoration. It stops `Space` scrolling
68
+ // the page — which is what makes a hand-written toggle feel broken even
69
+ // when it works — and it stops the browser's own click from arriving
70
+ // after this handler and toggling the switch a second time.
71
+ event.preventDefault();
72
+ setOn(!on);
73
+ }),
74
+ role: "switch",
75
+ });
76
+
77
+ if (render != null) {
78
+ return render(props);
79
+ }
80
+ return <button {...props} type="button" />;
73
81
  }
package/table.js CHANGED
@@ -57,9 +57,8 @@
57
57
  // It is rendered by the root rather than offered as a part a caller places,
58
58
  // because a sort that announces nothing is the failure this component exists
59
59
  // to prevent and a part is a thing somebody forgets. The cost is one extra
60
- // element in the caller's layout, and a sentence that is visible until they
61
- // style it — which is not a bad thing to see, and a table that shows its sort
62
- // status in words is a table more people can use.
60
+ // element after the table, hidden from layout and visible to the screen readers
61
+ // watching it.
63
62
 
64
63
  "use client";
65
64
 
@@ -68,9 +67,15 @@ import { createContext, useContext, useEffect, useId, useMemo, useState } from "
68
67
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
69
68
 
70
69
  import { Checkbox } from "./checkbox.js";
71
- import type { Rest } from "./internal/merge-props.js";
72
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
70
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
71
+ import {
72
+ composeHandlers,
73
+ composeRefs,
74
+ withProps,
75
+ withoutComposed,
76
+ } from "./internal/merge-props.js";
73
77
  import { useControlled } from "./internal/controlled-state.js";
78
+ import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
74
79
 
75
80
  /** Which column a table is sorted by, and which way. */
76
81
  export type Sort = {|
@@ -130,6 +135,7 @@ export component TableRoot(
130
135
  rowCount?: number | null = null,
131
136
  rowOffset?: number = 0,
132
137
  announceSort?: (column: string, direction: "ascending" | "descending") => string,
138
+ render?: RenderProp,
133
139
  ...rest: Rest
134
140
  ) {
135
141
  const base = useId();
@@ -184,30 +190,28 @@ export component TableRoot(
184
190
  labels[current.column] ?? current.column,
185
191
  current.direction,
186
192
  );
193
+ const props = withProps(rest, {
194
+ "aria-labelledby": captioned ? `${base}-caption` : undefined,
195
+ "aria-rowcount": rowCount == null ? undefined : rowCount + headerRows,
196
+ children,
197
+ });
187
198
 
188
199
  return (
189
200
  <TableContext.Provider value={state}>
190
- <table
191
- {...rest}
192
- // The caption is a `<table>`'s accessible name in HTML-AAM already;
193
- // naming it again here is what turns "the host will probably do this"
194
- // into something this component promises, and it costs nothing because
195
- // both point at the same words. Only while a caption is rendered, for
196
- // the reason every part of this package repeats.
197
- aria-labelledby={captioned ? `${base}-caption` : undefined}
198
- // Every row in the table, which is the data plus the header — the
199
- // arithmetic a caller should not have to remember.
200
- aria-rowcount={rowCount == null ? undefined : rowCount + headerRows}
201
- >
202
- {children}
203
- </table>
201
+ {render == null ? <table {...props} /> : render(withProps(props, { role: "table" }))}
204
202
  {/*
205
203
  Beside the table rather than inside it, because a `<table>` may only
206
204
  contain a caption, column groups and row groups — and mounted from the
207
205
  first render, holding nothing, because a live region that appears with
208
206
  its text is a live region that says nothing.
209
207
  */}
210
- <div aria-atomic="true" aria-live="polite" data-uf-table-status="" role="status">
208
+ <div
209
+ aria-atomic="true"
210
+ aria-live="polite"
211
+ data-uf-table-status=""
212
+ role="status"
213
+ style={visuallyHiddenStyle}
214
+ >
211
215
  {message}
212
216
  </div>
213
217
  </TableContext.Provider>
@@ -221,7 +225,7 @@ export component TableRoot(
221
225
  * what a screen reader reads when a reader lands on it. A heading above the
222
226
  * table looks the same and is not the table's name.
223
227
  */
224
- export component TableCaption(children: React.Node, ...rest: Rest) {
228
+ export component TableCaption(children: React.Node, render?: RenderProp, ...rest: Rest) {
225
229
  const table = useTable("Table.Caption");
226
230
  const register = table.registerCaption;
227
231
 
@@ -230,11 +234,11 @@ export component TableCaption(children: React.Node, ...rest: Rest) {
230
234
  return () => register(false);
231
235
  }, [register]);
232
236
 
233
- return (
234
- <caption {...rest} id={table.captionId}>
235
- {children}
236
- </caption>
237
- );
237
+ const props = withProps(rest, { children, id: table.captionId });
238
+ if (render != null) {
239
+ return render(withProps(props, { role: "caption" }));
240
+ }
241
+ return <caption {...props} />;
238
242
  }
239
243
 
240
244
  /**
@@ -243,7 +247,7 @@ export component TableCaption(children: React.Node, ...rest: Rest) {
243
247
  * It tells the root that it exists, because `aria-rowcount` and every row's
244
248
  * `aria-rowindex` count header rows and a table without one counts differently.
245
249
  */
246
- export component TableHeader(children: React.Node, ...rest: Rest) {
250
+ export component TableHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
247
251
  const table = useTable("Table.Header");
248
252
  const register = table.registerHeader;
249
253
 
@@ -252,18 +256,21 @@ export component TableHeader(children: React.Node, ...rest: Rest) {
252
256
  return () => register(false);
253
257
  }, [register]);
254
258
 
259
+ const props = withProps(rest, { children });
260
+
255
261
  return (
256
262
  <HeaderContext.Provider value={true}>
257
- <thead {...rest}>{children}</thead>
263
+ {render == null ? <thead {...props} /> : render(withProps(props, { role: "rowgroup" }))}
258
264
  </HeaderContext.Provider>
259
265
  );
260
266
  }
261
267
 
262
268
  /** The data rows. */
263
- export component TableBody(children: React.Node, ...rest: Rest) {
269
+ export component TableBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
270
+ const props = withProps(rest, { children });
264
271
  return (
265
272
  <HeaderContext.Provider value={false}>
266
- <tbody {...rest}>{children}</tbody>
273
+ {render == null ? <tbody {...props} /> : render(withProps(props, { role: "rowgroup" }))}
267
274
  </HeaderContext.Provider>
268
275
  );
269
276
  }
@@ -284,7 +291,12 @@ export component TableBody(children: React.Node, ...rest: Rest) {
284
291
  * adding them anyway is a second source of truth that can disagree with the
285
292
  * document.
286
293
  */
287
- export component TableRow(children: React.Node, index?: number | null = null, ...rest: Rest) {
294
+ export component TableRow(
295
+ children: React.Node,
296
+ index?: number | null = null,
297
+ render?: RenderProp,
298
+ ...rest: Rest
299
+ ) {
288
300
  const table = useTable("Table.Row");
289
301
  const header = useContext(HeaderContext);
290
302
  const counted = table.rowCount != null;
@@ -300,11 +312,11 @@ export component TableRow(children: React.Node, index?: number | null = null, ..
300
312
  rowIndex = table.headerRows + table.rowOffset + (index ?? 0) + 1;
301
313
  }
302
314
 
303
- return (
304
- <tr {...rest} aria-rowindex={rowIndex}>
305
- {children}
306
- </tr>
307
- );
315
+ const props = withProps(rest, { "aria-rowindex": rowIndex, children });
316
+ if (render != null) {
317
+ return render(withProps(props, { role: "row" }));
318
+ }
319
+ return <tr {...props} />;
308
320
  }
309
321
 
310
322
  /**
@@ -320,7 +332,12 @@ export component TableRow(children: React.Node, index?: number | null = null, ..
320
332
  * Not `"none"` on the others: eleven headers each announcing "not sorted" is
321
333
  * eleven announcements of nothing, on every pass through the table.
322
334
  */
323
- export component TableHead(children: React.Node, column?: string | null = null, ...rest: Rest) {
335
+ export component TableHead(
336
+ children: React.Node,
337
+ column?: string | null = null,
338
+ render?: RenderProp,
339
+ ...rest: Rest
340
+ ) {
324
341
  const table = useTable("Table.Head");
325
342
  const passed = withoutComposed(rest, column == null ? [] : ["onClick", "ref"]);
326
343
  const sorted = column != null && table.sort?.column === column;
@@ -341,41 +358,49 @@ export component TableHead(children: React.Node, column?: string | null = null,
341
358
  });
342
359
 
343
360
  if (column == null) {
344
- return (
345
- <th {...rest} scope="col">
346
- {children}
347
- </th>
348
- );
361
+ const props = withProps(rest, { children, scope: "col" });
362
+ if (render != null) {
363
+ return render(withProps(props, { role: "columnheader" }));
364
+ }
365
+ return <th {...props} />;
349
366
  }
350
367
 
351
- return (
352
- <th
353
- {...passed}
354
- aria-sort={sorted ? table.sort?.direction : undefined}
355
- ref={composeRefs(rest.ref, setElement)}
356
- scope="col"
368
+ const button = (
369
+ <button
370
+ onClick={composeHandlers(rest.onClick, () => {
371
+ // Two states, not three. A sort that cycles back to "unsorted" gives
372
+ // a reader a third press whose result is a table in an order nobody
373
+ // asked for.
374
+ table.setSort({
375
+ column,
376
+ direction: sorted && table.sort?.direction === "ascending" ? "descending" : "ascending",
377
+ });
378
+ })}
379
+ type="button"
357
380
  >
358
- <button
359
- onClick={composeHandlers(rest.onClick, () => {
360
- // Two states, not three. A sort that cycles back to "unsorted" gives
361
- // a reader a third press whose result is a table in an order nobody
362
- // asked for.
363
- table.setSort({
364
- column,
365
- direction: sorted && table.sort?.direction === "ascending" ? "descending" : "ascending",
366
- });
367
- })}
368
- type="button"
369
- >
370
- {children}
371
- </button>
372
- </th>
381
+ {children}
382
+ </button>
373
383
  );
384
+ const props = withProps(passed, {
385
+ "aria-sort": sorted ? table.sort?.direction : undefined,
386
+ children: button,
387
+ ref: composeRefs(rest.ref, setElement),
388
+ scope: "col",
389
+ });
390
+
391
+ if (render != null) {
392
+ return render(withProps(props, { role: "columnheader" }));
393
+ }
394
+ return <th {...props} />;
374
395
  }
375
396
 
376
397
  /** One cell. */
377
- export component TableCell(children: React.Node, ...rest: Rest) {
378
- return <td {...rest}>{children}</td>;
398
+ export component TableCell(children: React.Node, render?: RenderProp, ...rest: Rest) {
399
+ const props = withProps(rest, { children });
400
+ if (render != null) {
401
+ return render(withProps(props, { role: "cell" }));
402
+ }
403
+ return <td {...props} />;
379
404
  }
380
405
 
381
406
  /**
@@ -386,12 +411,12 @@ export component TableCell(children: React.Node, ...rest: Rest) {
386
411
  * moves down the year column. A table of records usually has one and almost
387
412
  * never marks it.
388
413
  */
389
- export component TableRowHeader(children: React.Node, ...rest: Rest) {
390
- return (
391
- <th {...rest} scope="row">
392
- {children}
393
- </th>
394
- );
414
+ export component TableRowHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
415
+ const props = withProps(rest, { children, scope: "row" });
416
+ if (render != null) {
417
+ return render(withProps(props, { role: "rowheader" }));
418
+ }
419
+ return <th {...props} />;
395
420
  }
396
421
 
397
422
  /**
@@ -430,6 +455,7 @@ export component TableSelectAll(
430
455
  label?: string = "Select all rows",
431
456
  className?: string,
432
457
  disabled?: boolean = false,
458
+ render?: RenderProp,
433
459
  ) {
434
460
  return (
435
461
  <Checkbox
@@ -439,6 +465,7 @@ export component TableSelectAll(
439
465
  disabled={disabled}
440
466
  indeterminate={checked === "mixed"}
441
467
  onCheckedChange={onCheckedChange}
468
+ render={render}
442
469
  />
443
470
  );
444
471
  }
@@ -461,6 +488,7 @@ export component TableRowSelect(
461
488
  onCheckedChange: (checked: boolean) => void,
462
489
  className?: string,
463
490
  disabled?: boolean = false,
491
+ render?: RenderProp,
464
492
  ) {
465
493
  return (
466
494
  <Checkbox
@@ -469,6 +497,7 @@ export component TableRowSelect(
469
497
  className={className}
470
498
  disabled={disabled}
471
499
  onCheckedChange={onCheckedChange}
500
+ render={render}
472
501
  />
473
502
  );
474
503
  }