@uniflowed/ui 0.0.0-alpha.2 → 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.
Files changed (58) 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 +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. package/internal/tabs.js +0 -270
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
+ }
package/sidebar.js ADDED
@@ -0,0 +1,300 @@
1
+ // @flow
2
+ //
3
+ // A sidebar: the one of the four dialog-shaped components that is usually not a
4
+ // dialog at all.
5
+ //
6
+ // `alert-dialog.js`, `sheet.js` and `drawer.js` are modal, and being modal is
7
+ // the point of each. A sidebar is *part of the page*: the reader uses what is
8
+ // beside it while it is open, nothing behind it is inert, nothing is
9
+ // scroll-locked, and there is no focus trap. It is a `<nav>` landmark and a
10
+ // button that says whether it is showing — the disclosure pattern
11
+ // `internal/disclosure.js` describes, applied to a region of the layout.
12
+ //
13
+ // Then the viewport gets narrow, and it *becomes* a dialog. That transition is
14
+ // the component, and it is the reason this is not something a caller assembles
15
+ // out of `Collapsible` and a media query:
16
+ //
17
+ // * **Collapsed is not closed.** On a wide screen the sidebar collapses to
18
+ // its icons and stays in the page. On a narrow one it is a `Sheet`: modal,
19
+ // over the content, gone when it is closed. One `open` describes both, and
20
+ // `aria-expanded` on the trigger is true about both.
21
+ // * **Focus has to move in both directions.** Opening the narrow sidebar
22
+ // moves focus into it, because it is a modal dialog and that is what
23
+ // `dialog.js` promises; closing it gives focus back to the trigger. Opening
24
+ // the wide one moves focus nowhere at all, because nothing was taken away.
25
+ // A component that got this backwards would either strand a reader in a
26
+ // dialog they cannot leave or move their focus for no reason.
27
+ // * **A collapsed button still has a name.** This is where the pattern goes
28
+ // wrong in practice. Collapsed to icons, a button whose accessible name
29
+ // came from its text is announced as "button" — so `Sidebar.Item` takes a
30
+ // `label`, puts it in `aria-label` the moment the sidebar collapses, and
31
+ // shows it in a `Tooltip` for readers who can see the icon and do not know
32
+ // what it means. The name survives whatever the stylesheet does to the
33
+ // text, which is the only way to promise it survives.
34
+ //
35
+ // # The state is the caller's, and that is a server-rendering decision
36
+ //
37
+ // `open` is uncontrolled by default like everything else here, and a real
38
+ // application will control it: shadcn persists the answer in a cookie so the
39
+ // server renders the sidebar in the state the reader left it. That matters more
40
+ // under RSC than it looks — an uncontrolled sidebar renders expanded on the
41
+ // server and collapses on hydration, which is a layout shift on every
42
+ // navigation. `open` and `onOpenChange` are how a caller reads the cookie and
43
+ // hands the answer in.
44
+ //
45
+ // # The narrow viewport is a query, not a guess
46
+ //
47
+ // `useMediaQuery` from `@uniflowed/hooks/browser`, with `false` on the server:
48
+ // there is no viewport during a prerender, and guessing "narrow" would send
49
+ // every reader markup in which the navigation is a closed dialog. The wide
50
+ // layout is the one that is still usable when the guess is wrong.
51
+
52
+ "use client";
53
+
54
+ import * as React from "@uniflowed/react";
55
+ import { createContext, useContext, useId, useMemo } from "@uniflowed/react";
56
+ import { useMediaQuery } from "@uniflowed/hooks/browser";
57
+
58
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
59
+ import { composeHandlers, forwarded, withProps, withoutComposed } from "./internal/merge-props.js";
60
+ import { SheetBody, SheetOverlay, SheetRoot, SheetTrigger } from "./sheet.js";
61
+ import { TooltipBody, TooltipRoot, TooltipTrigger } from "./tooltip.js";
62
+ import { useControlled } from "./internal/controlled-state.js";
63
+
64
+ /**
65
+ * Which side of the layout the sidebar is on.
66
+ *
67
+ * Two members and not `sheet.js`'s four, because a sidebar is never attached to
68
+ * the top or the bottom: a navigation rail across the top of a page is a header,
69
+ * with different semantics and a different component. `<Sidebar.Root side="top">`
70
+ * is a type error, which is the point of naming the union rather than reusing
71
+ * `Edge`.
72
+ */
73
+ export type SidebarSide = "left" | "right";
74
+
75
+ /** The breakpoint below which the sidebar is a modal sheet. */
76
+ const NARROW = "(max-width: 48rem)";
77
+
78
+ type SidebarState = {|
79
+ readonly base: string,
80
+ /** Expanded on a wide screen, and showing on a narrow one. */
81
+ readonly open: boolean,
82
+ readonly setOpen: (open: boolean) => void,
83
+ /** In the page, showing icons only. Never true while it is a sheet. */
84
+ readonly collapsed: boolean,
85
+ /** Whether the viewport has made it a modal sheet. */
86
+ readonly modal: boolean,
87
+ readonly side: SidebarSide,
88
+ /** Whether the navigation is in the document, so nothing names it when it is not. */
89
+ readonly present: boolean,
90
+ |};
91
+
92
+ const SidebarContext: React.Context<SidebarState | null> = createContext(null);
93
+
94
+ /**
95
+ * The sidebar a part belongs to.
96
+ *
97
+ * Raising rather than returning null, for the reason `useDialog` gives: a
98
+ * `Sidebar.Trigger` outside a root would render a button with an
99
+ * `aria-expanded` that never changes, and it would look correct.
100
+ */
101
+ hook useSidebar(part: string): SidebarState {
102
+ const state = useContext(SidebarContext);
103
+ if (state == null) {
104
+ throw new Error(`${part} must be rendered inside a Sidebar.Root`);
105
+ }
106
+ return state;
107
+ }
108
+
109
+ /**
110
+ * The sidebar, expanded or collapsed — and, on a narrow viewport, a sheet.
111
+ *
112
+ * Renders no element of its own when it is part of the page: the trigger and
113
+ * the navigation are siblings in whatever layout the caller wrote. When the
114
+ * viewport makes it modal it renders a `Sheet.Root` around both, so the trigger
115
+ * is the sheet's trigger and focus goes back to it — which is the half of the
116
+ * transition a wrapper around only the navigation could not do.
117
+ */
118
+ export component SidebarRoot(
119
+ children: React.Node,
120
+ defaultOpen?: boolean = true,
121
+ narrowQuery?: string = NARROW,
122
+ onOpenChange?: (open: boolean) => void,
123
+ open?: boolean,
124
+ side?: SidebarSide = "left",
125
+ ) {
126
+ const base = useId();
127
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
128
+ // `false` on the server: see the module header. The wide layout is the one
129
+ // that is still usable when there is no viewport to ask.
130
+ const modal = useMediaQuery(narrowQuery, false);
131
+
132
+ const state = useMemo(
133
+ () => ({
134
+ base,
135
+ collapsed: !modal && !isOpen,
136
+ modal,
137
+ open: isOpen,
138
+ // A sheet's navigation is in the document only while the sheet is open;
139
+ // the page's is always there, collapsed or not.
140
+ present: modal ? isOpen : true,
141
+ setOpen,
142
+ side,
143
+ }),
144
+ [base, isOpen, modal, setOpen, side],
145
+ );
146
+
147
+ return (
148
+ <SidebarContext.Provider value={state}>
149
+ {modal ? (
150
+ <SheetRoot onOpenChange={setOpen} open={isOpen} side={side}>
151
+ {children}
152
+ </SheetRoot>
153
+ ) : (
154
+ children
155
+ )}
156
+ </SidebarContext.Provider>
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The button that expands and collapses it.
162
+ *
163
+ * `aria-expanded` either way, and it is a true sentence about two different
164
+ * things: on a wide screen it says whether the navigation is showing its
165
+ * labels, on a narrow one whether the sheet is open. `aria-controls` names the
166
+ * navigation only while the navigation is in the document — a reference to an
167
+ * id nothing has tells a reader there is somewhere to go and has nowhere to
168
+ * send them.
169
+ */
170
+ export component SidebarTrigger(children: React.Node, ...rest: Rest) {
171
+ const sidebar = useSidebar("Sidebar.Trigger");
172
+ const passed = withoutComposed(rest, ["onClick"]);
173
+ const named = sidebar.present ? `${sidebar.base}-nav` : undefined;
174
+
175
+ // The sheet's own trigger while it is one: `Dialog.Trigger` is what records
176
+ // where focus came from, and focus going back to this button when the sheet
177
+ // closes is the second half of the transition.
178
+ if (sidebar.modal) {
179
+ // `Dialog.Trigger` names the sheet's own body while it is open, which is a
180
+ // better `aria-controls` than the navigation inside it, so this part adds
181
+ // nothing to it.
182
+ return <SheetTrigger {...forwarded(rest)}>{children}</SheetTrigger>;
183
+ }
184
+
185
+ return (
186
+ <button
187
+ {...passed}
188
+ aria-controls={named}
189
+ aria-expanded={sidebar.open ? "true" : "false"}
190
+ onClick={composeHandlers(rest.onClick, () => sidebar.setOpen(!sidebar.open))}
191
+ type="button"
192
+ >
193
+ {children}
194
+ </button>
195
+ );
196
+ }
197
+
198
+ /**
199
+ * The navigation itself: a named `<nav>` landmark, and on a narrow viewport a
200
+ * named `<nav>` landmark inside a modal sheet.
201
+ *
202
+ * A `<div>` here is the mistake the component exists to prevent. A `<nav>` is
203
+ * how a screen reader's landmark list offers "skip to the navigation", and a
204
+ * site's main navigation that is not one is navigation a reader has to find by
205
+ * tabbing through it.
206
+ *
207
+ * `label` is required rather than defaulted, because a landmark with no name is
208
+ * announced as "navigation" — and a page with two of those has told the reader
209
+ * there are two and which is which is a guess.
210
+ */
211
+ export component SidebarBody(children: React.Node, label: string, ...rest: Rest) {
212
+ const sidebar = useSidebar("Sidebar.Body");
213
+ const nav = (
214
+ <nav
215
+ {...forwarded(rest)}
216
+ aria-label={label}
217
+ data-collapsed={sidebar.collapsed ? "true" : undefined}
218
+ id={`${sidebar.base}-nav`}
219
+ >
220
+ {children}
221
+ </nav>
222
+ );
223
+
224
+ if (!sidebar.modal) {
225
+ return nav;
226
+ }
227
+
228
+ // Named rather than titled: a heading nobody asked for would appear in the
229
+ // page's outline, and the sheet's name is the navigation's name.
230
+ return (
231
+ <>
232
+ <SheetOverlay />
233
+ <SheetBody aria-label={label}>{nav}</SheetBody>
234
+ </>
235
+ );
236
+ }
237
+
238
+ /** The top of the sidebar, as a place to put styles. See `Dialog.Header`. */
239
+ export component SidebarHeader(children: React.Node, ...rest: Rest) {
240
+ return <div {...rest}>{children}</div>;
241
+ }
242
+
243
+ /** The bottom of the sidebar. See `Sidebar.Header`. */
244
+ export component SidebarFooter(children: React.Node, ...rest: Rest) {
245
+ return <div {...rest}>{children}</div>;
246
+ }
247
+
248
+ /**
249
+ * One entry in the navigation, whose name survives the collapse.
250
+ *
251
+ * `label` is what the reader hears. While the sidebar is expanded the entry's
252
+ * own content is its name, so nothing is overridden and a label with an icon,
253
+ * a count and a second line reads as written. The moment it collapses,
254
+ * `aria-label` takes over — because at that point the text is whatever the
255
+ * stylesheet has done to it, and a promise about the accessible name cannot
256
+ * depend on that.
257
+ *
258
+ * `render` for an entry that is a link. Site navigation is links, and a
259
+ * `<button>` that navigates is a button a reader cannot open in a new tab; see
260
+ * `navigation-menu.js`, which is the same argument at the scale of a whole
261
+ * menu.
262
+ */
263
+ export component SidebarItem(
264
+ children: React.Node,
265
+ label: string,
266
+ render?: RenderProp,
267
+ ...rest: Rest
268
+ ) {
269
+ const sidebar = useSidebar("Sidebar.Item");
270
+ const passed = withoutComposed(rest, ["ref"]);
271
+ const mine: Rest = {
272
+ "aria-label": sidebar.collapsed ? label : undefined,
273
+ children,
274
+ "data-collapsed": sidebar.collapsed ? "true" : undefined,
275
+ };
276
+
277
+ const entry = (extra: Rest) => {
278
+ // `mine` on top, and the order is load-bearing now that a part's props
279
+ // carry its children: the collapsed branch hands this the props
280
+ // `Tooltip.Trigger` built, and those name `children` as `undefined`
281
+ // because that tooltip trigger has none of its own. Applied last, a named
282
+ // `undefined` would blank the entry.
283
+ const props = withProps(withProps(passed, extra), mine);
284
+ return render == null ? <button {...props} type="button" /> : render(props);
285
+ };
286
+
287
+ if (!sidebar.collapsed) {
288
+ return entry({ ref: rest.ref });
289
+ }
290
+
291
+ // The icon's name, shown. A reader who can see the rail and not read minds
292
+ // needs the same sentence `aria-label` gives everybody else, and a tooltip is
293
+ // the mechanism that already satisfies WCAG 1.4.13 in this package.
294
+ return (
295
+ <TooltipRoot>
296
+ <TooltipTrigger ref={rest.ref} render={(props: Rest) => entry(props)} />
297
+ <TooltipBody side={sidebar.side === "left" ? "right" : "left"}>{label}</TooltipBody>
298
+ </TooltipRoot>
299
+ );
300
+ }