@uniflowed/ui 0.0.0-alpha.9 → 0.1.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 (62) 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 +560 -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 +235 -177
  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 +1159 -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 +395 -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 +316 -0
  35. package/list-box.js +13 -0
  36. package/menu.js +553 -361
  37. package/menubar.js +295 -0
  38. package/number-field.js +263 -0
  39. package/package.json +8 -28
  40. package/pagination.js +34 -22
  41. package/popover.js +116 -75
  42. package/progress.js +21 -16
  43. package/radio-group.js +81 -75
  44. package/range-calendar.js +78 -0
  45. package/resizable.js +155 -9
  46. package/scroll-area.js +283 -0
  47. package/select.js +83 -37
  48. package/separator.js +97 -0
  49. package/sheet.js +189 -0
  50. package/sidebar.js +320 -0
  51. package/skeleton.js +163 -0
  52. package/slider.js +95 -89
  53. package/switch.js +42 -34
  54. package/table.js +112 -71
  55. package/tabs.js +100 -91
  56. package/tag-group.js +8 -0
  57. package/time-field.js +8 -0
  58. package/toast.js +36 -66
  59. package/toggle-group.js +53 -49
  60. package/toggle.js +41 -27
  61. package/tooltip.js +48 -55
  62. package/tree.js +8 -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,8 +67,13 @@ 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";
74
78
 
75
79
  /** Which column a table is sorted by, and which way. */
@@ -100,6 +104,19 @@ const TableContext: React.Context<TableState | null> = createContext(null);
100
104
  /** Whether the rows below are header rows, which decides `th` versus `td`. */
101
105
  const HeaderContext: React.Context<boolean> = createContext(false);
102
106
 
107
+ const VISUALLY_HIDDEN_STYLE = Object.freeze({
108
+ border: 0,
109
+ clip: "rect(0, 0, 0, 0)",
110
+ clipPath: "inset(50%)",
111
+ height: 1,
112
+ margin: -1,
113
+ overflow: "hidden",
114
+ padding: 0,
115
+ position: "absolute",
116
+ whiteSpace: "nowrap",
117
+ width: 1,
118
+ });
119
+
103
120
  hook useTable(part: string): TableState {
104
121
  const state = useContext(TableContext);
105
122
  if (state == null) {
@@ -130,6 +147,7 @@ export component TableRoot(
130
147
  rowCount?: number | null = null,
131
148
  rowOffset?: number = 0,
132
149
  announceSort?: (column: string, direction: "ascending" | "descending") => string,
150
+ render?: RenderProp,
133
151
  ...rest: Rest
134
152
  ) {
135
153
  const base = useId();
@@ -184,30 +202,28 @@ export component TableRoot(
184
202
  labels[current.column] ?? current.column,
185
203
  current.direction,
186
204
  );
205
+ const props = withProps(rest, {
206
+ "aria-labelledby": captioned ? `${base}-caption` : undefined,
207
+ "aria-rowcount": rowCount == null ? undefined : rowCount + headerRows,
208
+ children,
209
+ });
187
210
 
188
211
  return (
189
212
  <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>
213
+ {render == null ? <table {...props} /> : render(withProps(props, { role: "table" }))}
204
214
  {/*
205
215
  Beside the table rather than inside it, because a `<table>` may only
206
216
  contain a caption, column groups and row groups — and mounted from the
207
217
  first render, holding nothing, because a live region that appears with
208
218
  its text is a live region that says nothing.
209
219
  */}
210
- <div aria-atomic="true" aria-live="polite" data-uf-table-status="" role="status">
220
+ <div
221
+ aria-atomic="true"
222
+ aria-live="polite"
223
+ data-uf-table-status=""
224
+ role="status"
225
+ style={VISUALLY_HIDDEN_STYLE}
226
+ >
211
227
  {message}
212
228
  </div>
213
229
  </TableContext.Provider>
@@ -221,7 +237,7 @@ export component TableRoot(
221
237
  * what a screen reader reads when a reader lands on it. A heading above the
222
238
  * table looks the same and is not the table's name.
223
239
  */
224
- export component TableCaption(children: React.Node, ...rest: Rest) {
240
+ export component TableCaption(children: React.Node, render?: RenderProp, ...rest: Rest) {
225
241
  const table = useTable("Table.Caption");
226
242
  const register = table.registerCaption;
227
243
 
@@ -230,11 +246,11 @@ export component TableCaption(children: React.Node, ...rest: Rest) {
230
246
  return () => register(false);
231
247
  }, [register]);
232
248
 
233
- return (
234
- <caption {...rest} id={table.captionId}>
235
- {children}
236
- </caption>
237
- );
249
+ const props = withProps(rest, { children, id: table.captionId });
250
+ if (render != null) {
251
+ return render(withProps(props, { role: "caption" }));
252
+ }
253
+ return <caption {...props} />;
238
254
  }
239
255
 
240
256
  /**
@@ -243,7 +259,7 @@ export component TableCaption(children: React.Node, ...rest: Rest) {
243
259
  * It tells the root that it exists, because `aria-rowcount` and every row's
244
260
  * `aria-rowindex` count header rows and a table without one counts differently.
245
261
  */
246
- export component TableHeader(children: React.Node, ...rest: Rest) {
262
+ export component TableHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
247
263
  const table = useTable("Table.Header");
248
264
  const register = table.registerHeader;
249
265
 
@@ -252,18 +268,21 @@ export component TableHeader(children: React.Node, ...rest: Rest) {
252
268
  return () => register(false);
253
269
  }, [register]);
254
270
 
271
+ const props = withProps(rest, { children });
272
+
255
273
  return (
256
274
  <HeaderContext.Provider value={true}>
257
- <thead {...rest}>{children}</thead>
275
+ {render == null ? <thead {...props} /> : render(withProps(props, { role: "rowgroup" }))}
258
276
  </HeaderContext.Provider>
259
277
  );
260
278
  }
261
279
 
262
280
  /** The data rows. */
263
- export component TableBody(children: React.Node, ...rest: Rest) {
281
+ export component TableBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
282
+ const props = withProps(rest, { children });
264
283
  return (
265
284
  <HeaderContext.Provider value={false}>
266
- <tbody {...rest}>{children}</tbody>
285
+ {render == null ? <tbody {...props} /> : render(withProps(props, { role: "rowgroup" }))}
267
286
  </HeaderContext.Provider>
268
287
  );
269
288
  }
@@ -284,7 +303,12 @@ export component TableBody(children: React.Node, ...rest: Rest) {
284
303
  * adding them anyway is a second source of truth that can disagree with the
285
304
  * document.
286
305
  */
287
- export component TableRow(children: React.Node, index?: number | null = null, ...rest: Rest) {
306
+ export component TableRow(
307
+ children: React.Node,
308
+ index?: number | null = null,
309
+ render?: RenderProp,
310
+ ...rest: Rest
311
+ ) {
288
312
  const table = useTable("Table.Row");
289
313
  const header = useContext(HeaderContext);
290
314
  const counted = table.rowCount != null;
@@ -300,11 +324,11 @@ export component TableRow(children: React.Node, index?: number | null = null, ..
300
324
  rowIndex = table.headerRows + table.rowOffset + (index ?? 0) + 1;
301
325
  }
302
326
 
303
- return (
304
- <tr {...rest} aria-rowindex={rowIndex}>
305
- {children}
306
- </tr>
307
- );
327
+ const props = withProps(rest, { "aria-rowindex": rowIndex, children });
328
+ if (render != null) {
329
+ return render(withProps(props, { role: "row" }));
330
+ }
331
+ return <tr {...props} />;
308
332
  }
309
333
 
310
334
  /**
@@ -320,7 +344,12 @@ export component TableRow(children: React.Node, index?: number | null = null, ..
320
344
  * Not `"none"` on the others: eleven headers each announcing "not sorted" is
321
345
  * eleven announcements of nothing, on every pass through the table.
322
346
  */
323
- export component TableHead(children: React.Node, column?: string | null = null, ...rest: Rest) {
347
+ export component TableHead(
348
+ children: React.Node,
349
+ column?: string | null = null,
350
+ render?: RenderProp,
351
+ ...rest: Rest
352
+ ) {
324
353
  const table = useTable("Table.Head");
325
354
  const passed = withoutComposed(rest, column == null ? [] : ["onClick", "ref"]);
326
355
  const sorted = column != null && table.sort?.column === column;
@@ -341,41 +370,49 @@ export component TableHead(children: React.Node, column?: string | null = null,
341
370
  });
342
371
 
343
372
  if (column == null) {
344
- return (
345
- <th {...rest} scope="col">
346
- {children}
347
- </th>
348
- );
373
+ const props = withProps(rest, { children, scope: "col" });
374
+ if (render != null) {
375
+ return render(withProps(props, { role: "columnheader" }));
376
+ }
377
+ return <th {...props} />;
349
378
  }
350
379
 
351
- return (
352
- <th
353
- {...passed}
354
- aria-sort={sorted ? table.sort?.direction : undefined}
355
- ref={composeRefs(rest.ref, setElement)}
356
- scope="col"
380
+ const button = (
381
+ <button
382
+ onClick={composeHandlers(rest.onClick, () => {
383
+ // Two states, not three. A sort that cycles back to "unsorted" gives
384
+ // a reader a third press whose result is a table in an order nobody
385
+ // asked for.
386
+ table.setSort({
387
+ column,
388
+ direction: sorted && table.sort?.direction === "ascending" ? "descending" : "ascending",
389
+ });
390
+ })}
391
+ type="button"
357
392
  >
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>
393
+ {children}
394
+ </button>
373
395
  );
396
+ const props = withProps(passed, {
397
+ "aria-sort": sorted ? table.sort?.direction : undefined,
398
+ children: button,
399
+ ref: composeRefs(rest.ref, setElement),
400
+ scope: "col",
401
+ });
402
+
403
+ if (render != null) {
404
+ return render(withProps(props, { role: "columnheader" }));
405
+ }
406
+ return <th {...props} />;
374
407
  }
375
408
 
376
409
  /** One cell. */
377
- export component TableCell(children: React.Node, ...rest: Rest) {
378
- return <td {...rest}>{children}</td>;
410
+ export component TableCell(children: React.Node, render?: RenderProp, ...rest: Rest) {
411
+ const props = withProps(rest, { children });
412
+ if (render != null) {
413
+ return render(withProps(props, { role: "cell" }));
414
+ }
415
+ return <td {...props} />;
379
416
  }
380
417
 
381
418
  /**
@@ -386,12 +423,12 @@ export component TableCell(children: React.Node, ...rest: Rest) {
386
423
  * moves down the year column. A table of records usually has one and almost
387
424
  * never marks it.
388
425
  */
389
- export component TableRowHeader(children: React.Node, ...rest: Rest) {
390
- return (
391
- <th {...rest} scope="row">
392
- {children}
393
- </th>
394
- );
426
+ export component TableRowHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
427
+ const props = withProps(rest, { children, scope: "row" });
428
+ if (render != null) {
429
+ return render(withProps(props, { role: "rowheader" }));
430
+ }
431
+ return <th {...props} />;
395
432
  }
396
433
 
397
434
  /**
@@ -430,6 +467,7 @@ export component TableSelectAll(
430
467
  label?: string = "Select all rows",
431
468
  className?: string,
432
469
  disabled?: boolean = false,
470
+ render?: RenderProp,
433
471
  ) {
434
472
  return (
435
473
  <Checkbox
@@ -439,6 +477,7 @@ export component TableSelectAll(
439
477
  disabled={disabled}
440
478
  indeterminate={checked === "mixed"}
441
479
  onCheckedChange={onCheckedChange}
480
+ render={render}
442
481
  />
443
482
  );
444
483
  }
@@ -461,6 +500,7 @@ export component TableRowSelect(
461
500
  onCheckedChange: (checked: boolean) => void,
462
501
  className?: string,
463
502
  disabled?: boolean = false,
503
+ render?: RenderProp,
464
504
  ) {
465
505
  return (
466
506
  <Checkbox
@@ -469,6 +509,7 @@ export component TableRowSelect(
469
509
  className={className}
470
510
  disabled={disabled}
471
511
  onCheckedChange={onCheckedChange}
512
+ render={render}
472
513
  />
473
514
  );
474
515
  }