@uniflowed/ui 0.0.0-alpha.10
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 +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Site navigation, which is not a menu.
|
|
4
|
+
//
|
|
5
|
+
// This component exists mostly to prevent one mistake, and the mistake is
|
|
6
|
+
// reaching for `role="menu"` because the thing is called a navigation menu.
|
|
7
|
+
// `menu`, `menubar` and `menuitem` are for *application commands* — Cut, Paste,
|
|
8
|
+
// Export as PNG — and using them for a site's navigation costs a reader three
|
|
9
|
+
// things at once:
|
|
10
|
+
//
|
|
11
|
+
// * A screen reader announces "menu, five items" where the reader expected a
|
|
12
|
+
// list of links, and a list of links is a thing they know how to read.
|
|
13
|
+
// * The whole menu keyboard map comes with the role, and a reader who knows
|
|
14
|
+
// it will use it: `Tab` should leave the set in one press, arrow keys
|
|
15
|
+
// should move between items, typing a letter should jump. Claiming the role
|
|
16
|
+
// and not implementing the map is worse than not claiming it.
|
|
17
|
+
// * `menuitem` is not a link. It is not announced as one, it is not in the
|
|
18
|
+
// list of links a reader can pull up, and "open in a new tab" is not
|
|
19
|
+
// obviously available on it.
|
|
20
|
+
//
|
|
21
|
+
// The WAI-ARIA practices have a pattern for exactly this and it is the
|
|
22
|
+
// *Disclosure Navigation Menu*: a `<nav>` containing a list, where an
|
|
23
|
+
// expandable entry is a `button` with `aria-expanded` controlling a list of
|
|
24
|
+
// ordinary links. Radix's NavigationMenu makes the same choice. So: `Tab` walks
|
|
25
|
+
// the links, because they are links; `Escape` closes the open group and gives
|
|
26
|
+
// focus back to the button that opened it; choosing a link closes the group,
|
|
27
|
+
// because the reader is leaving.
|
|
28
|
+
//
|
|
29
|
+
// # One group open at a time
|
|
30
|
+
//
|
|
31
|
+
// Opening one closes the others, which is what the practices' example does and
|
|
32
|
+
// what a site's navigation looks like everywhere. It is also why the root holds
|
|
33
|
+
// which entry is open rather than each entry holding its own state: "the open
|
|
34
|
+
// one" is a fact about the set.
|
|
35
|
+
//
|
|
36
|
+
// # The closed group is not rendered
|
|
37
|
+
//
|
|
38
|
+
// `accordion.js` keeps its closed panels in the document with
|
|
39
|
+
// `hidden="until-found"` so that find-in-page can reach the text in them. This
|
|
40
|
+
// deliberately does not, and the difference is what the content is *for*. An
|
|
41
|
+
// accordion panel is prose a reader might be searching. A closed navigation
|
|
42
|
+
// group is a list of destinations, and having Ctrl+F unfold the site's
|
|
43
|
+
// navigation on the way to a word in the article would be a surprise with
|
|
44
|
+
// nothing to show for it.
|
|
45
|
+
//
|
|
46
|
+
// # Name the landmark
|
|
47
|
+
//
|
|
48
|
+
// `<nav>` is a landmark, and a page with two unnamed ones gives a reader a
|
|
49
|
+
// choice between "navigation" and "navigation". Pass `aria-label`.
|
|
50
|
+
|
|
51
|
+
"use client";
|
|
52
|
+
|
|
53
|
+
import * as React from "@uniflowed/react";
|
|
54
|
+
import { createContext, useContext, useId, useMemo, useState } from "@uniflowed/react";
|
|
55
|
+
|
|
56
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
57
|
+
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
58
|
+
import { usePresence } from "./internal/disclosure.js";
|
|
59
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
60
|
+
|
|
61
|
+
type NavigationMenuState = {|
|
|
62
|
+
/** The entry whose group is open, or null for none. */
|
|
63
|
+
readonly open: string | null,
|
|
64
|
+
readonly setOpen: (value: string | null) => void,
|
|
65
|
+
|};
|
|
66
|
+
|
|
67
|
+
const NavigationMenuContext: React.Context<NavigationMenuState | null> = createContext(null);
|
|
68
|
+
|
|
69
|
+
type NavigationMenuItemState = {|
|
|
70
|
+
readonly triggerId: string,
|
|
71
|
+
readonly bodyId: string,
|
|
72
|
+
readonly expanded: boolean,
|
|
73
|
+
readonly toggle: () => void,
|
|
74
|
+
readonly close: () => void,
|
|
75
|
+
/** Whether a `NavigationMenu.Body` is rendered, so the trigger names one that exists. */
|
|
76
|
+
readonly present: boolean,
|
|
77
|
+
readonly registerBody: (present: boolean) => void,
|
|
78
|
+
|};
|
|
79
|
+
|
|
80
|
+
const NavigationMenuItemContext: React.Context<NavigationMenuItemState | null> =
|
|
81
|
+
createContext(null);
|
|
82
|
+
|
|
83
|
+
hook useNavigationMenu(part: string): NavigationMenuState {
|
|
84
|
+
const state = useContext(NavigationMenuContext);
|
|
85
|
+
if (state == null) {
|
|
86
|
+
throw new Error(`${part} must be rendered inside a NavigationMenu.Root`);
|
|
87
|
+
}
|
|
88
|
+
return state;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
hook useNavigationMenuItem(part: string): NavigationMenuItemState {
|
|
92
|
+
const state = useContext(NavigationMenuItemContext);
|
|
93
|
+
if (state == null) {
|
|
94
|
+
throw new Error(`${part} must be rendered inside a NavigationMenu.Item`);
|
|
95
|
+
}
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The landmark, and the one place `Escape` is handled.
|
|
101
|
+
*
|
|
102
|
+
* `Escape` is here rather than on each group because the reader may be anywhere
|
|
103
|
+
* inside the open one when they press it, and because the button to give focus
|
|
104
|
+
* back to is found the same way everything else in this package finds things:
|
|
105
|
+
* by asking the document at the moment of the press. The alternative — every
|
|
106
|
+
* trigger writing itself into a ref — is a registry that has to be kept in step
|
|
107
|
+
* with a document that already knows the answer.
|
|
108
|
+
*/
|
|
109
|
+
export component NavigationMenuRoot(
|
|
110
|
+
children: renders* NavigationMenuList,
|
|
111
|
+
defaultValue?: string | null = null,
|
|
112
|
+
value?: string | null,
|
|
113
|
+
onValueChange?: (value: string | null) => void,
|
|
114
|
+
...rest: Rest
|
|
115
|
+
) {
|
|
116
|
+
const [open, setOpen] = useControlled<string | null>(value, defaultValue, onValueChange);
|
|
117
|
+
const state = useMemo(() => ({ open, setOpen }), [open, setOpen]);
|
|
118
|
+
const passed = withoutComposed(rest, ["onKeyDown"]);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<NavigationMenuContext.Provider value={state}>
|
|
122
|
+
<nav
|
|
123
|
+
{...passed}
|
|
124
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
125
|
+
if (event.key !== "Escape" || open == null) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
// This navigation menu, not a dialog around it.
|
|
130
|
+
event.stopPropagation();
|
|
131
|
+
const nav: $FlowFixMe = event.currentTarget;
|
|
132
|
+
const trigger = nav.querySelector('[aria-expanded="true"]');
|
|
133
|
+
setOpen(null);
|
|
134
|
+
// After closing, and synchronously: the trigger is not the element
|
|
135
|
+
// being removed — the group inside it is — so it is still there to
|
|
136
|
+
// take focus, and leaving focus on a `<li>` that no longer contains
|
|
137
|
+
// anything focusable drops the reader at the top of the page.
|
|
138
|
+
trigger?.focus?.();
|
|
139
|
+
})}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</nav>
|
|
143
|
+
</NavigationMenuContext.Provider>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The list of entries. A `<ul>`, because a reader is told how many there are. */
|
|
148
|
+
export component NavigationMenuList(children: renders* NavigationMenuItem, ...rest: Rest) {
|
|
149
|
+
useNavigationMenu("NavigationMenu.List");
|
|
150
|
+
return <ul {...rest}>{children}</ul>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One entry: a link on its own, or a button and the group it opens. */
|
|
154
|
+
export component NavigationMenuItem(value: string, children: React.Node, ...rest: Rest) {
|
|
155
|
+
const menu = useNavigationMenu("NavigationMenu.Item");
|
|
156
|
+
const base = useId();
|
|
157
|
+
const [present, setPresent] = useState(false);
|
|
158
|
+
const setOpen = menu.setOpen;
|
|
159
|
+
const expanded = menu.open === value;
|
|
160
|
+
|
|
161
|
+
const state = useMemo(
|
|
162
|
+
() => ({
|
|
163
|
+
triggerId: `${base}-trigger`,
|
|
164
|
+
bodyId: `${base}-body`,
|
|
165
|
+
expanded,
|
|
166
|
+
toggle: () => setOpen(expanded ? null : value),
|
|
167
|
+
close: () => setOpen(null),
|
|
168
|
+
present,
|
|
169
|
+
registerBody: setPresent,
|
|
170
|
+
}),
|
|
171
|
+
[base, expanded, setOpen, value, present],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<NavigationMenuItemContext.Provider value={state}>
|
|
176
|
+
<li {...rest}>{children}</li>
|
|
177
|
+
</NavigationMenuItemContext.Provider>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The button that opens an entry's group. Not a `menuitem`; see the header. */
|
|
182
|
+
export component NavigationMenuTrigger(children: React.Node, ...rest: Rest) {
|
|
183
|
+
const item = useNavigationMenuItem("NavigationMenu.Trigger");
|
|
184
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<button
|
|
188
|
+
{...passed}
|
|
189
|
+
aria-controls={item.present ? item.bodyId : undefined}
|
|
190
|
+
aria-expanded={item.expanded ? "true" : "false"}
|
|
191
|
+
id={item.triggerId}
|
|
192
|
+
onClick={composeHandlers(rest.onClick, item.toggle)}
|
|
193
|
+
type="button"
|
|
194
|
+
>
|
|
195
|
+
{children}
|
|
196
|
+
</button>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The group of links an entry opens.
|
|
202
|
+
*
|
|
203
|
+
* Named after its trigger, so a reader who lands in it by `Tab` is told which
|
|
204
|
+
* entry they are inside rather than hearing an unnamed list of four links.
|
|
205
|
+
*/
|
|
206
|
+
export component NavigationMenuBody(children: renders* NavigationMenuLink, ...rest: Rest) {
|
|
207
|
+
const item = useNavigationMenuItem("NavigationMenu.Body");
|
|
208
|
+
// Registered only while the group is actually in the document, which for this
|
|
209
|
+
// component means only while it is open — see the module header for why a
|
|
210
|
+
// closed group is removed rather than hidden. The register has to be handed
|
|
211
|
+
// over conditionally rather than the hook called conditionally, because a
|
|
212
|
+
// hook that runs on some renders and not others is a different bug.
|
|
213
|
+
usePresence(item.expanded ? item.registerBody : undefined);
|
|
214
|
+
|
|
215
|
+
if (!item.expanded) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<ul {...rest} aria-labelledby={item.triggerId} id={item.bodyId}>
|
|
221
|
+
{children}
|
|
222
|
+
</ul>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A destination.
|
|
228
|
+
*
|
|
229
|
+
* It renders its own `<li>` around the `<a>`, which is worth knowing before it
|
|
230
|
+
* surprises somebody: a `<ul>` may only contain `<li>`, and a `Link` that
|
|
231
|
+
* rendered a bare anchor would put this package's own markup outside what HTML
|
|
232
|
+
* allows in the list it sits in. The caller's props go on the anchor, which is
|
|
233
|
+
* the element they are about.
|
|
234
|
+
*
|
|
235
|
+
* Choosing one closes the group, because the reader is leaving. That is done
|
|
236
|
+
* without preventing anything: the click still navigates, and the group is shut
|
|
237
|
+
* behind them so that coming back — or a router that never unmounted the page —
|
|
238
|
+
* does not leave it hanging open.
|
|
239
|
+
*/
|
|
240
|
+
export component NavigationMenuLink(children: React.Node, ...rest: Rest) {
|
|
241
|
+
const item = useNavigationMenuItem("NavigationMenu.Link");
|
|
242
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<li>
|
|
246
|
+
<a {...passed} onClick={composeHandlers(rest.onClick, item.close)}>
|
|
247
|
+
{children}
|
|
248
|
+
</a>
|
|
249
|
+
</li>
|
|
250
|
+
);
|
|
251
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/ui",
|
|
3
|
+
"version": "0.0.0-alpha.10",
|
|
4
|
+
"description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/ui"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./accordion": "./accordion.js",
|
|
16
|
+
"./alert-dialog": "./alert-dialog.js",
|
|
17
|
+
"./carousel": "./carousel.js",
|
|
18
|
+
"./checkbox": "./checkbox.js",
|
|
19
|
+
"./collapsible": "./collapsible.js",
|
|
20
|
+
"./combobox": "./combobox.js",
|
|
21
|
+
"./dialog": "./dialog.js",
|
|
22
|
+
"./drawer": "./drawer.js",
|
|
23
|
+
"./field": "./field.js",
|
|
24
|
+
"./hover-card": "./hover-card.js",
|
|
25
|
+
"./input-otp": "./input-otp.js",
|
|
26
|
+
"./menu": "./menu.js",
|
|
27
|
+
"./navigation-menu": "./navigation-menu.js",
|
|
28
|
+
"./pagination": "./pagination.js",
|
|
29
|
+
"./popover": "./popover.js",
|
|
30
|
+
"./progress": "./progress.js",
|
|
31
|
+
"./radio-group": "./radio-group.js",
|
|
32
|
+
"./resizable": "./resizable.js",
|
|
33
|
+
"./scroll-area": "./scroll-area.js",
|
|
34
|
+
"./select": "./select.js",
|
|
35
|
+
"./sheet": "./sheet.js",
|
|
36
|
+
"./sidebar": "./sidebar.js",
|
|
37
|
+
"./slider": "./slider.js",
|
|
38
|
+
"./switch": "./switch.js",
|
|
39
|
+
"./table": "./table.js",
|
|
40
|
+
"./tabs": "./tabs.js",
|
|
41
|
+
"./toast": "./toast.js",
|
|
42
|
+
"./toggle": "./toggle.js",
|
|
43
|
+
"./toggle-group": "./toggle-group.js",
|
|
44
|
+
"./tooltip": "./tooltip.js"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"*.js",
|
|
48
|
+
"internal"
|
|
49
|
+
],
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@uniflowed/hooks": "0.0.0-alpha.10",
|
|
52
|
+
"@uniflowed/react": "0.0.0-alpha.10"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"react": ">=19"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/pagination.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Pagination: the navigation a table needs to be usable, and its four rules.
|
|
4
|
+
//
|
|
5
|
+
// It is here rather than in `table.js` because it is `crates/uf_lib`'s own
|
|
6
|
+
// entry and because a paginated list is not always a table — but it is written
|
|
7
|
+
// for the table next door, and the two are documented together.
|
|
8
|
+
//
|
|
9
|
+
// Four things, each of which is invisible when it is missing:
|
|
10
|
+
//
|
|
11
|
+
// * **It is navigation, so it is a `<nav>` with a name.** A page has more
|
|
12
|
+
// than one `nav`, and an unnamed one is announced as "navigation" with no
|
|
13
|
+
// way to tell it from the site's menu. `aria-label="Pagination"` is what
|
|
14
|
+
// puts it in a screen reader's landmark list under a useful name.
|
|
15
|
+
// * **The current page is `aria-current="page"`.** Not a class, not bold
|
|
16
|
+
// text, not `aria-selected` — `page` is the value ARIA defines for exactly
|
|
17
|
+
// this, and it is the only one that tells a reader where they are.
|
|
18
|
+
// * **Previous and next are named as such.** A link whose content is `‹` is
|
|
19
|
+
// announced as "link, left single quotation mark", which is not a thing
|
|
20
|
+
// anybody can act on. The glyph stays; the name is words.
|
|
21
|
+
// * **The change is announced.** Pressing "next" replaces the rows and moves
|
|
22
|
+
// nothing a reader is looking at, so a live region that was already there
|
|
23
|
+
// says "Page 4 of 25". `combobox.js` states the rule about why it has to
|
|
24
|
+
// have been there first.
|
|
25
|
+
//
|
|
26
|
+
// # Why the controls are links
|
|
27
|
+
//
|
|
28
|
+
// `Pagination.Item` renders an `<a>`, not a `<button>`, and that is an opinion
|
|
29
|
+
// worth stating because it constrains the caller: page four of a table is a
|
|
30
|
+
// *place*, and a reader expects to be able to open it in a new tab, copy it,
|
|
31
|
+
// bookmark it and come back to it. A list paginated with buttons is a list
|
|
32
|
+
// whose fourth page does not exist as far as the rest of the web is concerned.
|
|
33
|
+
//
|
|
34
|
+
// An application that genuinely has no URL for a page — a modal, an unsaved
|
|
35
|
+
// draft — is the case where this is the wrong component, and a `<button>` the
|
|
36
|
+
// caller writes themselves is the right answer. That is a smaller cost than
|
|
37
|
+
// making every well-behaved application invent its own links.
|
|
38
|
+
//
|
|
39
|
+
// # No `"use client"`
|
|
40
|
+
//
|
|
41
|
+
// Nothing here holds state, listens to anything or moves focus. Which page is
|
|
42
|
+
// current is the caller's, the links are links, and the announcement is a
|
|
43
|
+
// string in a div. It renders on a server.
|
|
44
|
+
|
|
45
|
+
import * as React from "@uniflowed/react";
|
|
46
|
+
|
|
47
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
48
|
+
import { withoutComposed } from "./internal/merge-props.js";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The pagination, as a named landmark, and the region that announces it.
|
|
52
|
+
*
|
|
53
|
+
* `page` and `pageCount` are what the announcement says. They are separate
|
|
54
|
+
* from which `Pagination.Item` is marked current because the two answer
|
|
55
|
+
* different questions — a reader is told "page 4 of 25" whether or not 25
|
|
56
|
+
* links are on screen, and a component that showed five links out of
|
|
57
|
+
* twenty-five would otherwise announce "page 4 of 5".
|
|
58
|
+
*/
|
|
59
|
+
export component PaginationRoot(
|
|
60
|
+
children: React.Node,
|
|
61
|
+
label?: string = "Pagination",
|
|
62
|
+
page?: number | null = null,
|
|
63
|
+
pageCount?: number | null = null,
|
|
64
|
+
announcePage?: (page: number, pageCount: number) => string,
|
|
65
|
+
...rest: Rest
|
|
66
|
+
) {
|
|
67
|
+
const message =
|
|
68
|
+
page == null || pageCount == null ? "" : (announcePage ?? defaultAnnouncement)(page, pageCount);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<nav {...rest} aria-label={label}>
|
|
73
|
+
{children}
|
|
74
|
+
</nav>
|
|
75
|
+
{/*
|
|
76
|
+
Beside the navigation rather than inside it, so a reader walking the
|
|
77
|
+
landmark hears the links and not a sentence about them — and mounted
|
|
78
|
+
from the first render holding nothing, because a live region that
|
|
79
|
+
appears together with its text is not announced at all.
|
|
80
|
+
*/}
|
|
81
|
+
<div aria-atomic="true" aria-live="polite" data-uf-pagination-status="" role="status">
|
|
82
|
+
{message}
|
|
83
|
+
</div>
|
|
84
|
+
</>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The list of pages.
|
|
90
|
+
*
|
|
91
|
+
* A real list, so a reader is told how many there are before walking them and
|
|
92
|
+
* can skip the whole thing in one keystroke.
|
|
93
|
+
*/
|
|
94
|
+
export component PaginationContent(
|
|
95
|
+
children: renders* (PaginationItem | PaginationPrevious | PaginationNext),
|
|
96
|
+
...rest: Rest
|
|
97
|
+
) {
|
|
98
|
+
return <ul {...rest}>{children}</ul>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One page.
|
|
103
|
+
*
|
|
104
|
+
* The `<li>` is structural and takes nothing; everything a caller passes goes
|
|
105
|
+
* on the `<a>`, which is what they style and what a reader activates.
|
|
106
|
+
*/
|
|
107
|
+
export component PaginationItem(
|
|
108
|
+
children: React.Node,
|
|
109
|
+
current?: boolean = false,
|
|
110
|
+
disabled?: boolean = false,
|
|
111
|
+
...rest: Rest
|
|
112
|
+
) {
|
|
113
|
+
return (
|
|
114
|
+
<PageLink current={current} disabled={disabled} rest={rest}>
|
|
115
|
+
{children}
|
|
116
|
+
</PageLink>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The link to the page before this one.
|
|
122
|
+
*
|
|
123
|
+
* `label` is its accessible name and has a default, because the content of
|
|
124
|
+
* this link is a chevron every time — and "link, left single quotation mark"
|
|
125
|
+
* is not something a reader can act on. Pass `label` to translate it; the
|
|
126
|
+
* glyph stays whatever the caller rendered.
|
|
127
|
+
*/
|
|
128
|
+
export component PaginationPrevious(
|
|
129
|
+
children?: React.Node,
|
|
130
|
+
label?: string = "Previous page",
|
|
131
|
+
disabled?: boolean = false,
|
|
132
|
+
...rest: Rest
|
|
133
|
+
) {
|
|
134
|
+
return (
|
|
135
|
+
<PageLink disabled={disabled} label={label} rest={rest}>
|
|
136
|
+
{children}
|
|
137
|
+
</PageLink>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The link to the page after this one. See `Pagination.Previous`. */
|
|
142
|
+
export component PaginationNext(
|
|
143
|
+
children?: React.Node,
|
|
144
|
+
label?: string = "Next page",
|
|
145
|
+
disabled?: boolean = false,
|
|
146
|
+
...rest: Rest
|
|
147
|
+
) {
|
|
148
|
+
return (
|
|
149
|
+
<PageLink disabled={disabled} label={label} rest={rest}>
|
|
150
|
+
{children}
|
|
151
|
+
</PageLink>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `<li><a>` the three parts above all render.
|
|
157
|
+
*
|
|
158
|
+
* `rest` arrives as a *named prop* rather than as a spread, and that is not a
|
|
159
|
+
* style choice. `Rest` is the type of props on their way onto an intrinsic —
|
|
160
|
+
* `merge-props.js` explains why an intrinsic's own props are unchecked here —
|
|
161
|
+
* and spreading its `mixed` indexer onto a typed component makes the checker
|
|
162
|
+
* say, correctly, that `disabled` might not be a boolean. Handing the bag over
|
|
163
|
+
* as one value keeps it a bag until it reaches the element it was always for.
|
|
164
|
+
*
|
|
165
|
+
* `disabled` drops the `href` rather than adding an attribute, because there
|
|
166
|
+
* is no such thing as a disabled link: an `<a>` with no `href` is not in the
|
|
167
|
+
* tab order and is not announced as a link, which is exactly what "there is no
|
|
168
|
+
* previous page" means. `aria-disabled` is there too, so a reader who reaches
|
|
169
|
+
* it another way is told why it does nothing.
|
|
170
|
+
*/
|
|
171
|
+
component PageLink(
|
|
172
|
+
rest: Rest,
|
|
173
|
+
children?: React.Node,
|
|
174
|
+
current?: boolean = false,
|
|
175
|
+
disabled?: boolean = false,
|
|
176
|
+
label?: string,
|
|
177
|
+
) {
|
|
178
|
+
const passed = withoutComposed(rest, disabled ? ["href"] : []);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<li>
|
|
182
|
+
<a
|
|
183
|
+
{...passed}
|
|
184
|
+
aria-current={current ? "page" : undefined}
|
|
185
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
186
|
+
aria-label={label}
|
|
187
|
+
>
|
|
188
|
+
{children}
|
|
189
|
+
</a>
|
|
190
|
+
</li>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The wording used when the caller supplies none. */
|
|
195
|
+
function defaultAnnouncement(page: number, pageCount: number): string {
|
|
196
|
+
return `Page ${String(page)} of ${String(pageCount)}.`;
|
|
197
|
+
}
|