@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/accordion.js +61 -54
- package/alert-dialog.js +43 -17
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/checkbox.js +45 -39
- package/collapsible.js +41 -36
- package/context-menu.js +32 -24
- package/dialog.js +143 -119
- package/drawer.js +121 -89
- package/field.js +71 -37
- package/hover-card.js +23 -27
- package/index.js +567 -31
- package/input-otp.js +8 -8
- package/interactions.js +2163 -0
- package/internal/merge-props.js +85 -1
- package/menu.js +236 -213
- package/menubar.js +91 -89
- package/package.json +8 -40
- package/pagination.js +29 -17
- package/popover.js +59 -55
- package/progress.js +21 -16
- package/radio-group.js +79 -75
- package/separator.js +97 -0
- package/sheet.js +41 -17
- package/sidebar.js +10 -10
- package/skeleton.js +159 -0
- package/slider.js +89 -89
- package/switch.js +37 -31
- package/table.js +90 -67
- package/tabs.js +100 -91
- package/toast.js +23 -55
- package/toggle-group.js +52 -49
- package/toggle.js +38 -25
- package/tooltip.js +26 -37
package/context-menu.js
CHANGED
|
@@ -58,8 +58,13 @@ import { useCallback, useContext, useMemo, useRef, useState } from "@uniflowed/r
|
|
|
58
58
|
import { useLongPress } from "@uniflowed/hooks/dom";
|
|
59
59
|
|
|
60
60
|
import type { Rect } from "./internal/anchor.js";
|
|
61
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
62
|
-
import {
|
|
61
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
62
|
+
import {
|
|
63
|
+
composeHandlers,
|
|
64
|
+
composeRefs,
|
|
65
|
+
withProps,
|
|
66
|
+
withoutComposed,
|
|
67
|
+
} from "./internal/merge-props.js";
|
|
63
68
|
import { MenuAnchorContext, MenuContext, MenuLevel, useMenu } from "./internal/menu-tree.js";
|
|
64
69
|
|
|
65
70
|
/**
|
|
@@ -126,7 +131,7 @@ hook usePoint(part: string): PointState {
|
|
|
126
131
|
* region of the page. The module header says why it is in the tab order and
|
|
127
132
|
* when a caller should take it out again.
|
|
128
133
|
*/
|
|
129
|
-
export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
|
|
134
|
+
export component ContextMenuTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
130
135
|
const menu = useMenu("ContextMenu.Trigger");
|
|
131
136
|
const { openAt } = usePoint("ContextMenu.Trigger");
|
|
132
137
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
@@ -149,19 +154,19 @@ export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
|
|
|
149
154
|
menu.setOpen(true);
|
|
150
155
|
});
|
|
151
156
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
id
|
|
164
|
-
onContextMenu
|
|
157
|
+
const props = withProps(
|
|
158
|
+
// The `tabIndex` goes *underneath* the caller's props, alone, because it is
|
|
159
|
+
// the one attribute here a caller is invited to overrule: the module header
|
|
160
|
+
// promises `tabIndex={-1}` to a caller whose trigger already contains
|
|
161
|
+
// something focusable, and a value that won over the caller's would be a
|
|
162
|
+
// documented escape hatch that does nothing. Everything in the second
|
|
163
|
+
// argument is this component's own and stays on top.
|
|
164
|
+
withProps({ tabIndex: 0 }, passed),
|
|
165
|
+
{
|
|
166
|
+
"aria-haspopup": "menu",
|
|
167
|
+
children,
|
|
168
|
+
id: `${menu.base}-trigger`,
|
|
169
|
+
onContextMenu: composeHandlers(rest.onContextMenu, (event: PartEvent) => {
|
|
165
170
|
const press: $FlowFixMe = event;
|
|
166
171
|
// The browser's own menu would otherwise cover this one, and the reader
|
|
167
172
|
// would be looking at the platform's Back/Reload rather than at the
|
|
@@ -170,8 +175,8 @@ export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
|
|
|
170
175
|
openAt(pointAt(press.clientX ?? 0, press.clientY ?? 0));
|
|
171
176
|
menu.pendingFocus.current = "first";
|
|
172
177
|
menu.setOpen(true);
|
|
173
|
-
})
|
|
174
|
-
onKeyDown
|
|
178
|
+
}),
|
|
179
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
175
180
|
// Both spellings. `ContextMenu` is the dedicated key on a PC keyboard;
|
|
176
181
|
// `Shift+F10` is the one every platform has, and is what a laptop
|
|
177
182
|
// without that key leaves a reader with.
|
|
@@ -182,17 +187,20 @@ export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
|
|
|
182
187
|
}
|
|
183
188
|
event.preventDefault();
|
|
184
189
|
openHere();
|
|
185
|
-
})
|
|
186
|
-
ref
|
|
190
|
+
}),
|
|
191
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
187
192
|
triggerRef.current = element;
|
|
188
193
|
// What focus goes back to when the menu closes. It is deliberately not
|
|
189
194
|
// registered as the menu's *name*; see the module header.
|
|
190
195
|
menu.triggerRef.current = element;
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
{children}
|
|
194
|
-
</div>
|
|
196
|
+
}),
|
|
197
|
+
},
|
|
195
198
|
);
|
|
199
|
+
|
|
200
|
+
if (render != null) {
|
|
201
|
+
return render(props);
|
|
202
|
+
}
|
|
203
|
+
return <div {...props} />;
|
|
196
204
|
}
|
|
197
205
|
|
|
198
206
|
export type { MenuSelect } from "./menu.js";
|
package/dialog.js
CHANGED
|
@@ -78,8 +78,13 @@ import {
|
|
|
78
78
|
import { useScrollLock } from "@uniflowed/hooks/browser";
|
|
79
79
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
80
80
|
|
|
81
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
82
|
-
import {
|
|
81
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
82
|
+
import {
|
|
83
|
+
composeHandlers,
|
|
84
|
+
composeRefs,
|
|
85
|
+
withProps,
|
|
86
|
+
withoutComposed,
|
|
87
|
+
} from "./internal/merge-props.js";
|
|
83
88
|
import { focusable } from "./internal/focus.js";
|
|
84
89
|
import { useControlled } from "./internal/controlled-state.js";
|
|
85
90
|
|
|
@@ -155,29 +160,34 @@ export component DialogRoot(
|
|
|
155
160
|
return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
163
|
+
/**
|
|
164
|
+
* What opens the dialog, and what focus comes back to when it closes.
|
|
165
|
+
*
|
|
166
|
+
* `render` for a trigger that is not a `<button>` — a card, a table row, an
|
|
167
|
+
* icon in somebody else's `<Pressable>`. The ref goes across with everything
|
|
168
|
+
* else, which is what keeps "focus comes back here" true of whatever the
|
|
169
|
+
* caller rendered.
|
|
170
|
+
*/
|
|
171
|
+
export component DialogTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
160
172
|
const dialog = useDialog("Dialog.Trigger");
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
</button>
|
|
180
|
-
);
|
|
173
|
+
const props = withProps(withoutComposed(rest, ["onClick", "ref"]), {
|
|
174
|
+
// Only while it is open. An `aria-controls` naming an element that is not
|
|
175
|
+
// in the document is worse than no `aria-controls`: a reader is told
|
|
176
|
+
// there is somewhere to go and there is not.
|
|
177
|
+
"aria-controls": dialog.open ? `${dialog.base}-body` : undefined,
|
|
178
|
+
"aria-expanded": dialog.open ? "true" : "false",
|
|
179
|
+
"aria-haspopup": "dialog",
|
|
180
|
+
children,
|
|
181
|
+
onClick: composeHandlers(rest.onClick, () => dialog.setOpen(true)),
|
|
182
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
183
|
+
dialog.triggerRef.current = element;
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (render != null) {
|
|
188
|
+
return render(props);
|
|
189
|
+
}
|
|
190
|
+
return <button {...props} type="button" />;
|
|
181
191
|
}
|
|
182
192
|
|
|
183
193
|
/**
|
|
@@ -189,12 +199,16 @@ export component DialogTrigger(children: React.Node, ...rest: Rest) {
|
|
|
189
199
|
* their own backdrop or omits one entirely must still get it. That lives on
|
|
190
200
|
* `Dialog.Body`, which is the part that knows where "outside" is.
|
|
191
201
|
*/
|
|
192
|
-
export component DialogOverlay(...rest: Rest) {
|
|
202
|
+
export component DialogOverlay(render?: RenderProp, ...rest: Rest) {
|
|
193
203
|
const dialog = useDialog("Dialog.Overlay");
|
|
194
204
|
if (!dialog.open) {
|
|
195
205
|
return null;
|
|
196
206
|
}
|
|
197
|
-
|
|
207
|
+
const props = withProps(rest, { "aria-hidden": "true", "data-state": "open" });
|
|
208
|
+
if (render != null) {
|
|
209
|
+
return render(props);
|
|
210
|
+
}
|
|
211
|
+
return <div {...props} />;
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
/**
|
|
@@ -209,6 +223,7 @@ export component DialogBody(
|
|
|
209
223
|
dismissOnOutsidePress?: boolean = true,
|
|
210
224
|
initialFocus?: { current: HTMLElement | null },
|
|
211
225
|
role?: DialogRole = "dialog",
|
|
226
|
+
render?: RenderProp,
|
|
212
227
|
...rest: Rest
|
|
213
228
|
) {
|
|
214
229
|
const dialog = useDialog("Dialog.Body");
|
|
@@ -295,72 +310,71 @@ export component DialogBody(
|
|
|
295
310
|
return null;
|
|
296
311
|
}
|
|
297
312
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// The dialog behind this one must not also close. Two stacked
|
|
319
|
-
// dialogs nest in the DOM, so without this the event bubbled to the
|
|
320
|
-
// outer dialog's handler and one Escape closed both.
|
|
321
|
-
event.stopPropagation();
|
|
322
|
-
close();
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
if (event.key !== "Tab") {
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const body = bodyRef.current;
|
|
329
|
-
if (body == null) {
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
const stops = focusable(body);
|
|
333
|
-
// An outer dialog must not also run its trap on this key.
|
|
313
|
+
// The caller's props first. A caller `ref` used to replace `bodyRef`, which
|
|
314
|
+
// left it null, made the Tab branch below return early, and turned the focus
|
|
315
|
+
// trap off while the dialog still announced `aria-modal="true"`. A caller
|
|
316
|
+
// `onKeyDown` used to replace this one, and Escape stopped closing it.
|
|
317
|
+
const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
|
|
318
|
+
// Only ids that are in the document: an `aria-labelledby` naming a
|
|
319
|
+
// missing element makes a screen reader announce nothing at all, so a
|
|
320
|
+
// dialog without a `Dialog.Title` falls through to whatever `aria-label`
|
|
321
|
+
// the caller passed instead.
|
|
322
|
+
"aria-describedby": dialog.described ? `${dialog.base}-description` : undefined,
|
|
323
|
+
"aria-labelledby": dialog.titled ? `${dialog.base}-title` : undefined,
|
|
324
|
+
"aria-modal": "true",
|
|
325
|
+
children,
|
|
326
|
+
id: `${dialog.base}-body`,
|
|
327
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
328
|
+
if (event.key === "Escape") {
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
// The dialog behind this one must not also close. Two stacked
|
|
331
|
+
// dialogs nest in the DOM, so without this the event bubbled to the
|
|
332
|
+
// outer dialog's handler and one Escape closed both.
|
|
334
333
|
event.stopPropagation();
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{
|
|
362
|
-
|
|
363
|
-
|
|
334
|
+
close();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (event.key !== "Tab") {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const body = bodyRef.current;
|
|
341
|
+
if (body == null) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const stops = focusable(body);
|
|
345
|
+
// An outer dialog must not also run its trap on this key.
|
|
346
|
+
event.stopPropagation();
|
|
347
|
+
if (stops.length === 0) {
|
|
348
|
+
// Nothing to move to, so Tab must not leave either.
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const first = stops[0];
|
|
353
|
+
const last = stops[stops.length - 1];
|
|
354
|
+
const active = body.ownerDocument?.activeElement;
|
|
355
|
+
// Wrap at the ends. This is the whole of "focus cannot leave"; every
|
|
356
|
+
// other Tab press is the browser's own business.
|
|
357
|
+
if (event.shiftKey && (active === first || active === body)) {
|
|
358
|
+
event.preventDefault();
|
|
359
|
+
last.focus();
|
|
360
|
+
} else if (!event.shiftKey && active === last) {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
first.focus();
|
|
363
|
+
}
|
|
364
|
+
}),
|
|
365
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
366
|
+
bodyRef.current = element;
|
|
367
|
+
}),
|
|
368
|
+
role,
|
|
369
|
+
// So the dialog can hold focus itself when it contains nothing focusable,
|
|
370
|
+
// and so the trap has somewhere to put focus that is still inside.
|
|
371
|
+
tabIndex: -1,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (render != null) {
|
|
375
|
+
return render(props);
|
|
376
|
+
}
|
|
377
|
+
return <div {...props} />;
|
|
364
378
|
}
|
|
365
379
|
|
|
366
380
|
/**
|
|
@@ -370,7 +384,7 @@ export component DialogBody(
|
|
|
370
384
|
* rendered — a conditional title that is absent used to leave the dialog
|
|
371
385
|
* pointing at an id nothing had.
|
|
372
386
|
*/
|
|
373
|
-
export component DialogTitle(children: React.Node, ...rest: Rest) {
|
|
387
|
+
export component DialogTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
374
388
|
const dialog = useDialog("Dialog.Title");
|
|
375
389
|
const register = dialog.registerTitle;
|
|
376
390
|
useEffect(() => {
|
|
@@ -378,11 +392,15 @@ export component DialogTitle(children: React.Node, ...rest: Rest) {
|
|
|
378
392
|
return () => register(false);
|
|
379
393
|
}, [register]);
|
|
380
394
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
395
|
+
const props = withProps(rest, { children, id: `${dialog.base}-title` });
|
|
396
|
+
// `<h2>` is a default rather than a decision. Which heading level a dialog's
|
|
397
|
+
// name is depends on what is around it — ubugeeei-prod/uf#276 is the same
|
|
398
|
+
// observation about an accordion — and `render` is how a caller says so
|
|
399
|
+
// without losing the id `aria-labelledby` points at.
|
|
400
|
+
if (render != null) {
|
|
401
|
+
return render(props);
|
|
402
|
+
}
|
|
403
|
+
return <h2 {...props} />;
|
|
386
404
|
}
|
|
387
405
|
|
|
388
406
|
/**
|
|
@@ -392,7 +410,7 @@ export component DialogTitle(children: React.Node, ...rest: Rest) {
|
|
|
392
410
|
* the one moment the reader has to decide whether they care — so this is where
|
|
393
411
|
* "this cannot be undone" belongs, not in body text further down.
|
|
394
412
|
*/
|
|
395
|
-
export component DialogDescription(children: React.Node, ...rest: Rest) {
|
|
413
|
+
export component DialogDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
396
414
|
const dialog = useDialog("Dialog.Description");
|
|
397
415
|
const register = dialog.registerDescription;
|
|
398
416
|
useEffect(() => {
|
|
@@ -400,11 +418,11 @@ export component DialogDescription(children: React.Node, ...rest: Rest) {
|
|
|
400
418
|
return () => register(false);
|
|
401
419
|
}, [register]);
|
|
402
420
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
421
|
+
const props = withProps(rest, { children, id: `${dialog.base}-description` });
|
|
422
|
+
if (render != null) {
|
|
423
|
+
return render(props);
|
|
424
|
+
}
|
|
425
|
+
return <p {...props} />;
|
|
408
426
|
}
|
|
409
427
|
|
|
410
428
|
/**
|
|
@@ -416,29 +434,35 @@ export component DialogDescription(children: React.Node, ...rest: Rest) {
|
|
|
416
434
|
* styling layer has a name to attach to, and contributes no semantics because
|
|
417
435
|
* it has none to contribute.
|
|
418
436
|
*/
|
|
419
|
-
export component DialogHeader(children: React.Node, ...rest: Rest) {
|
|
420
|
-
|
|
437
|
+
export component DialogHeader(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} />;
|
|
421
443
|
}
|
|
422
444
|
|
|
423
445
|
/** The bottom of the dialog, where the actions go. See `Dialog.Header`. */
|
|
424
|
-
export component DialogFooter(children: React.Node, ...rest: Rest) {
|
|
425
|
-
|
|
446
|
+
export component DialogFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
447
|
+
const props = withProps(rest, { children });
|
|
448
|
+
if (render != null) {
|
|
449
|
+
return render(props);
|
|
450
|
+
}
|
|
451
|
+
return <div {...props} />;
|
|
426
452
|
}
|
|
427
453
|
|
|
428
454
|
/** A button that closes the dialog. */
|
|
429
|
-
export component DialogClose(children: React.Node, ...rest: Rest) {
|
|
455
|
+
export component DialogClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
430
456
|
const dialog = useDialog("Dialog.Close");
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
</button>
|
|
441
|
-
);
|
|
457
|
+
const props = withProps(withoutComposed(rest, ["onClick"]), {
|
|
458
|
+
children,
|
|
459
|
+
onClick: composeHandlers(rest.onClick, () => dialog.setOpen(false)),
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
if (render != null) {
|
|
463
|
+
return render(props);
|
|
464
|
+
}
|
|
465
|
+
return <button {...props} type="button" />;
|
|
442
466
|
}
|
|
443
467
|
|
|
444
468
|
/**
|