@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.
- package/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- package/visually-hidden.js +259 -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,13 +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
|
|
55
|
-
|
|
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";
|
|
56
90
|
import { focusable } from "./internal/focus.js";
|
|
57
91
|
import { useControlled } from "./internal/controlled-state.js";
|
|
58
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
|
+
|
|
59
106
|
type DialogState = {|
|
|
60
107
|
readonly base: string,
|
|
61
108
|
readonly open: boolean,
|
|
@@ -115,29 +162,36 @@ export component DialogRoot(
|
|
|
115
162
|
return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
|
|
116
163
|
}
|
|
117
164
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
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) {
|
|
120
174
|
const dialog = useDialog("Dialog.Trigger");
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
// React calls callback refs during commit; focus restoration reads it later.
|
|
186
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
187
|
+
dialog.triggerRef.current = element;
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (render != null) {
|
|
192
|
+
return render(props);
|
|
193
|
+
}
|
|
194
|
+
return <button {...props} type="button" />;
|
|
141
195
|
}
|
|
142
196
|
|
|
143
197
|
/**
|
|
@@ -149,12 +203,16 @@ export component DialogTrigger(children: React.Node, ...rest: Rest) {
|
|
|
149
203
|
* their own backdrop or omits one entirely must still get it. That lives on
|
|
150
204
|
* `Dialog.Body`, which is the part that knows where "outside" is.
|
|
151
205
|
*/
|
|
152
|
-
export component DialogOverlay(...rest: Rest) {
|
|
206
|
+
export component DialogOverlay(render?: RenderProp, ...rest: Rest) {
|
|
153
207
|
const dialog = useDialog("Dialog.Overlay");
|
|
154
208
|
if (!dialog.open) {
|
|
155
209
|
return null;
|
|
156
210
|
}
|
|
157
|
-
|
|
211
|
+
const props = withProps(rest, { "aria-hidden": "true", "data-state": "open" });
|
|
212
|
+
if (render != null) {
|
|
213
|
+
return render(props);
|
|
214
|
+
}
|
|
215
|
+
return <div {...props} />;
|
|
158
216
|
}
|
|
159
217
|
|
|
160
218
|
/**
|
|
@@ -164,7 +222,14 @@ export component DialogOverlay(...rest: Rest) {
|
|
|
164
222
|
* which is the half of "modal" that CSS cannot express; `inert` on everything
|
|
165
223
|
* outside is the half the browser enforces.
|
|
166
224
|
*/
|
|
167
|
-
export component DialogBody(
|
|
225
|
+
export component DialogBody(
|
|
226
|
+
children: React.Node,
|
|
227
|
+
dismissOnOutsidePress?: boolean = true,
|
|
228
|
+
initialFocus?: { current: HTMLElement | null },
|
|
229
|
+
role?: DialogRole = "dialog",
|
|
230
|
+
render?: RenderProp,
|
|
231
|
+
...rest: Rest
|
|
232
|
+
) {
|
|
168
233
|
const dialog = useDialog("Dialog.Body");
|
|
169
234
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
170
235
|
// Stable, so the effect below depends on `open` and on nothing else. Keyed on
|
|
@@ -173,6 +238,19 @@ export component DialogBody(children: React.Node, ...rest: Rest) {
|
|
|
173
238
|
// parent that re-rendered stole focus back from whatever the reader had
|
|
174
239
|
// moved it to inside the dialog.
|
|
175
240
|
const close = useStableCallback(() => dialog.setOpen(false));
|
|
241
|
+
// Asked at the moment of the press rather than named in the dependencies
|
|
242
|
+
// below, because naming it there re-runs the effect when it changes and
|
|
243
|
+
// re-running the effect re-takes focus. A caller who flips this on a
|
|
244
|
+
// breakpoint would otherwise have focus dragged back to the top of the
|
|
245
|
+
// dialog underneath the reader.
|
|
246
|
+
const dismissable = useStableCallback(() => dismissOnOutsidePress);
|
|
247
|
+
|
|
248
|
+
// The page is held still by `@uniflowed/hooks`' reference-counted lock rather
|
|
249
|
+
// than by a second one written here. Two implementations of this in one
|
|
250
|
+
// repository is the duplication "build uf with uf" exists to catch, and the
|
|
251
|
+
// shared one is the one that also pads out the scrollbar's width — a page
|
|
252
|
+
// that jumps sideways when a dialog opens is this component's doing.
|
|
253
|
+
useScrollLock(dialog.open);
|
|
176
254
|
|
|
177
255
|
useEffect(() => {
|
|
178
256
|
const body = bodyRef.current;
|
|
@@ -186,111 +264,114 @@ export component DialogBody(children: React.Node, ...rest: Rest) {
|
|
|
186
264
|
const opener = trigger ?? (document.activeElement as $FlowFixMe);
|
|
187
265
|
|
|
188
266
|
const restorePage = concealOutside(body);
|
|
189
|
-
const releaseScroll = lockScroll(document);
|
|
190
|
-
|
|
191
|
-
const onOutsidePress = (event: Event) => {
|
|
192
|
-
const target: $FlowFixMe = event.target;
|
|
193
|
-
if (target == null || body.contains(target)) {
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
// The trigger is outside the dialog and is not "outside" for this
|
|
197
|
-
// purpose: closing here and letting the trigger's own click reopen made
|
|
198
|
-
// a press on the trigger a no-op that flickered.
|
|
199
|
-
if (trigger != null && trigger.contains(target)) {
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
close();
|
|
203
|
-
};
|
|
204
|
-
// Capture, so a press is seen even where something below it stops the
|
|
205
|
-
// event — a menu inside the dialog, for instance.
|
|
206
|
-
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
207
267
|
|
|
208
|
-
//
|
|
209
|
-
// nothing focusable
|
|
210
|
-
|
|
268
|
+
// Where the caller said, then the first thing worth acting on, then the
|
|
269
|
+
// dialog itself when it holds nothing focusable — so focus is inside it
|
|
270
|
+
// whichever of the three answers.
|
|
271
|
+
//
|
|
272
|
+
// The named element has to still be *in* this dialog: a ref left over from
|
|
273
|
+
// a previous opening, or one pointing at something the caller renders
|
|
274
|
+
// elsewhere, would move focus out of a dialog that announces the rest of
|
|
275
|
+
// the page is unavailable.
|
|
276
|
+
const named = initialFocus?.current ?? null;
|
|
277
|
+
const target = named != null && body.contains(named) ? named : (focusable(body)[0] ?? body);
|
|
211
278
|
target.focus();
|
|
212
279
|
|
|
213
280
|
return () => {
|
|
214
|
-
document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
215
281
|
// Order matters: the page comes back before focus is restored, because
|
|
216
282
|
// the trigger is one of the elements that was made `inert` and an inert
|
|
217
283
|
// element cannot take focus.
|
|
218
284
|
restorePage();
|
|
219
|
-
releaseScroll();
|
|
220
285
|
opener?.focus?.();
|
|
221
286
|
};
|
|
222
|
-
}, [dialog.open, dialog.triggerRef,
|
|
287
|
+
}, [dialog.open, dialog.triggerRef, initialFocus]);
|
|
288
|
+
|
|
289
|
+
// A confirmation declines to close here, and `Escape` still does. See the
|
|
290
|
+
// module header: there is a difference between a dialog the reader dismissed
|
|
291
|
+
// and one that went away while they were reaching for it.
|
|
292
|
+
useInteractOutside({
|
|
293
|
+
isDisabled: !dialog.open,
|
|
294
|
+
onInteractOutside: () => {
|
|
295
|
+
if (dismissable()) {
|
|
296
|
+
close();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
refs: [bodyRef, dialog.triggerRef],
|
|
300
|
+
});
|
|
223
301
|
|
|
224
302
|
if (!dialog.open) {
|
|
225
303
|
return null;
|
|
226
304
|
}
|
|
227
305
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// outer dialog's handler and one Escape closed both.
|
|
251
|
-
event.stopPropagation();
|
|
252
|
-
close();
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
if (event.key !== "Tab") {
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
const body = bodyRef.current;
|
|
259
|
-
if (body == null) {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
const stops = focusable(body);
|
|
263
|
-
// An outer dialog must not also run its trap on this key.
|
|
306
|
+
// The caller's props first. A caller `ref` used to replace `bodyRef`, which
|
|
307
|
+
// left it null, made the Tab branch below return early, and turned the focus
|
|
308
|
+
// trap off while the dialog still announced `aria-modal="true"`. A caller
|
|
309
|
+
// `onKeyDown` used to replace this one, and Escape stopped closing it.
|
|
310
|
+
const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
|
|
311
|
+
// Only ids that are in the document: an `aria-labelledby` naming a
|
|
312
|
+
// missing element makes a screen reader announce nothing at all, so a
|
|
313
|
+
// dialog without a `Dialog.Title` falls through to whatever `aria-label`
|
|
314
|
+
// the caller passed instead.
|
|
315
|
+
"aria-describedby": dialog.described ? `${dialog.base}-description` : undefined,
|
|
316
|
+
"aria-labelledby": dialog.titled ? `${dialog.base}-title` : undefined,
|
|
317
|
+
"aria-modal": "true",
|
|
318
|
+
children,
|
|
319
|
+
id: `${dialog.base}-body`,
|
|
320
|
+
// Key handling closes the mounted dialog and restores focus after events.
|
|
321
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
322
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
323
|
+
if (event.key === "Escape") {
|
|
324
|
+
event.preventDefault();
|
|
325
|
+
// The dialog behind this one must not also close. Two stacked
|
|
326
|
+
// dialogs nest in the DOM, so without this the event bubbled to the
|
|
327
|
+
// outer dialog's handler and one Escape closed both.
|
|
264
328
|
event.stopPropagation();
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
{
|
|
292
|
-
|
|
293
|
-
|
|
329
|
+
close();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (event.key !== "Tab") {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const body = bodyRef.current;
|
|
336
|
+
if (body == null) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const stops = focusable(body);
|
|
340
|
+
// An outer dialog must not also run its trap on this key.
|
|
341
|
+
event.stopPropagation();
|
|
342
|
+
if (stops.length === 0) {
|
|
343
|
+
// Nothing to move to, so Tab must not leave either.
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const first = stops[0];
|
|
348
|
+
const last = stops[stops.length - 1];
|
|
349
|
+
const active = body.ownerDocument?.activeElement;
|
|
350
|
+
// Wrap at the ends. This is the whole of "focus cannot leave"; every
|
|
351
|
+
// other Tab press is the browser's own business.
|
|
352
|
+
if (event.shiftKey && (active === first || active === body)) {
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
last.focus();
|
|
355
|
+
} else if (!event.shiftKey && active === last) {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
first.focus();
|
|
358
|
+
}
|
|
359
|
+
}),
|
|
360
|
+
// React calls callback refs during commit; focus effects and traps read it later.
|
|
361
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
362
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
363
|
+
bodyRef.current = element;
|
|
364
|
+
}),
|
|
365
|
+
role,
|
|
366
|
+
// So the dialog can hold focus itself when it contains nothing focusable,
|
|
367
|
+
// and so the trap has somewhere to put focus that is still inside.
|
|
368
|
+
tabIndex: -1,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (render != null) {
|
|
372
|
+
return render(props);
|
|
373
|
+
}
|
|
374
|
+
return <div {...props} />;
|
|
294
375
|
}
|
|
295
376
|
|
|
296
377
|
/**
|
|
@@ -300,7 +381,7 @@ export component DialogBody(children: React.Node, ...rest: Rest) {
|
|
|
300
381
|
* rendered — a conditional title that is absent used to leave the dialog
|
|
301
382
|
* pointing at an id nothing had.
|
|
302
383
|
*/
|
|
303
|
-
export component DialogTitle(children: React.Node, ...rest: Rest) {
|
|
384
|
+
export component DialogTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
304
385
|
const dialog = useDialog("Dialog.Title");
|
|
305
386
|
const register = dialog.registerTitle;
|
|
306
387
|
useEffect(() => {
|
|
@@ -308,11 +389,15 @@ export component DialogTitle(children: React.Node, ...rest: Rest) {
|
|
|
308
389
|
return () => register(false);
|
|
309
390
|
}, [register]);
|
|
310
391
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
392
|
+
const props = withProps(rest, { children, id: `${dialog.base}-title` });
|
|
393
|
+
// `<h2>` is a default rather than a decision. Which heading level a dialog's
|
|
394
|
+
// name is depends on what is around it — ubugeeei-prod/uf#276 is the same
|
|
395
|
+
// observation about an accordion — and `render` is how a caller says so
|
|
396
|
+
// without losing the id `aria-labelledby` points at.
|
|
397
|
+
if (render != null) {
|
|
398
|
+
return render(props);
|
|
399
|
+
}
|
|
400
|
+
return <h2 {...props} />;
|
|
316
401
|
}
|
|
317
402
|
|
|
318
403
|
/**
|
|
@@ -322,7 +407,7 @@ export component DialogTitle(children: React.Node, ...rest: Rest) {
|
|
|
322
407
|
* the one moment the reader has to decide whether they care — so this is where
|
|
323
408
|
* "this cannot be undone" belongs, not in body text further down.
|
|
324
409
|
*/
|
|
325
|
-
export component DialogDescription(children: React.Node, ...rest: Rest) {
|
|
410
|
+
export component DialogDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
326
411
|
const dialog = useDialog("Dialog.Description");
|
|
327
412
|
const register = dialog.registerDescription;
|
|
328
413
|
useEffect(() => {
|
|
@@ -330,11 +415,11 @@ export component DialogDescription(children: React.Node, ...rest: Rest) {
|
|
|
330
415
|
return () => register(false);
|
|
331
416
|
}, [register]);
|
|
332
417
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
418
|
+
const props = withProps(rest, { children, id: `${dialog.base}-description` });
|
|
419
|
+
if (render != null) {
|
|
420
|
+
return render(props);
|
|
421
|
+
}
|
|
422
|
+
return <p {...props} />;
|
|
338
423
|
}
|
|
339
424
|
|
|
340
425
|
/**
|
|
@@ -346,29 +431,35 @@ export component DialogDescription(children: React.Node, ...rest: Rest) {
|
|
|
346
431
|
* styling layer has a name to attach to, and contributes no semantics because
|
|
347
432
|
* it has none to contribute.
|
|
348
433
|
*/
|
|
349
|
-
export component DialogHeader(children: React.Node, ...rest: Rest) {
|
|
350
|
-
|
|
434
|
+
export component DialogHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
435
|
+
const props = withProps(rest, { children });
|
|
436
|
+
if (render != null) {
|
|
437
|
+
return render(props);
|
|
438
|
+
}
|
|
439
|
+
return <div {...props} />;
|
|
351
440
|
}
|
|
352
441
|
|
|
353
442
|
/** The bottom of the dialog, where the actions go. See `Dialog.Header`. */
|
|
354
|
-
export component DialogFooter(children: React.Node, ...rest: Rest) {
|
|
355
|
-
|
|
443
|
+
export component DialogFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
444
|
+
const props = withProps(rest, { children });
|
|
445
|
+
if (render != null) {
|
|
446
|
+
return render(props);
|
|
447
|
+
}
|
|
448
|
+
return <div {...props} />;
|
|
356
449
|
}
|
|
357
450
|
|
|
358
451
|
/** A button that closes the dialog. */
|
|
359
|
-
export component DialogClose(children: React.Node, ...rest: Rest) {
|
|
452
|
+
export component DialogClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
360
453
|
const dialog = useDialog("Dialog.Close");
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
</button>
|
|
371
|
-
);
|
|
454
|
+
const props = withProps(withoutComposed(rest, ["onClick"]), {
|
|
455
|
+
children,
|
|
456
|
+
onClick: composeHandlers(rest.onClick, () => dialog.setOpen(false)),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (render != null) {
|
|
460
|
+
return render(props);
|
|
461
|
+
}
|
|
462
|
+
return <button {...props} type="button" />;
|
|
372
463
|
}
|
|
373
464
|
|
|
374
465
|
/**
|
|
@@ -380,6 +471,11 @@ export component DialogClose(children: React.Node, ...rest: Rest) {
|
|
|
380
471
|
* hiding body's children would hide nothing new and the outer dialog's own
|
|
381
472
|
* content would stay readable behind the inner one.
|
|
382
473
|
*
|
|
474
|
+
* The one sibling left alone is the live announcer `visually-hidden.js`
|
|
475
|
+
* creates, which lives in `<body>` and would otherwise go silent for as long as
|
|
476
|
+
* any dialog is open. React Aria's `ariaHideOutside` keeps its announcer for the
|
|
477
|
+
* same reason.
|
|
478
|
+
*
|
|
383
479
|
* Both attributes, because they address different audiences. `aria-hidden`
|
|
384
480
|
* removes the subtree from the accessibility tree; `inert` also stops clicks
|
|
385
481
|
* and takes it out of the tab order, which is the browser's own enforcement of
|
|
@@ -397,7 +493,9 @@ function concealOutside(element: HTMLElement): () => void {
|
|
|
397
493
|
break;
|
|
398
494
|
}
|
|
399
495
|
for (const sibling of Array.from(parent.children)) {
|
|
400
|
-
|
|
496
|
+
// `announce()`'s regions stay readable: a message about what the dialog
|
|
497
|
+
// just did ("Saved", "3 results") is exactly what a modal needs to say.
|
|
498
|
+
if (sibling === node || sibling.hasAttribute("data-uf-live-announcer")) {
|
|
401
499
|
continue;
|
|
402
500
|
}
|
|
403
501
|
restore.push({
|
|
@@ -427,36 +525,3 @@ function concealOutside(element: HTMLElement): () => void {
|
|
|
427
525
|
}
|
|
428
526
|
};
|
|
429
527
|
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* How many dialogs are holding the page still, and what it looked like before.
|
|
433
|
-
*
|
|
434
|
-
* A count rather than each dialog saving and restoring, because two dialogs
|
|
435
|
-
* that open and close in any order but the strictest nesting would otherwise
|
|
436
|
-
* hand the page back a value the other one had already replaced.
|
|
437
|
-
*/
|
|
438
|
-
let scrollLocks = 0;
|
|
439
|
-
let overflowBeforeLock: string = "";
|
|
440
|
-
|
|
441
|
-
/** Stop the page behind the dialog from scrolling, and undo exactly that. */
|
|
442
|
-
function lockScroll(document: Document): () => void {
|
|
443
|
-
const body: $FlowFixMe = document.body;
|
|
444
|
-
if (scrollLocks === 0) {
|
|
445
|
-
overflowBeforeLock = body.style.overflow;
|
|
446
|
-
body.style.overflow = "hidden";
|
|
447
|
-
}
|
|
448
|
-
scrollLocks += 1;
|
|
449
|
-
|
|
450
|
-
let released = false;
|
|
451
|
-
return () => {
|
|
452
|
-
if (released) {
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
released = true;
|
|
456
|
-
scrollLocks -= 1;
|
|
457
|
-
if (scrollLocks === 0) {
|
|
458
|
-
body.style.overflow = overflowBeforeLock;
|
|
459
|
-
overflowBeforeLock = "";
|
|
460
|
-
}
|
|
461
|
-
};
|
|
462
|
-
}
|