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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/avatar.js ADDED
@@ -0,0 +1,276 @@
1
+ // @flow
2
+ //
3
+ // An avatar: three states, and an `alt` that is empty on purpose.
4
+ //
5
+ // This one looks like a rounded `<img>` with a `<span>` behind it, and it is a
6
+ // component because of two things that are not visible in a screenshot: what
7
+ // happens between the states, and what a screen reader says.
8
+ //
9
+ // # The three states, and the flash between two of them
10
+ //
11
+ // An image is *loading*, *loaded* or *failed*, and the naive version has two —
12
+ // there is an image or there is not — so it renders the fallback whenever the
13
+ // image has not painted yet. On a cached image that is a flash of somebody's
14
+ // initials for one frame, on every navigation, for ever. The fix is to hold the
15
+ // fallback back for a moment: an image that is going to appear immediately does
16
+ // so before the delay is up, and only an image that is genuinely slow or
17
+ // genuinely broken ever shows initials.
18
+ //
19
+ // # Asking the DOM, and the one direction it may be asked in
20
+ //
21
+ // The event is not enough on its own. A cached image can finish loading before
22
+ // React has attached `onLoad` — most obviously when the markup came from a
23
+ // server and the browser started the request while the JavaScript was still
24
+ // downloading — and a component that waits for an event that already happened
25
+ // waits for ever, which is the same flash held permanently.
26
+ //
27
+ // So the element is asked directly, in an effect, which is where this package
28
+ // is allowed to read the document (see `index.js`). And it is asked in *one*
29
+ // direction:
30
+ //
31
+ // * `complete` **and** `naturalWidth > 0` means it loaded. Pixels exist;
32
+ // nothing else produces them.
33
+ // * anything else means nothing. In particular `complete` with no pixels is
34
+ // *not* read as a failure, because that is also what a DOM that does not
35
+ // fetch images says about a perfectly good `src` — a test environment, a
36
+ // server-side render, a browser with images turned off — and concluding
37
+ // "failed" from it would put a fallback over an image that was never asked
38
+ // for. The `error` event is what says a load failed, and an image that
39
+ // failed before anything was listening falls through to the delay and shows
40
+ // the fallback a moment later, which is the same answer arriving late
41
+ // rather than the wrong answer arriving early.
42
+ //
43
+ // # `alt=""` is the default, and it is the accessible answer
44
+ //
45
+ // An avatar almost always sits beside the name of the person it is a picture
46
+ // of. Putting that name in `alt` makes every screen reader say it twice —
47
+ // "Ada Lovelace, image, Ada Lovelace" — which is the most common avatar bug
48
+ // there is, and it is caused by a component being helpful. An empty `alt` takes
49
+ // the image out of the accessibility tree, which is what "decorative" means and
50
+ // what this one is.
51
+ //
52
+ // A caller whose avatar is the *only* thing identifying the person — a bare
53
+ // grid of faces, a comment with no byline — passes `alt` and gets it. The
54
+ // default is the common case; the prop is the honest one.
55
+ //
56
+ // The fallback's content is the caller's and is announced, because this
57
+ // component cannot know whether "AL" is a decoration beside a name or the only
58
+ // thing on the row. A decorative avatar wants `aria-hidden` on its fallback for
59
+ // the same reason its image wants `alt=""`, and that is one prop the caller
60
+ // spreads.
61
+
62
+ "use client";
63
+
64
+ import * as React from "@uniflowed/react";
65
+ import {
66
+ createContext,
67
+ useCallback,
68
+ useContext,
69
+ useEffect,
70
+ useMemo,
71
+ useRef,
72
+ useState,
73
+ } from "@uniflowed/react";
74
+ import { useTimeout } from "@uniflowed/hooks/timing";
75
+
76
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
77
+ import {
78
+ composeHandlers,
79
+ composeRefs,
80
+ withProps,
81
+ withoutComposed,
82
+ } from "./internal/merge-props.js";
83
+
84
+ /**
85
+ * Where an avatar's image is between having been asked for and being there.
86
+ *
87
+ * Three members rather than a `loaded` boolean, because the fallback's whole
88
+ * job is to tell the middle one from the last one: an image that has not
89
+ * arrived *yet* must not be replaced, and one that is never arriving must.
90
+ */
91
+ export type AvatarStatus = "loading" | "loaded" | "error";
92
+
93
+ /** What has been decided, and about which source. */
94
+ type Seen = {| readonly source: string | null, readonly status: AvatarStatus |};
95
+
96
+ /** Nothing asked yet. A source of `null` with no verdict cannot collide. */
97
+ const START: Seen = Object.freeze({ source: null, status: "loading" });
98
+
99
+ type AvatarState = {|
100
+ readonly status: AvatarStatus,
101
+ readonly hasImage: boolean,
102
+ readonly report: (source: string | null, status: AvatarStatus) => void,
103
+ readonly registerImage: (present: boolean) => void,
104
+ |};
105
+
106
+ const AvatarContext: React.Context<AvatarState | null> = createContext(null);
107
+
108
+ /**
109
+ * The avatar a part belongs to.
110
+ *
111
+ * Raising rather than returning null, for `field.js`'s reason: an
112
+ * `Avatar.Fallback` outside a root would render initials that never go away and
113
+ * would look exactly like one that works.
114
+ */
115
+ hook useAvatar(part: string): AvatarState {
116
+ const state = useContext(AvatarContext);
117
+ if (state == null) {
118
+ throw new Error(`${part} must be rendered inside an Avatar.Root`);
119
+ }
120
+ return state;
121
+ }
122
+
123
+ /**
124
+ * The avatar, and the state machine its two parts read.
125
+ *
126
+ * A `<span>` rather than a `<div>`, because an avatar belongs beside a name —
127
+ * in a table cell, in a paragraph, inside a button's label — and a block
128
+ * element is invalid in half of those.
129
+ */
130
+ export component AvatarRoot(children: React.Node, render?: RenderProp, ...rest: Rest) {
131
+ const [seen, setSeen] = useState<Seen>(START);
132
+ const [hasImage, setHasImage] = useState(false);
133
+
134
+ const report = useCallback((source: string | null, status: AvatarStatus) => {
135
+ setSeen((current) => {
136
+ if (current.source !== source) {
137
+ return { source, status };
138
+ }
139
+ // A verdict already reached about this source is not revisited. Without
140
+ // this the failed image would be put back to ask again, fail again, and
141
+ // be put back again: `Avatar.Image` stops rendering the element once it
142
+ // has failed, so "loading" for the same source is a loop and not a retry.
143
+ if (status === "loading" || current.status === status) {
144
+ return current;
145
+ }
146
+ return { source, status };
147
+ });
148
+ }, []);
149
+
150
+ const state = useMemo(
151
+ () => ({
152
+ status: seen.status,
153
+ hasImage,
154
+ report,
155
+ registerImage: setHasImage,
156
+ }),
157
+ [seen, hasImage, report],
158
+ );
159
+ const props = withProps(rest, { children });
160
+
161
+ return (
162
+ <AvatarContext.Provider value={state}>
163
+ {render != null ? render(props) : <span {...props} />}
164
+ </AvatarContext.Provider>
165
+ );
166
+ }
167
+
168
+ /**
169
+ * The picture.
170
+ *
171
+ * `src` is named rather than left in `rest` because the state machine is about
172
+ * it: a new source is a new question, and the verdict reached about the last
173
+ * one has to stop applying the moment it changes.
174
+ *
175
+ * `alt` defaults to `""`, which is the decision this component exists for as
176
+ * much as the three states are — see the module header.
177
+ *
178
+ * The element stops being rendered once it has failed, rather than being left
179
+ * to show the browser's broken-image glyph beside the fallback that replaced
180
+ * it. That is a thing a package which ships no styles cannot leave to a
181
+ * stylesheet: `hidden` loses to any `display` the caller sets, and a caller who
182
+ * has not written that rule yet would see both.
183
+ */
184
+ export component AvatarImage(
185
+ alt?: string = "",
186
+ src?: string | null,
187
+ render?: RenderProp,
188
+ ...rest: Rest
189
+ ) {
190
+ const avatar = useAvatar("Avatar.Image");
191
+ const element = useRef<HTMLImageElement | null>(null);
192
+ const report = avatar.report;
193
+ const registerImage = avatar.registerImage;
194
+ const source = src ?? null;
195
+ const passed = withoutComposed(rest, ["onError", "onLoad", "ref"]);
196
+
197
+ useEffect(() => {
198
+ registerImage(true);
199
+ return () => registerImage(false);
200
+ }, [registerImage]);
201
+
202
+ useEffect(() => {
203
+ if (source == null || source === "") {
204
+ // No source is not a slow source. There is nothing coming, so the
205
+ // fallback is the answer now rather than after the delay.
206
+ report(source, "error");
207
+ return;
208
+ }
209
+ const image = element.current;
210
+ // Null only when this source has already failed and the element went with
211
+ // it, in which case the verdict on record is the right one.
212
+ if (image != null && image.complete && image.naturalWidth > 0) {
213
+ report(source, "loaded");
214
+ return;
215
+ }
216
+ report(source, "loading");
217
+ }, [report, source]);
218
+
219
+ if (avatar.status === "error") {
220
+ return null;
221
+ }
222
+ const props = withProps(passed, {
223
+ alt,
224
+ onError: composeHandlers(rest.onError, () => report(source, "error")),
225
+ onLoad: composeHandlers(rest.onLoad, () => report(source, "loaded")),
226
+ ref: composeRefs(rest.ref, (node: HTMLImageElement | null) => {
227
+ element.current = node;
228
+ }),
229
+ src: source ?? undefined,
230
+ });
231
+ if (render != null) {
232
+ return render(props);
233
+ }
234
+ return <img {...props} />;
235
+ }
236
+
237
+ /**
238
+ * What is shown instead: initials, a silhouette, a coloured disc.
239
+ *
240
+ * Absent while the image is still loading, which is the point. `delay` is how
241
+ * long "still loading" is allowed to last before the fallback appears anyway —
242
+ * long enough that a cached image never flashes initials, short enough that a
243
+ * genuinely slow one does not leave a hole. Pass `0` to show it the moment
244
+ * there is nothing to show instead.
245
+ *
246
+ * The delay applies to *loading* and to nothing else. A failed image and an
247
+ * avatar with no `Avatar.Image` at all are both answers rather than waits, and
248
+ * the fallback for either is immediate.
249
+ */
250
+ export component AvatarFallback(
251
+ children: React.Node,
252
+ delay?: number = 300,
253
+ render?: RenderProp,
254
+ ...rest: Rest
255
+ ) {
256
+ const avatar = useAvatar("Avatar.Fallback");
257
+ const [elapsed, setElapsed] = useState(false);
258
+ const waiting = avatar.hasImage && avatar.status === "loading";
259
+
260
+ useTimeout(() => setElapsed(true), waiting && delay > 0 ? delay : null);
261
+
262
+ useEffect(() => {
263
+ if (!waiting) {
264
+ setElapsed(false);
265
+ }
266
+ }, [waiting]);
267
+
268
+ if (avatar.status === "loaded" || (waiting && delay > 0 && !elapsed)) {
269
+ return null;
270
+ }
271
+ const props = withProps(rest, { children });
272
+ if (render != null) {
273
+ return render(props);
274
+ }
275
+ return <span {...props} />;
276
+ }
package/breadcrumb.js ADDED
@@ -0,0 +1,138 @@
1
+ // @flow
2
+ //
3
+ // A breadcrumb trail, read as a trail rather than as punctuation.
4
+ //
5
+ // It is `pagination.js`'s shape — a named `<nav>` around a list, with one item
6
+ // marked `aria-current="page"` — and it is a separate module for the same
7
+ // reason those two are separate entries: a paginated table and a trail through
8
+ // a hierarchy are different things to a reader, and the parts are named after
9
+ // what they mean rather than after what they render.
10
+ //
11
+ // Three decisions, each of which is invisible when it is missing:
12
+ //
13
+ // * **It is navigation, so it is a `<nav>` with a name.** A page has more
14
+ // than one `nav` and an unnamed one is announced as "navigation", with
15
+ // nothing to tell it from the site's menu. `aria-label="Breadcrumb"` is
16
+ // what puts it in a screen reader's landmark list under a useful name, and
17
+ // it is the name assistive technology's own documentation tells readers to
18
+ // look for.
19
+ // * **The last item is `aria-current="page"`, and it is not a link.** It is
20
+ // where the reader already is. A trail whose last entry is a link that
21
+ // leads to the page it is on is a link that does nothing, and announcing
22
+ // "link" for it is a promise the page does not keep — so `Breadcrumb.Page`
23
+ // is a `<span>`. The `role="link"` with `aria-disabled` that this component
24
+ // is usually copied with says "link, dimmed", which is a *control the
25
+ // reader cannot use* rather than a place they have arrived at.
26
+ // * **The separators are `aria-hidden`.** Otherwise the trail is read as
27
+ // "Home slash Settings slash Billing", and the slashes are the loudest
28
+ // thing in it. They are `<li>` elements because an `<ol>` may only contain
29
+ // `<li>`, and they carry `role="presentation"` as well so that the count a
30
+ // reader is given — "list, three items" — is the number of places and not
31
+ // the number of places plus the punctuation between them.
32
+ //
33
+ // # What the type says that the markup cannot
34
+ //
35
+ // `Breadcrumb.List` declares `renders* (Breadcrumb.Item | Breadcrumb.Separator)`,
36
+ // so a `<div>` between two crumbs is a type error rather than an `<ol>` a
37
+ // validator would reject and a screen reader would count wrong.
38
+ // `Pagination.Content` states the same constraint for the same element and the
39
+ // same reason.
40
+ //
41
+ // # No `"use client"`
42
+ //
43
+ // Nothing here holds state, listens to anything or moves focus. Which crumb is
44
+ // current is the caller's, and the links are links. It renders on a server.
45
+
46
+ import * as React from "@uniflowed/react";
47
+
48
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
49
+ import { withProps } from "./internal/merge-props.js";
50
+
51
+ /**
52
+ * The trail, as a named landmark.
53
+ *
54
+ * `label` is the accessible name and has a default because there is one right
55
+ * answer in English and it is the one readers are taught to look for. Pass it
56
+ * to translate; there is no case for leaving it off, which is why it is not
57
+ * optional in the sense of being absent.
58
+ */
59
+ export component BreadcrumbRoot(
60
+ children: React.Node,
61
+ label?: string = "Breadcrumb",
62
+ render?: RenderProp,
63
+ ...rest: Rest
64
+ ) {
65
+ const props = withProps(rest, { "aria-label": label, children });
66
+ if (render != null) {
67
+ return render(withProps(props, { role: "navigation" }));
68
+ }
69
+ return <nav {...props} />;
70
+ }
71
+
72
+ /**
73
+ * The crumbs, in order.
74
+ *
75
+ * An ordered list rather than a row of links, because the order is the whole
76
+ * information: a reader is told how many levels there are before walking them,
77
+ * and can skip the lot in one keystroke.
78
+ */
79
+ export component BreadcrumbList(
80
+ children: renders* (BreadcrumbItem | BreadcrumbSeparator),
81
+ render?: RenderProp,
82
+ ...rest: Rest
83
+ ) {
84
+ const props = withProps(rest, { children });
85
+ if (render != null) {
86
+ return render(withProps(props, { role: "list" }));
87
+ }
88
+ return <ol {...props} />;
89
+ }
90
+
91
+ /** One level of the trail. Holds a `Breadcrumb.Link` or a `Breadcrumb.Page`. */
92
+ export component BreadcrumbItem(children: React.Node, render?: RenderProp, ...rest: Rest) {
93
+ const props = withProps(rest, { children });
94
+ if (render != null) {
95
+ return render(withProps(props, { role: "listitem" }));
96
+ }
97
+ return <li {...props} />;
98
+ }
99
+
100
+ /** A level you can go back to. */
101
+ export component BreadcrumbLink(children: React.Node, render?: RenderProp, ...rest: Rest) {
102
+ const props = withProps(rest, { children });
103
+ if (render != null) {
104
+ return render(withProps(props, { role: "link" }));
105
+ }
106
+ return <a {...props} />;
107
+ }
108
+
109
+ /**
110
+ * The level you are on.
111
+ *
112
+ * `aria-current="page"` is the whole of it, and it is on this part rather than
113
+ * being a `current` prop on `Breadcrumb.Link` so that the last crumb cannot be
114
+ * a link by accident. See the module header for why announcing it as a disabled
115
+ * link is worse than announcing it as text.
116
+ */
117
+ export component BreadcrumbPage(children: React.Node, render?: RenderProp, ...rest: Rest) {
118
+ const props = withProps(rest, { "aria-current": "page", children });
119
+ if (render != null) {
120
+ return render(props);
121
+ }
122
+ return <span {...props} />;
123
+ }
124
+
125
+ /**
126
+ * The mark between two crumbs.
127
+ *
128
+ * The glyph is the caller's — a slash, a chevron, an icon — because it is a
129
+ * design decision and this package makes none. What is not the caller's is that
130
+ * it is announced to nobody.
131
+ */
132
+ export component BreadcrumbSeparator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
133
+ const props = withProps(rest, { "aria-hidden": "true", children, role: "presentation" });
134
+ if (render != null) {
135
+ return render(props);
136
+ }
137
+ return <li {...props} />;
138
+ }
package/checkbox.js CHANGED
@@ -68,8 +68,8 @@
68
68
 
69
69
  import * as React from "@uniflowed/react";
70
70
 
71
- import type { Rest } from "./internal/merge-props.js";
72
- import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
71
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
72
+ import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
73
73
  import { useControlled } from "./internal/controlled-state.js";
74
74
 
75
75
  /**
@@ -203,7 +203,14 @@ function submitImplicitly(control: HTMLElement): void {
203
203
  form.requestSubmit(submitter);
204
204
  }
205
205
 
206
- /** A checkbox, which may also be mixed. */
206
+ /**
207
+ * A checkbox, which may also be mixed.
208
+ *
209
+ * `render` is the escape hatch, and the implicit submission above is the reason
210
+ * it hands over `onKeyDown` rather than attaching it: whatever element a caller
211
+ * renders is the one `Enter` arrives on, and it is that element's `form` the
212
+ * key walks up to.
213
+ */
207
214
  export component Checkbox(
208
215
  checked?: boolean,
209
216
  defaultChecked?: boolean = false,
@@ -211,6 +218,7 @@ export component Checkbox(
211
218
  onCheckedChange?: (checked: boolean) => void,
212
219
  disabled?: boolean = false,
213
220
  children?: React.Node,
221
+ render?: RenderProp,
214
222
  ...rest: Rest
215
223
  ) {
216
224
  const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
@@ -218,41 +226,39 @@ export component Checkbox(
218
226
  // underneath it": a half-selected "select all" that clears itself on the
219
227
  // first click is the behaviour every table in every application gets wrong.
220
228
  const next = indeterminate ? true : !on;
221
- const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
229
+ const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown"]), {
230
+ "aria-checked": indeterminate ? "mixed" : on ? "true" : "false",
231
+ children,
232
+ disabled,
233
+ onClick: composeHandlers(rest.onClick, () => {
234
+ if (!disabled) {
235
+ setOn(next);
236
+ }
237
+ }),
238
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
239
+ if (disabled) {
240
+ return;
241
+ }
242
+ if (event.key === " ") {
243
+ // Stops `Space` scrolling the page, and stops the browser's own click
244
+ // arriving afterwards and toggling this a second time.
245
+ event.preventDefault();
246
+ setOn(next);
247
+ return;
248
+ }
249
+ if (event.key === "Enter") {
250
+ // Claimed, and *not* to make the key inert: the default action here
251
+ // is a click on this button, and a click on this button toggles. See
252
+ // the module header for the whole of it.
253
+ event.preventDefault();
254
+ submitImplicitly(event.currentTarget as $FlowFixMe);
255
+ }
256
+ }),
257
+ role: "checkbox",
258
+ });
222
259
 
223
- return (
224
- <button
225
- {...passed}
226
- aria-checked={indeterminate ? "mixed" : on ? "true" : "false"}
227
- disabled={disabled}
228
- onClick={composeHandlers(rest.onClick, () => {
229
- if (!disabled) {
230
- setOn(next);
231
- }
232
- })}
233
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
234
- if (disabled) {
235
- return;
236
- }
237
- if (event.key === " ") {
238
- // Stops `Space` scrolling the page, and stops the browser's own click
239
- // arriving afterwards and toggling this a second time.
240
- event.preventDefault();
241
- setOn(next);
242
- return;
243
- }
244
- if (event.key === "Enter") {
245
- // Claimed, and *not* to make the key inert: the default action here
246
- // is a click on this button, and a click on this button toggles. See
247
- // the module header for the whole of it.
248
- event.preventDefault();
249
- submitImplicitly(event.currentTarget as $FlowFixMe);
250
- }
251
- })}
252
- role="checkbox"
253
- type="button"
254
- >
255
- {children}
256
- </button>
257
- );
260
+ if (render != null) {
261
+ return render(props);
262
+ }
263
+ return <button {...props} type="button" />;
258
264
  }
package/collapsible.js CHANGED
@@ -52,8 +52,13 @@
52
52
  import * as React from "@uniflowed/react";
53
53
  import { createContext, useContext, useId, useMemo, useRef, useState } from "@uniflowed/react";
54
54
 
55
- import type { Rest } from "./internal/merge-props.js";
56
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
55
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
56
+ import {
57
+ composeHandlers,
58
+ composeRefs,
59
+ withProps,
60
+ withoutComposed,
61
+ } from "./internal/merge-props.js";
57
62
  import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
58
63
  import { useControlled } from "./internal/controlled-state.js";
59
64
 
@@ -105,34 +110,33 @@ export component CollapsibleRoot(
105
110
  return <CollapsibleContext.Provider value={state}>{children}</CollapsibleContext.Provider>;
106
111
  }
107
112
 
108
- /** The button that shows and hides the content. */
113
+ /** The control that shows and hides the content. */
109
114
  export component CollapsibleTrigger(
110
115
  children: React.Node,
111
116
  disabled?: boolean = false,
117
+ render?: RenderProp,
112
118
  ...rest: Rest
113
119
  ) {
114
120
  const collapsible = useCollapsible("Collapsible.Trigger");
115
- const passed = withoutComposed(rest, ["onClick"]);
121
+ const props = withProps(withoutComposed(rest, ["onClick"]), {
122
+ // Named only while the content is in the document. A caller who renders
123
+ // the content conditionally — or not at all until data arrives — would
124
+ // otherwise have this trigger pointing at nothing.
125
+ "aria-controls": collapsible.present ? collapsible.contentId : undefined,
126
+ "aria-expanded": collapsible.open ? "true" : "false",
127
+ children,
128
+ disabled,
129
+ onClick: composeHandlers(rest.onClick, () => {
130
+ if (!disabled) {
131
+ collapsible.setOpen(!collapsible.open);
132
+ }
133
+ }),
134
+ });
116
135
 
117
- return (
118
- <button
119
- {...passed}
120
- // Named only while the content is in the document. A caller who renders
121
- // the content conditionally — or not at all until data arrives — would
122
- // otherwise have this button pointing at nothing.
123
- aria-controls={collapsible.present ? collapsible.contentId : undefined}
124
- aria-expanded={collapsible.open ? "true" : "false"}
125
- disabled={disabled}
126
- onClick={composeHandlers(rest.onClick, () => {
127
- if (!disabled) {
128
- collapsible.setOpen(!collapsible.open);
129
- }
130
- })}
131
- type="button"
132
- >
133
- {children}
134
- </button>
135
- );
136
+ if (render != null) {
137
+ return render(props);
138
+ }
139
+ return <button {...props} type="button" />;
136
140
  }
137
141
 
138
142
  /**
@@ -142,23 +146,24 @@ export component CollapsibleTrigger(
142
146
  * the module header, and `internal/disclosure.js` for what `hidden` is upgraded
143
147
  * to and why that takes an effect.
144
148
  */
145
- export component CollapsibleContent(children: React.Node, ...rest: Rest) {
149
+ export component CollapsibleContent(children: React.Node, render?: RenderProp, ...rest: Rest) {
146
150
  const collapsible = useCollapsible("Collapsible.Content");
147
151
  const contentRef = useRef<HTMLElement | null>(null);
148
152
  usePresence(collapsible.registerContent);
149
153
  useUntilFound(contentRef, collapsible.open);
150
154
  useMeasuredHeight(contentRef, collapsible.measure);
151
155
 
152
- return (
153
- <div
154
- {...withoutComposed(rest, ["ref"])}
155
- hidden={!collapsible.open}
156
- id={collapsible.contentId}
157
- ref={composeRefs(rest.ref, (element) => {
158
- contentRef.current = element;
159
- })}
160
- >
161
- {children}
162
- </div>
163
- );
156
+ const props = withProps(withoutComposed(rest, ["ref"]), {
157
+ children,
158
+ hidden: !collapsible.open,
159
+ id: collapsible.contentId,
160
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
161
+ contentRef.current = element;
162
+ }),
163
+ });
164
+
165
+ if (render != null) {
166
+ return render(props);
167
+ }
168
+ return <div {...props} />;
164
169
  }