@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40

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 (52) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +169 -0
  10. package/combobox.js +209 -40
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +229 -197
  14. package/drawer.js +490 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +330 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2323 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +205 -11
  30. package/menu.js +521 -336
  31. package/menubar.js +288 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +344 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +302 -0
  38. package/resizable.js +447 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +888 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +313 -0
  44. package/skeleton.js +159 -0
  45. package/slider.js +405 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +592 -0
  50. package/toggle-group.js +282 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +400 -0
package/dialog.js CHANGED
@@ -31,6 +31,32 @@
31
31
  // looks. The page behind is hidden by marking it inert rather than by moving
32
32
  // the dialog out of it, which gets the same guarantee without the move.
33
33
  //
34
+ // # Three things a caller decides, and why they are props rather than four
35
+ // components
36
+ //
37
+ // A modal dialog is one pattern with three places a *different* modal dialog
38
+ // differs, and each of the three fails silently when it is hard-coded:
39
+ //
40
+ // * **`role`** — `dialog` or `alertdialog`. The second tells a screen reader
41
+ // the dialog is interrupting to say something urgent and makes it announce
42
+ // the description immediately, which is the difference between "dialog,
43
+ // Delete this project?" and an alert the reader is expected to answer.
44
+ // * **`dismissOnOutsidePress`** — whether a press beside the dialog closes
45
+ // it. A confirmation that vanishes when the reader clicks slightly beside
46
+ // it, losing what they were about to confirm and saying nothing about which
47
+ // way it went, is the single behaviour an alert dialog exists to prevent.
48
+ // `Escape` keeps closing it either way: a modal a reader cannot leave from
49
+ // the keyboard is a trap, and declining an alert is what `Escape` means.
50
+ // * **`initialFocus`** — where focus lands. "The first thing worth acting on"
51
+ // is right for a dialog and wrong for a confirmation, where the APG puts
52
+ // focus on the *least* destructive action: Cancel, not Delete.
53
+ //
54
+ // They are three props on `Dialog.Body` and not a `variant` flag, because a
55
+ // flag is a name for a bundle and the bundles differ: `alert-dialog.js` sets
56
+ // all three, `sheet.js` sets none of them and adds an edge, `sidebar.js` is
57
+ // most often not modal at all. What each of those components adds is written
58
+ // where it lives; what they share is here, once.
59
+ //
34
60
  // # Composition
35
61
  //
36
62
  // The parts are one namespace — `Dialog.Root`, `Dialog.Body`, `Dialog.Title` —
@@ -49,11 +75,34 @@ import {
49
75
  useRef,
50
76
  useState,
51
77
  } from "@uniflowed/react";
78
+ import { useScrollLock } from "@uniflowed/hooks/browser";
52
79
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
53
80
 
54
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
81
+ import { useInteractOutside } from "./interactions.js";
82
+
83
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
84
+ import {
85
+ composeHandlers,
86
+ composeRefs,
87
+ withProps,
88
+ withoutComposed,
89
+ } from "./internal/merge-props.js";
90
+ import { focusable } from "./internal/focus.js";
55
91
  import { useControlled } from "./internal/controlled-state.js";
56
92
 
93
+ /**
94
+ * What a screen reader is told the dialog is.
95
+ *
96
+ * A union rather than a string, so `role="alertdailog"` is a type error at the
97
+ * call rather than a dialog announced as a `div` with a name — which is what a
98
+ * misspelt role produces, silently, in markup that looks correct.
99
+ *
100
+ * Two members and not the whole of ARIA: these are the two roles that carry
101
+ * `aria-modal`, and a `Dialog.Body` that is a `region` or a `complementary` is
102
+ * a different component rather than this one with another string.
103
+ */
104
+ export type DialogRole = "dialog" | "alertdialog";
105
+
57
106
  type DialogState = {|
58
107
  readonly base: string,
59
108
  readonly open: boolean,
@@ -113,29 +162,34 @@ export component DialogRoot(
113
162
  return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
114
163
  }
115
164
 
116
- /** What opens the dialog, and what focus comes back to when it closes. */
117
- export component DialogTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
165
+ /**
166
+ * What opens the dialog, and what focus comes back to when it closes.
167
+ *
168
+ * `render` for a trigger that is not a `<button>` — a card, a table row, an
169
+ * icon in somebody else's `<Pressable>`. The ref goes across with everything
170
+ * else, which is what keeps "focus comes back here" true of whatever the
171
+ * caller rendered.
172
+ */
173
+ export component DialogTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
118
174
  const dialog = useDialog("Dialog.Trigger");
119
- const passed = withoutComposed(rest, ["onClick", "ref"]);
120
-
121
- return (
122
- <button
123
- {...passed}
124
- // Only while it is open. An `aria-controls` naming an element that is not
125
- // in the document is worse than no `aria-controls`: a reader is told
126
- // there is somewhere to go and there is not.
127
- aria-controls={dialog.open ? `${dialog.base}-body` : undefined}
128
- aria-expanded={dialog.open ? "true" : "false"}
129
- aria-haspopup="dialog"
130
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(true))}
131
- ref={composeRefs(rest.ref, (element) => {
132
- dialog.triggerRef.current = element;
133
- })}
134
- type="button"
135
- >
136
- {children}
137
- </button>
138
- );
175
+ const props = withProps(withoutComposed(rest, ["onClick", "ref"]), {
176
+ // Only while it is open. An `aria-controls` naming an element that is not
177
+ // in the document is worse than no `aria-controls`: a reader is told
178
+ // there is somewhere to go and there is not.
179
+ "aria-controls": dialog.open ? `${dialog.base}-body` : undefined,
180
+ "aria-expanded": dialog.open ? "true" : "false",
181
+ "aria-haspopup": "dialog",
182
+ children,
183
+ onClick: composeHandlers(rest.onClick, () => dialog.setOpen(true)),
184
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
185
+ dialog.triggerRef.current = element;
186
+ }),
187
+ });
188
+
189
+ if (render != null) {
190
+ return render(props);
191
+ }
192
+ return <button {...props} type="button" />;
139
193
  }
140
194
 
141
195
  /**
@@ -147,12 +201,16 @@ export component DialogTrigger(children: React.Node, ...rest: { readonly [string
147
201
  * their own backdrop or omits one entirely must still get it. That lives on
148
202
  * `Dialog.Body`, which is the part that knows where "outside" is.
149
203
  */
150
- export component DialogOverlay(...rest: { readonly [string]: mixed }) {
204
+ export component DialogOverlay(render?: RenderProp, ...rest: Rest) {
151
205
  const dialog = useDialog("Dialog.Overlay");
152
206
  if (!dialog.open) {
153
207
  return null;
154
208
  }
155
- return <div {...rest} aria-hidden="true" data-state="open" />;
209
+ const props = withProps(rest, { "aria-hidden": "true", "data-state": "open" });
210
+ if (render != null) {
211
+ return render(props);
212
+ }
213
+ return <div {...props} />;
156
214
  }
157
215
 
158
216
  /**
@@ -162,7 +220,14 @@ export component DialogOverlay(...rest: { readonly [string]: mixed }) {
162
220
  * which is the half of "modal" that CSS cannot express; `inert` on everything
163
221
  * outside is the half the browser enforces.
164
222
  */
165
- export component DialogBody(children: React.Node, ...rest: { readonly [string]: mixed }) {
223
+ export component DialogBody(
224
+ children: React.Node,
225
+ dismissOnOutsidePress?: boolean = true,
226
+ initialFocus?: { current: HTMLElement | null },
227
+ role?: DialogRole = "dialog",
228
+ render?: RenderProp,
229
+ ...rest: Rest
230
+ ) {
166
231
  const dialog = useDialog("Dialog.Body");
167
232
  const bodyRef = useRef<HTMLElement | null>(null);
168
233
  // Stable, so the effect below depends on `open` and on nothing else. Keyed on
@@ -171,6 +236,19 @@ export component DialogBody(children: React.Node, ...rest: { readonly [string]:
171
236
  // parent that re-rendered stole focus back from whatever the reader had
172
237
  // moved it to inside the dialog.
173
238
  const close = useStableCallback(() => dialog.setOpen(false));
239
+ // Asked at the moment of the press rather than named in the dependencies
240
+ // below, because naming it there re-runs the effect when it changes and
241
+ // re-running the effect re-takes focus. A caller who flips this on a
242
+ // breakpoint would otherwise have focus dragged back to the top of the
243
+ // dialog underneath the reader.
244
+ const dismissable = useStableCallback(() => dismissOnOutsidePress);
245
+
246
+ // The page is held still by `@uniflowed/hooks`' reference-counted lock rather
247
+ // than by a second one written here. Two implementations of this in one
248
+ // repository is the duplication "build uf with uf" exists to catch, and the
249
+ // shared one is the one that also pads out the scrollbar's width — a page
250
+ // that jumps sideways when a dialog opens is this component's doing.
251
+ useScrollLock(dialog.open);
174
252
 
175
253
  useEffect(() => {
176
254
  const body = bodyRef.current;
@@ -184,111 +262,110 @@ export component DialogBody(children: React.Node, ...rest: { readonly [string]:
184
262
  const opener = trigger ?? (document.activeElement as $FlowFixMe);
185
263
 
186
264
  const restorePage = concealOutside(body);
187
- const releaseScroll = lockScroll(document);
188
-
189
- const onOutsidePress = (event: Event) => {
190
- const target: $FlowFixMe = event.target;
191
- if (target == null || body.contains(target)) {
192
- return;
193
- }
194
- // The trigger is outside the dialog and is not "outside" for this
195
- // purpose: closing here and letting the trigger's own click reopen made
196
- // a press on the trigger a no-op that flickered.
197
- if (trigger != null && trigger.contains(target)) {
198
- return;
199
- }
200
- close();
201
- };
202
- // Capture, so a press is seen even where something below it stops the
203
- // event — a menu inside the dialog, for instance.
204
- document.addEventListener("pointerdown", onOutsidePress, true);
205
265
 
206
- // The first thing worth acting on, and the dialog itself when it holds
207
- // nothing focusable, so focus is inside it either way.
208
- const target = focusable(body)[0] ?? body;
266
+ // Where the caller said, then the first thing worth acting on, then the
267
+ // dialog itself when it holds nothing focusable — so focus is inside it
268
+ // whichever of the three answers.
269
+ //
270
+ // The named element has to still be *in* this dialog: a ref left over from
271
+ // a previous opening, or one pointing at something the caller renders
272
+ // elsewhere, would move focus out of a dialog that announces the rest of
273
+ // the page is unavailable.
274
+ const named = initialFocus?.current ?? null;
275
+ const target = named != null && body.contains(named) ? named : (focusable(body)[0] ?? body);
209
276
  target.focus();
210
277
 
211
278
  return () => {
212
- document.removeEventListener("pointerdown", onOutsidePress, true);
213
279
  // Order matters: the page comes back before focus is restored, because
214
280
  // the trigger is one of the elements that was made `inert` and an inert
215
281
  // element cannot take focus.
216
282
  restorePage();
217
- releaseScroll();
218
283
  opener?.focus?.();
219
284
  };
220
- }, [dialog.open, dialog.triggerRef, close]);
285
+ }, [dialog.open, dialog.triggerRef, initialFocus]);
286
+
287
+ // A confirmation declines to close here, and `Escape` still does. See the
288
+ // module header: there is a difference between a dialog the reader dismissed
289
+ // and one that went away while they were reaching for it.
290
+ useInteractOutside({
291
+ isDisabled: !dialog.open,
292
+ onInteractOutside: () => {
293
+ if (dismissable()) {
294
+ close();
295
+ }
296
+ },
297
+ refs: [bodyRef, dialog.triggerRef],
298
+ });
221
299
 
222
300
  if (!dialog.open) {
223
301
  return null;
224
302
  }
225
303
 
226
- const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
227
-
228
- return (
229
- <div
230
- // `passed` first. A caller `ref` used to replace `bodyRef`, which left it
231
- // null, made the Tab branch below return early, and turned the focus trap
232
- // off while the dialog still announced `aria-modal="true"`. A caller
233
- // `onKeyDown` used to replace this one, and Escape stopped closing it.
234
- {...passed}
235
- // Only ids that are in the document: an `aria-labelledby` naming a
236
- // missing element makes a screen reader announce nothing at all, so a
237
- // dialog without a `Dialog.Title` falls through to whatever `aria-label`
238
- // the caller passed instead.
239
- aria-describedby={dialog.described ? `${dialog.base}-description` : undefined}
240
- aria-labelledby={dialog.titled ? `${dialog.base}-title` : undefined}
241
- aria-modal="true"
242
- id={`${dialog.base}-body`}
243
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
244
- if (event.key === "Escape") {
245
- event.preventDefault();
246
- // The dialog behind this one must not also close. Two stacked
247
- // dialogs nest in the DOM, so without this the event bubbled to the
248
- // outer dialog's handler and one Escape closed both.
249
- event.stopPropagation();
250
- close();
251
- return;
252
- }
253
- if (event.key !== "Tab") {
254
- return;
255
- }
256
- const body = bodyRef.current;
257
- if (body == null) {
258
- return;
259
- }
260
- const stops = focusable(body);
261
- // An outer dialog must not also run its trap on this key.
304
+ // The caller's props first. A caller `ref` used to replace `bodyRef`, which
305
+ // left it null, made the Tab branch below return early, and turned the focus
306
+ // trap off while the dialog still announced `aria-modal="true"`. A caller
307
+ // `onKeyDown` used to replace this one, and Escape stopped closing it.
308
+ const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
309
+ // Only ids that are in the document: an `aria-labelledby` naming a
310
+ // missing element makes a screen reader announce nothing at all, so a
311
+ // dialog without a `Dialog.Title` falls through to whatever `aria-label`
312
+ // the caller passed instead.
313
+ "aria-describedby": dialog.described ? `${dialog.base}-description` : undefined,
314
+ "aria-labelledby": dialog.titled ? `${dialog.base}-title` : undefined,
315
+ "aria-modal": "true",
316
+ children,
317
+ id: `${dialog.base}-body`,
318
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
319
+ if (event.key === "Escape") {
320
+ event.preventDefault();
321
+ // The dialog behind this one must not also close. Two stacked
322
+ // dialogs nest in the DOM, so without this the event bubbled to the
323
+ // outer dialog's handler and one Escape closed both.
262
324
  event.stopPropagation();
263
- if (stops.length === 0) {
264
- // Nothing to move to, so Tab must not leave either.
265
- event.preventDefault();
266
- return;
267
- }
268
- const first = stops[0];
269
- const last = stops[stops.length - 1];
270
- const active = body.ownerDocument?.activeElement;
271
- // Wrap at the ends. This is the whole of "focus cannot leave"; every
272
- // other Tab press is the browser's own business.
273
- if (event.shiftKey && (active === first || active === body)) {
274
- event.preventDefault();
275
- last.focus();
276
- } else if (!event.shiftKey && active === last) {
277
- event.preventDefault();
278
- first.focus();
279
- }
280
- })}
281
- ref={composeRefs(rest.ref, (element) => {
282
- bodyRef.current = element;
283
- })}
284
- role="dialog"
285
- // So the dialog can hold focus itself when it contains nothing focusable,
286
- // and so the trap has somewhere to put focus that is still inside.
287
- tabIndex={-1}
288
- >
289
- {children}
290
- </div>
291
- );
325
+ close();
326
+ return;
327
+ }
328
+ if (event.key !== "Tab") {
329
+ return;
330
+ }
331
+ const body = bodyRef.current;
332
+ if (body == null) {
333
+ return;
334
+ }
335
+ const stops = focusable(body);
336
+ // An outer dialog must not also run its trap on this key.
337
+ event.stopPropagation();
338
+ if (stops.length === 0) {
339
+ // Nothing to move to, so Tab must not leave either.
340
+ event.preventDefault();
341
+ return;
342
+ }
343
+ const first = stops[0];
344
+ const last = stops[stops.length - 1];
345
+ const active = body.ownerDocument?.activeElement;
346
+ // Wrap at the ends. This is the whole of "focus cannot leave"; every
347
+ // other Tab press is the browser's own business.
348
+ if (event.shiftKey && (active === first || active === body)) {
349
+ event.preventDefault();
350
+ last.focus();
351
+ } else if (!event.shiftKey && active === last) {
352
+ event.preventDefault();
353
+ first.focus();
354
+ }
355
+ }),
356
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
357
+ bodyRef.current = element;
358
+ }),
359
+ role,
360
+ // So the dialog can hold focus itself when it contains nothing focusable,
361
+ // and so the trap has somewhere to put focus that is still inside.
362
+ tabIndex: -1,
363
+ });
364
+
365
+ if (render != null) {
366
+ return render(props);
367
+ }
368
+ return <div {...props} />;
292
369
  }
293
370
 
294
371
  /**
@@ -298,7 +375,7 @@ export component DialogBody(children: React.Node, ...rest: { readonly [string]:
298
375
  * rendered — a conditional title that is absent used to leave the dialog
299
376
  * pointing at an id nothing had.
300
377
  */
301
- export component DialogTitle(children: React.Node, ...rest: { readonly [string]: mixed }) {
378
+ export component DialogTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
302
379
  const dialog = useDialog("Dialog.Title");
303
380
  const register = dialog.registerTitle;
304
381
  useEffect(() => {
@@ -306,11 +383,15 @@ export component DialogTitle(children: React.Node, ...rest: { readonly [string]:
306
383
  return () => register(false);
307
384
  }, [register]);
308
385
 
309
- return (
310
- <h2 {...rest} id={`${dialog.base}-title`}>
311
- {children}
312
- </h2>
313
- );
386
+ const props = withProps(rest, { children, id: `${dialog.base}-title` });
387
+ // `<h2>` is a default rather than a decision. Which heading level a dialog's
388
+ // name is depends on what is around it — ubugeeei-prod/uf#276 is the same
389
+ // observation about an accordion — and `render` is how a caller says so
390
+ // without losing the id `aria-labelledby` points at.
391
+ if (render != null) {
392
+ return render(props);
393
+ }
394
+ return <h2 {...props} />;
314
395
  }
315
396
 
316
397
  /**
@@ -320,7 +401,7 @@ export component DialogTitle(children: React.Node, ...rest: { readonly [string]:
320
401
  * the one moment the reader has to decide whether they care — so this is where
321
402
  * "this cannot be undone" belongs, not in body text further down.
322
403
  */
323
- export component DialogDescription(children: React.Node, ...rest: { readonly [string]: mixed }) {
404
+ export component DialogDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
324
405
  const dialog = useDialog("Dialog.Description");
325
406
  const register = dialog.registerDescription;
326
407
  useEffect(() => {
@@ -328,11 +409,11 @@ export component DialogDescription(children: React.Node, ...rest: { readonly [st
328
409
  return () => register(false);
329
410
  }, [register]);
330
411
 
331
- return (
332
- <p {...rest} id={`${dialog.base}-description`}>
333
- {children}
334
- </p>
335
- );
412
+ const props = withProps(rest, { children, id: `${dialog.base}-description` });
413
+ if (render != null) {
414
+ return render(props);
415
+ }
416
+ return <p {...props} />;
336
417
  }
337
418
 
338
419
  /**
@@ -344,51 +425,35 @@ export component DialogDescription(children: React.Node, ...rest: { readonly [st
344
425
  * styling layer has a name to attach to, and contributes no semantics because
345
426
  * it has none to contribute.
346
427
  */
347
- export component DialogHeader(children: React.Node, ...rest: { readonly [string]: mixed }) {
348
- return <div {...rest}>{children}</div>;
428
+ export component DialogHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
429
+ const props = withProps(rest, { children });
430
+ if (render != null) {
431
+ return render(props);
432
+ }
433
+ return <div {...props} />;
349
434
  }
350
435
 
351
436
  /** The bottom of the dialog, where the actions go. See `Dialog.Header`. */
352
- export component DialogFooter(children: React.Node, ...rest: { readonly [string]: mixed }) {
353
- return <div {...rest}>{children}</div>;
437
+ export component DialogFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
438
+ const props = withProps(rest, { children });
439
+ if (render != null) {
440
+ return render(props);
441
+ }
442
+ return <div {...props} />;
354
443
  }
355
444
 
356
445
  /** A button that closes the dialog. */
357
- export component DialogClose(children: React.Node, ...rest: { readonly [string]: mixed }) {
446
+ export component DialogClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
358
447
  const dialog = useDialog("Dialog.Close");
359
- const passed = withoutComposed(rest, ["onClick"]);
360
-
361
- return (
362
- <button
363
- {...passed}
364
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(false))}
365
- type="button"
366
- >
367
- {children}
368
- </button>
369
- );
370
- }
448
+ const props = withProps(withoutComposed(rest, ["onClick"]), {
449
+ children,
450
+ onClick: composeHandlers(rest.onClick, () => dialog.setOpen(false)),
451
+ });
371
452
 
372
- /**
373
- * The focus stops inside an element, in document order.
374
- *
375
- * Disabled controls and `tabindex="-1"` are excluded because the browser
376
- * excludes them, and anything inside `[hidden]`, `[inert]` or `aria-hidden` is
377
- * excluded because a reader cannot reach it.
378
- */
379
- function focusable(root: HTMLElement): Array<HTMLElement> {
380
- const selector =
381
- 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
382
- return Array.from(root.querySelectorAll(selector)).filter(
383
- (element: $FlowFixMe) =>
384
- // All three attributes hide a whole subtree, so all three are checked on
385
- // the ancestors. Reading `aria-hidden` off the element alone returned a
386
- // button inside `<div aria-hidden="true">` as a focus stop, and the trap
387
- // then moved focus to a control no screen reader exposes.
388
- element.closest("[hidden]") == null &&
389
- element.closest("[inert]") == null &&
390
- element.closest('[aria-hidden="true"]') == null,
391
- );
453
+ if (render != null) {
454
+ return render(props);
455
+ }
456
+ return <button {...props} type="button" />;
392
457
  }
393
458
 
394
459
  /**
@@ -447,36 +512,3 @@ function concealOutside(element: HTMLElement): () => void {
447
512
  }
448
513
  };
449
514
  }
450
-
451
- /**
452
- * How many dialogs are holding the page still, and what it looked like before.
453
- *
454
- * A count rather than each dialog saving and restoring, because two dialogs
455
- * that open and close in any order but the strictest nesting would otherwise
456
- * hand the page back a value the other one had already replaced.
457
- */
458
- let scrollLocks = 0;
459
- let overflowBeforeLock: string = "";
460
-
461
- /** Stop the page behind the dialog from scrolling, and undo exactly that. */
462
- function lockScroll(document: Document): () => void {
463
- const body: $FlowFixMe = document.body;
464
- if (scrollLocks === 0) {
465
- overflowBeforeLock = body.style.overflow;
466
- body.style.overflow = "hidden";
467
- }
468
- scrollLocks += 1;
469
-
470
- let released = false;
471
- return () => {
472
- if (released) {
473
- return;
474
- }
475
- released = true;
476
- scrollLocks -= 1;
477
- if (scrollLocks === 0) {
478
- body.style.overflow = overflowBeforeLock;
479
- overflowBeforeLock = "";
480
- }
481
- };
482
- }