@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/select.js
CHANGED
|
@@ -146,6 +146,10 @@ import {
|
|
|
146
146
|
} from "@uniflowed/react";
|
|
147
147
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
148
148
|
|
|
149
|
+
import { useInteractOutside } from "./interactions.js";
|
|
150
|
+
|
|
151
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
152
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
149
153
|
import type { Rest } from "./internal/merge-props.js";
|
|
150
154
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
151
155
|
import type { Movement } from "./internal/roving-focus.js";
|
|
@@ -153,6 +157,8 @@ import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving
|
|
|
153
157
|
import { useControlled } from "./internal/controlled-state.js";
|
|
154
158
|
import { FormValue } from "./internal/form-value.js";
|
|
155
159
|
|
|
160
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
161
|
+
|
|
156
162
|
const OPTION_SELECTOR = '[role="option"]';
|
|
157
163
|
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
158
164
|
|
|
@@ -186,7 +192,7 @@ type SelectState = {|
|
|
|
186
192
|
/** The id of the option `aria-activedescendant` names, if any. */
|
|
187
193
|
readonly activeId: string | null,
|
|
188
194
|
readonly setActiveId: (id: string | null) => void,
|
|
189
|
-
readonly
|
|
195
|
+
readonly pendingLandingRef: { current: Landing | null },
|
|
190
196
|
readonly triggerRef: { current: HTMLElement | null },
|
|
191
197
|
readonly listRef: { current: HTMLElement | null },
|
|
192
198
|
/**
|
|
@@ -264,7 +270,7 @@ export component SelectRoot(
|
|
|
264
270
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
265
271
|
const [labels, setLabels] = useState<{ readonly [string]: string }>({});
|
|
266
272
|
const [labelled, setLabelled] = useState(false);
|
|
267
|
-
const
|
|
273
|
+
const pendingLandingRef = useRef<Landing | null>(null);
|
|
268
274
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
269
275
|
const listRef = useRef<HTMLElement | null>(null);
|
|
270
276
|
const typeahead = useTypeahead();
|
|
@@ -307,7 +313,7 @@ export component SelectRoot(
|
|
|
307
313
|
choose,
|
|
308
314
|
activeId,
|
|
309
315
|
setActiveId,
|
|
310
|
-
|
|
316
|
+
pendingLandingRef,
|
|
311
317
|
triggerRef,
|
|
312
318
|
listRef,
|
|
313
319
|
labels,
|
|
@@ -409,7 +415,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
409
415
|
/** Move the cursor within an open list, or open with an instruction. */
|
|
410
416
|
const move = (end: Movement, preferSelected: boolean) => {
|
|
411
417
|
if (!select.open) {
|
|
412
|
-
|
|
418
|
+
// This is an instruction for the list after the opening commit.
|
|
419
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
420
|
+
select.pendingLandingRef.current = { kind: "end", end, preferSelected };
|
|
413
421
|
select.setOpen(true);
|
|
414
422
|
return;
|
|
415
423
|
}
|
|
@@ -452,7 +460,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
452
460
|
select.setActiveId(null);
|
|
453
461
|
return;
|
|
454
462
|
}
|
|
455
|
-
|
|
463
|
+
// This is an instruction for the list after the opening commit.
|
|
464
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
465
|
+
select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
|
|
456
466
|
select.setOpen(true);
|
|
457
467
|
})}
|
|
458
468
|
onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
|
|
@@ -494,7 +504,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
494
504
|
// again as a click.
|
|
495
505
|
event.preventDefault();
|
|
496
506
|
if (!select.open) {
|
|
497
|
-
|
|
507
|
+
// This is an instruction for the list after the opening commit.
|
|
508
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
509
|
+
select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
|
|
498
510
|
select.setOpen(true);
|
|
499
511
|
return;
|
|
500
512
|
}
|
|
@@ -538,7 +550,8 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
538
550
|
event.preventDefault();
|
|
539
551
|
// The options are not in the document yet, so the keystroke travels
|
|
540
552
|
// to the commit that renders them.
|
|
541
|
-
|
|
553
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
554
|
+
select.pendingLandingRef.current = { kind: "typed", key: event.key };
|
|
542
555
|
select.setOpen(true);
|
|
543
556
|
return;
|
|
544
557
|
}
|
|
@@ -554,6 +567,8 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
|
554
567
|
}
|
|
555
568
|
})}
|
|
556
569
|
ref={composeRefs(rest.ref, (element) => {
|
|
570
|
+
// React calls callback refs during commit; the list reads the trigger later.
|
|
571
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
557
572
|
select.triggerRef.current = element;
|
|
558
573
|
})}
|
|
559
574
|
role="combobox"
|
|
@@ -613,20 +628,46 @@ export component SelectValue(children?: React.Node, placeholder?: React.Node, ..
|
|
|
613
628
|
*/
|
|
614
629
|
export component SelectList(
|
|
615
630
|
children: renders* (SelectOption | SelectGroup | SelectSeparator),
|
|
631
|
+
align?: Align = "start",
|
|
632
|
+
alignOffset?: number = 0,
|
|
633
|
+
avoidCollisions?: boolean = true,
|
|
634
|
+
collisionPadding?: number = 0,
|
|
635
|
+
side?: LogicalSide = "bottom",
|
|
636
|
+
sideOffset?: number = 0,
|
|
616
637
|
...rest: Rest
|
|
617
638
|
) {
|
|
618
639
|
const select = useSelect("Select.List");
|
|
619
|
-
const { activeId, listRef,
|
|
640
|
+
const { activeId, listRef, pendingLandingRef, setActiveId, triggerRef, typeahead, value } =
|
|
641
|
+
select;
|
|
620
642
|
const close = useStableCallback(() => {
|
|
621
643
|
select.setOpen(false);
|
|
622
644
|
select.setActiveId(null);
|
|
623
645
|
});
|
|
624
646
|
|
|
647
|
+
// The popup a select opens is the one case where the trigger's *width* is
|
|
648
|
+
// part of the design rather than a detail: a list narrower than the button it
|
|
649
|
+
// came out of reads as a different control. `--uf-anchor-trigger-width` is
|
|
650
|
+
// written on this element for a stylesheet to use, which is why the
|
|
651
|
+
// measurement is here and not in the caller.
|
|
652
|
+
const anchored = useAnchor({
|
|
653
|
+
align,
|
|
654
|
+
alignOffset,
|
|
655
|
+
anchorRef: triggerRef,
|
|
656
|
+
avoidCollisions,
|
|
657
|
+
collisionPadding,
|
|
658
|
+
open: select.open,
|
|
659
|
+
overlayRef: listRef,
|
|
660
|
+
side,
|
|
661
|
+
sideOffset,
|
|
662
|
+
});
|
|
663
|
+
|
|
625
664
|
// No dependency list, for the reason `combobox.js` gives: what this reads is
|
|
626
665
|
// the *rendered* options, and a caller may render different ones on any
|
|
627
666
|
// render — a change to `children` that no dependency list can describe. Every
|
|
628
667
|
// write is guarded by a comparison, so it settles after one extra pass rather
|
|
629
668
|
// than looping.
|
|
669
|
+
// This effect measures caller-rendered options after commit.
|
|
670
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
630
671
|
useEffect(() => {
|
|
631
672
|
const list = listRef.current;
|
|
632
673
|
if (list == null) {
|
|
@@ -634,9 +675,10 @@ export component SelectList(
|
|
|
634
675
|
}
|
|
635
676
|
const items = itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
|
|
636
677
|
|
|
637
|
-
const wanted =
|
|
678
|
+
const wanted = pendingLandingRef.current;
|
|
638
679
|
if (wanted != null) {
|
|
639
|
-
|
|
680
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
681
|
+
pendingLandingRef.current = null;
|
|
640
682
|
// An `if` rather than a `match` on `wanted.kind`, because matching on a
|
|
641
683
|
// property does not refine the object that property came from: inside
|
|
642
684
|
// `match (wanted.kind)` both arms still see the whole union, and `uf
|
|
@@ -664,32 +706,16 @@ export component SelectList(
|
|
|
664
706
|
}
|
|
665
707
|
});
|
|
666
708
|
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
const onOutsidePress = (event: Event) => {
|
|
678
|
-
const target: $FlowFixMe = event.target;
|
|
679
|
-
if (target == null || list.contains(target)) {
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
// The trigger is not "outside": closing here and letting its own click
|
|
683
|
-
// reopen the list makes a press on the trigger a no-op that flickers.
|
|
684
|
-
const trigger = triggerRef.current;
|
|
685
|
-
if (trigger != null && trigger.contains(target)) {
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
close();
|
|
689
|
-
};
|
|
690
|
-
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
691
|
-
return () => document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
692
|
-
}, [select.open, close, listRef, triggerRef]);
|
|
709
|
+
// The refs are read when a press arrives rather than when the listener is
|
|
710
|
+
// attached, which is what makes `select.open` the only thing this depends on:
|
|
711
|
+
// this component is mounted the whole time and only *renders* while the list
|
|
712
|
+
// is open, and a listener attached on the commit where `listRef.current` was
|
|
713
|
+
// still null used to be one that never worked.
|
|
714
|
+
useInteractOutside({
|
|
715
|
+
isDisabled: !select.open,
|
|
716
|
+
onInteractOutside: () => close(),
|
|
717
|
+
refs: [listRef, triggerRef],
|
|
718
|
+
});
|
|
693
719
|
|
|
694
720
|
if (!select.open) {
|
|
695
721
|
return null;
|
|
@@ -701,6 +727,8 @@ export component SelectList(
|
|
|
701
727
|
<div
|
|
702
728
|
{...passed}
|
|
703
729
|
aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
|
|
730
|
+
data-align={anchored.align}
|
|
731
|
+
data-side={anchored.side}
|
|
704
732
|
id={`${select.base}-list`}
|
|
705
733
|
ref={composeRefs(rest.ref, (element) => {
|
|
706
734
|
listRef.current = element;
|
|
@@ -793,8 +821,26 @@ export component SelectOption(
|
|
|
793
821
|
* `Select.GroupLabel` is rendered — the same rule, and the same reason, as
|
|
794
822
|
* `Menu.Group`. The arrow keys pass over the label without stopping on it,
|
|
795
823
|
* because they only ever look for `role="option"`.
|
|
824
|
+
*
|
|
825
|
+
* `children` is `renders* (SelectOption | SelectGroupLabel)`, which is what a
|
|
826
|
+
* `group` inside a `listbox` may hold: options, and the heading that names
|
|
827
|
+
* them. It took `React.Node` until ubugeeei-prod/uf#562, so a `<div>` in a
|
|
828
|
+
* group was a runtime surprise — an element with no role between two options,
|
|
829
|
+
* which the arrow keys walk straight past and a screen reader reads as a stray
|
|
830
|
+
* line — rather than a type error. `Combobox.Group` has stated the constraint
|
|
831
|
+
* since #558 and this is the same listbox.
|
|
832
|
+
*
|
|
833
|
+
* No `Select.Separator`, and that is deliberate rather than an omission: a rule
|
|
834
|
+
* separates *groups*, so it belongs between them in `Select.List` — which does
|
|
835
|
+
* admit one. A separator inside a group is a rule with nothing on one side of
|
|
836
|
+
* it.
|
|
837
|
+
*
|
|
838
|
+
* **Breaking.** A caller passing anything else — a `<div>` wrapper, a fragment
|
|
839
|
+
* of their own, a component that returns options — now fails `uf check`. The
|
|
840
|
+
* fix is to hand the options to the group directly; a wrapper had no effect on
|
|
841
|
+
* what this renders, because the group's element is the one below.
|
|
796
842
|
*/
|
|
797
|
-
export component SelectGroup(children:
|
|
843
|
+
export component SelectGroup(children: renders* (SelectOption | SelectGroupLabel), ...rest: Rest) {
|
|
798
844
|
const base = useId();
|
|
799
845
|
const [labelled, setLabelled] = useState(false);
|
|
800
846
|
|
package/separator.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A rule, and the one decision in it: whether anybody is told it is there.
|
|
4
|
+
//
|
|
5
|
+
// Two lines of markup, and it belongs in this package rather than in the preset
|
|
6
|
+
// for the same reason `Progress` does — the component *is* a conditional about
|
|
7
|
+
// what a reader hears:
|
|
8
|
+
//
|
|
9
|
+
// * A separator **between groups of content** is `role="separator"` with an
|
|
10
|
+
// `aria-orientation`. A reader moving down the page is told the subject
|
|
11
|
+
// changed, which is the information the line was drawn to give and the only
|
|
12
|
+
// way they get it.
|
|
13
|
+
// * A **decorative** rule — the line under a heading, the hairline between a
|
|
14
|
+
// card's padding and its footer — is `aria-hidden="true"` and announced to
|
|
15
|
+
// nobody. It is a border that happens to be an element.
|
|
16
|
+
//
|
|
17
|
+
// Getting it backwards is silent in both directions: a decorative rule with the
|
|
18
|
+
// role adds a "separator" to every reading of the page, and a real boundary
|
|
19
|
+
// without it takes the boundary away from everyone who is not looking at it.
|
|
20
|
+
//
|
|
21
|
+
// The default is the semantic one, because the two mistakes do not cost the
|
|
22
|
+
// same. A rule wrongly announced is noise a reader can hear and skip; a
|
|
23
|
+
// boundary wrongly silent is information that is simply not there, and nobody
|
|
24
|
+
// finds out. `progress.js` makes the same trade in its own sentence: the safe
|
|
25
|
+
// answer has to be the honest one.
|
|
26
|
+
//
|
|
27
|
+
// # Why this is a `<div>` and not an `<hr>`
|
|
28
|
+
//
|
|
29
|
+
// An `<hr>` already carries `role="separator"`, so for a horizontal rule
|
|
30
|
+
// between two blocks of prose it is the better answer and a caller who can use
|
|
31
|
+
// one should. This exists for what it cannot do.
|
|
32
|
+
//
|
|
33
|
+
// It comes with a border and a margin from the browser's own stylesheet, and a
|
|
34
|
+
// package that ships no styles cannot ship a visible line — every consumer
|
|
35
|
+
// would begin by turning it off. It is horizontal by definition, so a vertical
|
|
36
|
+
// rule between two things in a row is a rotated element rather than a described
|
|
37
|
+
// one. And it is a paragraph-level break in the flow, which is not what a
|
|
38
|
+
// hairline inside a toolbar is.
|
|
39
|
+
//
|
|
40
|
+
// # The two separators that are not this one
|
|
41
|
+
//
|
|
42
|
+
// `Menu.Separator` is the rule between groups of menu items, and belongs to the
|
|
43
|
+
// menu because it has to be skipped by the arrow keys that walk it.
|
|
44
|
+
// `Resizable.Handle` announces itself as a separator too, and is a *control* —
|
|
45
|
+
// the APG window splitter, a separator that behaves like a slider. Neither is
|
|
46
|
+
// this, and reaching for this one in either place loses the behaviour that made
|
|
47
|
+
// them their own components.
|
|
48
|
+
//
|
|
49
|
+
// # No `"use client"`
|
|
50
|
+
//
|
|
51
|
+
// One element, two attributes, nothing to remember. It renders on a server.
|
|
52
|
+
|
|
53
|
+
import type { Orientation } from "./internal/roving-focus.js";
|
|
54
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
55
|
+
import { withProps } from "./internal/merge-props.js";
|
|
56
|
+
|
|
57
|
+
export type { Orientation } from "./internal/roving-focus.js";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A rule between two things, or a line that is only a line.
|
|
61
|
+
*
|
|
62
|
+
* `decorative` is the whole component. Without it the element is a
|
|
63
|
+
* `role="separator"` a reader is told about; with it the element is hidden from
|
|
64
|
+
* the accessibility tree entirely.
|
|
65
|
+
*
|
|
66
|
+
* <Separator />
|
|
67
|
+
* <Separator orientation="vertical" />
|
|
68
|
+
* <Separator decorative />
|
|
69
|
+
*
|
|
70
|
+
* The decorative one gets `aria-hidden` and no role, rather than
|
|
71
|
+
* `role="presentation"` as well: a `<div>` has nothing to hide behind a
|
|
72
|
+
* presentation role, and one attribute that removes the element from the tree
|
|
73
|
+
* says the whole thing. `Breadcrumb.Separator` carries both because it is an
|
|
74
|
+
* `<li>`, whose `listitem` role would otherwise be counted.
|
|
75
|
+
*
|
|
76
|
+
* `aria-orientation` is written out even for the horizontal case, where ARIA
|
|
77
|
+
* would default to it. It is the attribute a reader of this markup is looking
|
|
78
|
+
* for, and a default that is left implicit is a default somebody has to know.
|
|
79
|
+
*
|
|
80
|
+
* `render` changes the element carrying that decision, not the decision
|
|
81
|
+
* itself: decorative rules stay hidden, semantic rules keep the separator
|
|
82
|
+
* role and orientation.
|
|
83
|
+
*/
|
|
84
|
+
export component Separator(
|
|
85
|
+
decorative?: boolean = false,
|
|
86
|
+
orientation?: Orientation = "horizontal",
|
|
87
|
+
render?: RenderProp,
|
|
88
|
+
...rest: Rest
|
|
89
|
+
) {
|
|
90
|
+
const props = decorative
|
|
91
|
+
? withProps(rest, { "aria-hidden": "true" })
|
|
92
|
+
: withProps(rest, { "aria-orientation": orientation, role: "separator" });
|
|
93
|
+
if (render != null) {
|
|
94
|
+
return render(props);
|
|
95
|
+
}
|
|
96
|
+
return <div {...props} />;
|
|
97
|
+
}
|
package/sheet.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A sheet: a modal dialog attached to an edge of the viewport.
|
|
4
|
+
//
|
|
5
|
+
// # What it is not, first
|
|
6
|
+
//
|
|
7
|
+
// A `Sheet` that were only a `Dialog` with a class on it would not be worth
|
|
8
|
+
// shipping, and this module would be a paragraph in the documentation saying
|
|
9
|
+
// "use `Dialog` and style it". The edge is a visual decision, `role="dialog"`
|
|
10
|
+
// is already right, and nothing a screen reader is told changes because the
|
|
11
|
+
// dialog slid in from the left. A component that added a `<div>` and a name
|
|
12
|
+
// for that would be a component that made a design system harder to read.
|
|
13
|
+
//
|
|
14
|
+
// What makes it a component is the two things a class cannot be:
|
|
15
|
+
//
|
|
16
|
+
// * **`side` is a type.** `<Sheet.Root side="lft">` is a Flow error at the
|
|
17
|
+
// call. A class name is a string, and a misspelt one is a sheet rendered
|
|
18
|
+
// off the top of the page with nothing reported anywhere.
|
|
19
|
+
// * **`data-side` is one contract.** The same attribute name `popover.js`
|
|
20
|
+
// writes, so a stylesheet has one thing to key on for every overlay in this
|
|
21
|
+
// package — and, more to the point, `drawer.js` and `sidebar.js` are both
|
|
22
|
+
// *defined in terms of this module*. A drawer is a sheet you can drag away;
|
|
23
|
+
// a sidebar on a narrow viewport becomes one. Written three times, "left"
|
|
24
|
+
// would come to mean three subtly different things, and the day one of them
|
|
25
|
+
// was fixed is the day they stopped agreeing.
|
|
26
|
+
//
|
|
27
|
+
// So the module is small on purpose. It is the shared definition of an edge,
|
|
28
|
+
// and the modal semantics under it are `dialog.js`'s, unchanged: focus in,
|
|
29
|
+
// `Tab` trapped, `Escape` out, focus back, the page inert and still. A sheet is
|
|
30
|
+
// modal, and every one of those is why.
|
|
31
|
+
//
|
|
32
|
+
// # The edge is not announced
|
|
33
|
+
//
|
|
34
|
+
// Deliberately. There is no `aria-*` for "this came in from the right", and
|
|
35
|
+
// inventing one — a `Sheet` that named its edge in its accessible name — would
|
|
36
|
+
// make a reader hear "Filters, right" and wonder what "right" meant. Where the
|
|
37
|
+
// box came from is the eye's business. The reader is told it is a dialog, what
|
|
38
|
+
// it is called, and that the rest of the page is unavailable, which is all
|
|
39
|
+
// three of the things that are true.
|
|
40
|
+
|
|
41
|
+
"use client";
|
|
42
|
+
|
|
43
|
+
import * as React from "@uniflowed/react";
|
|
44
|
+
import { createContext, useContext, useMemo } from "@uniflowed/react";
|
|
45
|
+
|
|
46
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
47
|
+
import { forwarded } from "./internal/merge-props.js";
|
|
48
|
+
import {
|
|
49
|
+
DialogBody,
|
|
50
|
+
DialogClose,
|
|
51
|
+
DialogDescription,
|
|
52
|
+
DialogFooter,
|
|
53
|
+
DialogHeader,
|
|
54
|
+
DialogOverlay,
|
|
55
|
+
DialogRoot,
|
|
56
|
+
DialogTitle,
|
|
57
|
+
DialogTrigger,
|
|
58
|
+
} from "./dialog.js";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Which edge of the viewport a sheet is attached to.
|
|
62
|
+
*
|
|
63
|
+
* Physical, and deliberately not logical: a design that puts a navigation sheet
|
|
64
|
+
* against the left of the screen means the left of the screen in any writing
|
|
65
|
+
* direction, the same way `internal/anchor.js`'s `Side` does and for the same
|
|
66
|
+
* reason. What the writing direction changes is the reading order inside the
|
|
67
|
+
* sheet, which is the page's business rather than this component's.
|
|
68
|
+
*
|
|
69
|
+
* A union rather than a string, so a typo is a type error at the call rather
|
|
70
|
+
* than a `data-side="lft"` no stylesheet matches and no test notices.
|
|
71
|
+
*/
|
|
72
|
+
export type Edge = "top" | "right" | "bottom" | "left";
|
|
73
|
+
|
|
74
|
+
type SheetState = {| readonly side: Edge |};
|
|
75
|
+
|
|
76
|
+
const SheetContext: React.Context<SheetState | null> = createContext(null);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The sheet a part belongs to.
|
|
80
|
+
*
|
|
81
|
+
* Raising rather than returning null, for the reason `useDialog` gives: a
|
|
82
|
+
* `Sheet.Body` outside a root would render a dialog with no edge, and it would
|
|
83
|
+
* look correct until somebody styled it.
|
|
84
|
+
*/
|
|
85
|
+
hook useSheet(part: string): SheetState {
|
|
86
|
+
const state = useContext(SheetContext);
|
|
87
|
+
if (state == null) {
|
|
88
|
+
throw new Error(`${part} must be rendered inside a Sheet.Root`);
|
|
89
|
+
}
|
|
90
|
+
return state;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The sheet, open or closed. Uncontrolled unless `open` is given. */
|
|
94
|
+
export component SheetRoot(
|
|
95
|
+
children: React.Node,
|
|
96
|
+
defaultOpen?: boolean = false,
|
|
97
|
+
onOpenChange?: (open: boolean) => void,
|
|
98
|
+
open?: boolean,
|
|
99
|
+
side?: Edge = "right",
|
|
100
|
+
) {
|
|
101
|
+
const state = useMemo(() => ({ side }), [side]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<SheetContext.Provider value={state}>
|
|
105
|
+
<DialogRoot defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
|
|
106
|
+
{children}
|
|
107
|
+
</DialogRoot>
|
|
108
|
+
</SheetContext.Provider>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What opens it, and what focus comes back to when it closes. */
|
|
113
|
+
export component SheetTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
114
|
+
return (
|
|
115
|
+
<DialogTrigger {...forwarded(rest)} render={render}>
|
|
116
|
+
{children}
|
|
117
|
+
</DialogTrigger>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The backdrop, which knows the edge so a stylesheet does not have to be told
|
|
123
|
+
* twice.
|
|
124
|
+
*/
|
|
125
|
+
export component SheetOverlay(render?: RenderProp, ...rest: Rest) {
|
|
126
|
+
const sheet = useSheet("Sheet.Overlay");
|
|
127
|
+
return <DialogOverlay {...forwarded(rest)} data-side={sheet.side} render={render} />;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The sheet itself: `Dialog.Body`, plus the edge as an attribute.
|
|
132
|
+
*
|
|
133
|
+
* Every modal promise `dialog.js` makes is made here, unchanged. This part adds
|
|
134
|
+
* `data-side` and nothing else, which is the honest size of the difference.
|
|
135
|
+
*/
|
|
136
|
+
export component SheetBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
137
|
+
const sheet = useSheet("Sheet.Body");
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<DialogBody {...forwarded(rest)} data-side={sheet.side} render={render}>
|
|
141
|
+
{children}
|
|
142
|
+
</DialogBody>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The top of the sheet. See `Dialog.Header` for why it is not a `<header>`. */
|
|
147
|
+
export component SheetHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
148
|
+
return (
|
|
149
|
+
<DialogHeader {...forwarded(rest)} render={render}>
|
|
150
|
+
{children}
|
|
151
|
+
</DialogHeader>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The bottom of the sheet, where the actions go. */
|
|
156
|
+
export component SheetFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
157
|
+
return (
|
|
158
|
+
<DialogFooter {...forwarded(rest)} render={render}>
|
|
159
|
+
{children}
|
|
160
|
+
</DialogFooter>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The sheet's accessible name. A modal without one is announced as "dialog". */
|
|
165
|
+
export component SheetTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
166
|
+
return (
|
|
167
|
+
<DialogTitle {...forwarded(rest)} render={render}>
|
|
168
|
+
{children}
|
|
169
|
+
</DialogTitle>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What the sheet is for, announced after its name. */
|
|
174
|
+
export component SheetDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
175
|
+
return (
|
|
176
|
+
<DialogDescription {...forwarded(rest)} render={render}>
|
|
177
|
+
{children}
|
|
178
|
+
</DialogDescription>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A button that closes the sheet. */
|
|
183
|
+
export component SheetClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
184
|
+
return (
|
|
185
|
+
<DialogClose {...forwarded(rest)} render={render}>
|
|
186
|
+
{children}
|
|
187
|
+
</DialogClose>
|
|
188
|
+
);
|
|
189
|
+
}
|