@haruhimemoe/ui 0.1.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +561 -0
- package/dist/components/actions/CopyButton.d.ts +33 -0
- package/dist/components/actions/CopyButton.js +40 -0
- package/dist/components/actions/JsonLd.d.ts +20 -0
- package/dist/components/actions/JsonLd.js +17 -0
- package/dist/components/actions/Pagination.d.ts +33 -0
- package/dist/components/actions/Pagination.js +27 -0
- package/dist/components/actions/PaginationStatus.d.ts +19 -0
- package/dist/components/actions/PaginationStatus.js +44 -0
- package/dist/components/basics/Button.d.ts +20 -0
- package/dist/components/basics/Button.js +10 -0
- package/dist/components/basics/ButtonLink.d.ts +23 -0
- package/dist/components/basics/ButtonLink.js +26 -0
- package/dist/components/basics/Card.d.ts +19 -0
- package/dist/components/basics/Card.js +20 -0
- package/dist/components/basics/Notice.d.ts +30 -0
- package/dist/components/basics/Notice.js +22 -0
- package/dist/components/basics/PageHeader.d.ts +25 -0
- package/dist/components/basics/PageHeader.js +10 -0
- package/dist/components/basics/Prose.d.ts +18 -0
- package/dist/components/basics/Prose.js +11 -0
- package/dist/components/basics/buttonStyles.d.ts +23 -0
- package/dist/components/basics/buttonStyles.js +28 -0
- package/dist/components/filters/Chip.d.ts +25 -0
- package/dist/components/filters/Chip.js +29 -0
- package/dist/components/filters/ChipGroup.d.ts +39 -0
- package/dist/components/filters/ChipGroup.js +44 -0
- package/dist/components/filters/FilterPanel.d.ts +36 -0
- package/dist/components/filters/FilterPanel.js +45 -0
- package/dist/components/filters/FilterRow.d.ts +22 -0
- package/dist/components/filters/FilterRow.js +22 -0
- package/dist/components/filters/RangeSlider.d.ts +62 -0
- package/dist/components/filters/RangeSlider.js +161 -0
- package/dist/components/forms/Checkbox.d.ts +20 -0
- package/dist/components/forms/Checkbox.js +13 -0
- package/dist/components/forms/FieldFrame.d.ts +66 -0
- package/dist/components/forms/FieldFrame.js +44 -0
- package/dist/components/forms/Select.d.ts +19 -0
- package/dist/components/forms/Select.js +12 -0
- package/dist/components/forms/TextInput.d.ts +19 -0
- package/dist/components/forms/TextInput.js +12 -0
- package/dist/components/forms/Textarea.d.ts +19 -0
- package/dist/components/forms/Textarea.js +13 -0
- package/dist/components/forms/fieldStyles.d.ts +17 -0
- package/dist/components/forms/fieldStyles.js +19 -0
- package/dist/components/icons/GitHubIcon.d.ts +18 -0
- package/dist/components/icons/GitHubIcon.js +10 -0
- package/dist/components/icons/HaruhimeWordmark.d.ts +23 -0
- package/dist/components/icons/HaruhimeWordmark.js +16 -0
- package/dist/components/icons/HaruhimeWordmarkLink.d.ts +21 -0
- package/dist/components/icons/HaruhimeWordmarkLink.js +12 -0
- package/dist/components/shell/AutoLink.d.ts +19 -0
- package/dist/components/shell/AutoLink.js +23 -0
- package/dist/components/shell/NavLinks.d.ts +24 -0
- package/dist/components/shell/NavLinks.js +40 -0
- package/dist/components/shell/PageShell.d.ts +29 -0
- package/dist/components/shell/PageShell.js +11 -0
- package/dist/components/shell/SiteFooter.d.ts +40 -0
- package/dist/components/shell/SiteFooter.js +21 -0
- package/dist/components/shell/SiteHeader.d.ts +32 -0
- package/dist/components/shell/SiteHeader.js +16 -0
- package/dist/components/shell/links.d.ts +25 -0
- package/dist/components/shell/links.js +32 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +41 -0
- package/dist/theme.css +54 -0
- package/dist/utils/cx.d.ts +18 -0
- package/dist/utils/cx.js +17 -0
- package/dist/utils/href.d.ts +15 -0
- package/dist/utils/href.js +17 -0
- package/package.json +90 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@haruhimemoe/ui` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While on 0.x, a change to how a component looks is a minor version.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `@haruhimemoe/ui/theme.css`: the osu!-web palette as Tailwind 4 colors (`b1` to `b6`, `c1` to `c4`, `h1`, `h2`) driven by one `--hue` (default 333), `--h1-l` and `--h2-l` to move `h1` and `h2` lightness at hues where the defaults fall under 4.5:1 contrast, Nunito as `font-sans` through `--font-nunito`, a visible focus ring, and an `@source` line so an app's Tailwind generates the components' classes.
|
|
14
|
+
- Basics: `Button`, `ButtonLink` (next/link, or a plain `<a>` for external URLs), `buttonClasses`, `Card`, `PageHeader`, `Notice` (info, warning, error; optionally live) and `Prose`.
|
|
15
|
+
- Forms: `TextInput`, `Textarea`, `Select` and `Checkbox`, each with a label, hint and error wired through `aria-describedby` and `aria-invalid`, plus `fieldClasses` for bare controls.
|
|
16
|
+
- Actions: `CopyButton` (a client component that reports "Copied." or a failure in an `<output>`), `Pagination` and `JsonLd`.
|
|
17
|
+
- Icons: `GitHubIcon`, `HaruhimeWordmark` and `HaruhimeWordmarkLink`.
|
|
18
|
+
- Filters: `Chip` and `ChipGroup` (toggle pills with `aria-pressed`), `RangeSlider` (two thumbs with editable ends, keyboard support, an open "+" top end, comma decimals, and an `inputMode` that switches to the text keyboard with a custom `parse`), `FilterRow` and `FilterPanel` (the osu! beatmap listing layout, collapsible on phones, with a live result count and "Clear filters").
|
|
19
|
+
- `className` on every component, and the extras passed to `buttonClasses` and `fieldClasses`, merge with tailwind-merge: a caller's class replaces a built-in one that sets the same property (`fieldClasses("w-auto")` drops `w-full`).
|
|
20
|
+
- Shell: `SiteHeader` (brand slot, nav links as data with `aria-current`, actions slot), `NavLinks`, `SiteFooter` (link columns as data, fine print, the haruhime.moe wordmark and a GitHub link) and `PageShell` (skip link, header, main, footer).
|
|
21
|
+
|
|
22
|
+
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.1.0...HEAD
|
|
23
|
+
[0.1.0]: https://github.com/haruhimemoe/ui/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David (@dvhsh)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# @haruhimemoe/ui
|
|
2
|
+
|
|
3
|
+
React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, cards, form fields, filter controls (toggle chips, a two-thumb range slider, a filter panel) and the site header, footer and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Next.js 16 (app router)
|
|
8
|
+
- React 19
|
|
9
|
+
- Tailwind CSS 4.1 or later
|
|
10
|
+
|
|
11
|
+
These are peer dependencies. The package is ESM only.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add @haruhimemoe/ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If the app doesn't have the peers yet:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun add next react react-dom
|
|
23
|
+
bun add -d tailwindcss @tailwindcss/postcss
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Setup
|
|
27
|
+
|
|
28
|
+
**1. Load Tailwind through PostCSS** (skip this if the app already uses Tailwind 4). Without this file Next generates no utilities, and the components render unstyled with no build error.
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
// postcss.config.mjs
|
|
32
|
+
export default { plugins: { "@tailwindcss/postcss": {} } };
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**2. Import the theme after Tailwind** in the app's global stylesheet:
|
|
36
|
+
|
|
37
|
+
```css
|
|
38
|
+
/* src/app/globals.css */
|
|
39
|
+
@import "tailwindcss";
|
|
40
|
+
@import "@haruhimemoe/ui/theme.css";
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The theme does three things:
|
|
44
|
+
|
|
45
|
+
- Adds the palette as Tailwind colors, so `bg-b4`, `text-c1`, `border-h1` and the rest work in your own markup too.
|
|
46
|
+
- Adds a visible focus ring (`h1`, 2px) to everything on `:focus-visible`.
|
|
47
|
+
- Points Tailwind at the package's files with `@source`, so the classes the components use get generated. Without it the components render unstyled.
|
|
48
|
+
|
|
49
|
+
| Token | Use |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `b1` to `b6` | Backgrounds, lightest (`b1`) to darkest (`b6`) |
|
|
52
|
+
| `c1` to `c4` | Text, brightest (`c1`) to most muted (`c4`) |
|
|
53
|
+
| `h1`, `h2` | Highlights: `h1` is the bright accent, `h2` the deeper one (primary buttons) |
|
|
54
|
+
|
|
55
|
+
**3. Load Nunito** with `next/font` as the `--font-nunito` variable on `<html>`. The theme's `font-sans` uses it, and falls back to the system font without it.
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { Nunito } from "next/font/google";
|
|
59
|
+
|
|
60
|
+
const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display: "swap" });
|
|
61
|
+
|
|
62
|
+
// <html lang="en" className={nunito.variable}>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**4. Pick a hue (optional).** Every color comes from one `--hue` (default 333, pink). Set it on `:root` after the imports to recolor the whole app:
|
|
66
|
+
|
|
67
|
+
```css
|
|
68
|
+
:root {
|
|
69
|
+
--hue: 200; /* blue */
|
|
70
|
+
--h2-l: 42%; /* keeps white text on primary buttons at 4.5:1 */
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
At some hues the defaults drop below 4.5:1 contrast, so check yours. Two variables fix it:
|
|
75
|
+
|
|
76
|
+
- `--h2-l` sets the lightness of `h2` (default `45%`). White text on `h2` (primary buttons, the skip link) is under 4.5:1 for hues from about 23 to 205. Use `42%` at hue 200, `35%` at hue 150, or `31%` for any hue.
|
|
77
|
+
- `--h1-l` sets the lightness of `h1` (default `70%`). `h1` text on `b4` (card links, "Clear filters") is under 4.5:1 for hues from about 222 to 283. Use `77%` there.
|
|
78
|
+
|
|
79
|
+
The theme is dark only (`color-scheme: dark`).
|
|
80
|
+
|
|
81
|
+
## Example
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// src/app/layout.tsx
|
|
85
|
+
import { PageShell, SiteFooter, SiteHeader } from "@haruhimemoe/ui";
|
|
86
|
+
import { Nunito } from "next/font/google";
|
|
87
|
+
import Link from "next/link";
|
|
88
|
+
import type { ReactNode } from "react";
|
|
89
|
+
import "./globals.css";
|
|
90
|
+
|
|
91
|
+
const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display: "swap" });
|
|
92
|
+
|
|
93
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
94
|
+
return (
|
|
95
|
+
<html lang="en" className={nunito.variable}>
|
|
96
|
+
<body className="bg-b5 font-sans text-c2 antialiased">
|
|
97
|
+
<PageShell
|
|
98
|
+
header={
|
|
99
|
+
<SiteHeader
|
|
100
|
+
brand={
|
|
101
|
+
<Link href="/" className="font-extrabold text-c1 text-lg">
|
|
102
|
+
packs
|
|
103
|
+
</Link>
|
|
104
|
+
}
|
|
105
|
+
links={[
|
|
106
|
+
{ label: "Public packs", href: "/packs" },
|
|
107
|
+
{ label: "Docs", href: "/docs" },
|
|
108
|
+
]}
|
|
109
|
+
/>
|
|
110
|
+
}
|
|
111
|
+
footer={
|
|
112
|
+
<SiteFooter
|
|
113
|
+
columns={[{ title: "Help", items: [{ label: "Docs", href: "/docs" }] }]}
|
|
114
|
+
finePrint="Not affiliated with osu! or ppy Pty Ltd."
|
|
115
|
+
/>
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
</PageShell>
|
|
120
|
+
</body>
|
|
121
|
+
</html>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// src/app/page.tsx
|
|
128
|
+
import { ButtonLink, Card, PageHeader } from "@haruhimemoe/ui";
|
|
129
|
+
|
|
130
|
+
export default function Home() {
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<PageHeader
|
|
134
|
+
title="Beatmap packs"
|
|
135
|
+
lead="Pick maps, name the pack, share the link."
|
|
136
|
+
actions={<ButtonLink href="/new">New pack</ButtonLink>}
|
|
137
|
+
/>
|
|
138
|
+
<Card title="Recent packs" className="mt-8">
|
|
139
|
+
Nothing here yet.
|
|
140
|
+
</Card>
|
|
141
|
+
</>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Server and client components
|
|
147
|
+
|
|
148
|
+
Import every component from `@haruhimemoe/ui`, in Server and Client Components alike.
|
|
149
|
+
|
|
150
|
+
- **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider`, `FilterPanel` and `NavLinks` (which `SiteHeader` renders for you). Each file starts with `"use client"`.
|
|
151
|
+
- **Everything else is server-safe:** no state, no effects, no browser APIs.
|
|
152
|
+
|
|
153
|
+
A Server Component can't pass a function to a Client Component. So callback props (`onChange`, `onPressedChange`, `onClear`) have to come from your own `"use client"` file, like the filters example below. Props that are plain data (`CopyButton`'s `text`, `Chip`'s `pressed`) work from a Server Component. `Pagination` takes a function (`hrefFor`), but it is a Server Component itself, so that is fine anywhere.
|
|
154
|
+
|
|
155
|
+
## Props, classes and refs
|
|
156
|
+
|
|
157
|
+
- Every component takes its element's native props and passes them through (`id`, `aria-*`, `data-*`, event handlers). The tables below list only the extra props.
|
|
158
|
+
- `ref` is a normal prop (React 19). Like the native props, it goes on the component's outer element (for `PageShell`, the wrapper `<div>`, not `<main>`).
|
|
159
|
+
- `className` is added after the built-in classes and wins on conflict: a class that sets the same property as a built-in one replaces it (merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge)). `<Select className="w-auto">` drops the built-in `w-full`.
|
|
160
|
+
|
|
161
|
+
## Components
|
|
162
|
+
|
|
163
|
+
### Basics
|
|
164
|
+
|
|
165
|
+
#### `Button`
|
|
166
|
+
|
|
167
|
+
A pill button. Every native `<button>` prop.
|
|
168
|
+
|
|
169
|
+
| Prop | Type | Default | What it does |
|
|
170
|
+
| --- | --- | --- | --- |
|
|
171
|
+
| `variant` | `"primary" \| "secondary" \| "ghost"` | `"primary"` | `primary` is the `h2` pill that lights up to `h1` on hover, `secondary` is `b3`, `ghost` is transparent. |
|
|
172
|
+
| `size` | `"md" \| "lg"` | `"md"` | Height, padding and text size. |
|
|
173
|
+
| `type` | `"button" \| "submit" \| "reset"` | `"button"` | Never submits a form unless you ask for `"submit"`. |
|
|
174
|
+
|
|
175
|
+
#### `ButtonLink`
|
|
176
|
+
|
|
177
|
+
A link that looks like `Button`. Every `next/link` prop (`href`, `prefetch`, `replace`, `scroll`, `target`, `rel`...), plus `variant` and `size` as on `Button`.
|
|
178
|
+
|
|
179
|
+
- An `href` with a scheme (`https:`, `mailto:`) or starting with `//` renders a plain `<a>`, and `next/link`'s own props are dropped.
|
|
180
|
+
- With `target="_blank"` and no `rel`, it adds `rel="noreferrer"`. A `rel` you pass always wins.
|
|
181
|
+
|
|
182
|
+
#### `buttonClasses`
|
|
183
|
+
|
|
184
|
+
`buttonClasses({ variant?, size?, className? }): string` returns the `Button` classes, for elements the components don't cover. Types: `ButtonVariant`, `ButtonSize`, `ButtonClassOptions`.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
<summary className={buttonClasses({ variant: "secondary" })}>More</summary>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### `Card`
|
|
191
|
+
|
|
192
|
+
The osu!-web panel: rounded, `b4` background, `p-5`. Every native `<section>` prop except `title`.
|
|
193
|
+
|
|
194
|
+
| Prop | Type | Default | What it does |
|
|
195
|
+
| --- | --- | --- | --- |
|
|
196
|
+
| `title` | `ReactNode` | none | Rendered as an `<h2>` at the top. It also names the section (`aria-labelledby`), which makes the card a region landmark. |
|
|
197
|
+
|
|
198
|
+
#### `PageHeader`
|
|
199
|
+
|
|
200
|
+
The page's one `<h1>`, with room for a lead line, a meta line and actions. Every native `<div>` prop except `title`.
|
|
201
|
+
|
|
202
|
+
| Prop | Type | Default | What it does |
|
|
203
|
+
| --- | --- | --- | --- |
|
|
204
|
+
| `title` | `ReactNode` | required | The `<h1>`. |
|
|
205
|
+
| `lead` | `ReactNode` | none | A sentence under the title (`text-c3`). Rendered in a `<p>`, so inline content only. |
|
|
206
|
+
| `meta` | `ReactNode` | none | A small muted line (`text-c4`) for dates, counts or owners. Also a `<p>`. |
|
|
207
|
+
| `actions` | `ReactNode` | none | Buttons or links on the right. They wrap under the title on narrow screens. |
|
|
208
|
+
|
|
209
|
+
#### `Notice`
|
|
210
|
+
|
|
211
|
+
Short status text in one of three tones. Every native `<p>` prop. Type: `NoticeTone`.
|
|
212
|
+
|
|
213
|
+
| Prop | Type | Default | What it does |
|
|
214
|
+
| --- | --- | --- | --- |
|
|
215
|
+
| `tone` | `"info" \| "warning" \| "error"` | `"info"` | `text-c3`, `text-amber-300` or `text-rose-300`, at `text-sm`. |
|
|
216
|
+
| `live` | `boolean` | `false` | Make it a live region: `role="alert"` for errors, `role="status"` for the others. A `role` you pass wins. See below. |
|
|
217
|
+
| `as` | `"p" \| "div"` | `"p"` | Use `"div"` for block content such as a list of errors. |
|
|
218
|
+
|
|
219
|
+
A `status` region is only reliably announced when its content changes while it is on the page. A `live` info or warning notice that mounts with its text already inside (`{saved && <Notice live>Saved.</Notice>}`) can go unannounced. Keep it mounted and change its children, empty while there is nothing to say:
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
<Notice live>{saved ? "Saved." : ""}</Notice>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
An error notice (`role="alert"`) is announced either way.
|
|
226
|
+
|
|
227
|
+
#### `Prose`
|
|
228
|
+
|
|
229
|
+
Long-form typography for MDX, docs and legal pages. A `max-w-3xl` `<div>` that styles the `h2`, `h3`, `p`, `a`, `strong`, `ul`, `ol`, `li`, `code`, `pre`, `hr` and `table` elements inside it. Every native `<div>` prop.
|
|
230
|
+
|
|
231
|
+
### Forms
|
|
232
|
+
|
|
233
|
+
The fields render a label, the control, an optional hint and an optional error, wired together for screen readers. They are Server Components: you pass the `id`, so they need no generated ids.
|
|
234
|
+
|
|
235
|
+
Shared props (type `FieldProps`), taken by `TextInput`, `Textarea`, `Select` and `Checkbox`:
|
|
236
|
+
|
|
237
|
+
| Prop | Type | Default | What it does |
|
|
238
|
+
| --- | --- | --- | --- |
|
|
239
|
+
| `id` | `string` | required | The control's id. The label points at it. The hint gets `<id>-hint` and the error `<id>-error`. |
|
|
240
|
+
| `label` | `ReactNode` | required | The visible label. |
|
|
241
|
+
| `hint` | `ReactNode` | none | Help text in a `<div>`, linked with `aria-describedby`. On `Checkbox` the hint sits inline inside the label, so keep it to text there. |
|
|
242
|
+
| `error` | `ReactNode` | none | Error text in a `role="alert"` `<div>` (`text-rose-300`), so a list of errors is fine. Sets `aria-invalid` and links the text with `aria-describedby`. |
|
|
243
|
+
| `wrapperClassName` | `string` | none | Classes for the wrapper around the label, control, hint and error, for layout (`min-w-48 flex-1`). |
|
|
244
|
+
|
|
245
|
+
`className` goes on the control itself. Your own `aria-describedby` is kept after the hint and error ids.
|
|
246
|
+
|
|
247
|
+
#### `TextInput`
|
|
248
|
+
|
|
249
|
+
Every native `<input>` prop (`type`, `name`, `value`, `onChange`, `placeholder`, `required`...), plus the field props.
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
<TextInput id="pack-name" label="Name" hint="Shown on the pack page." required />
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
#### `Textarea`
|
|
256
|
+
|
|
257
|
+
Every native `<textarea>` prop, plus the field props. At least `min-h-24` tall, resizes vertically.
|
|
258
|
+
|
|
259
|
+
#### `Select`
|
|
260
|
+
|
|
261
|
+
Every native `<select>` prop, plus the field props. Pass `<option>` elements as children.
|
|
262
|
+
|
|
263
|
+
#### `Checkbox`
|
|
264
|
+
|
|
265
|
+
Every native `<input>` prop except `type` (`checked`, `defaultChecked`, `onChange`, `name`, `disabled`...), plus the field props. The label is bold `text-c1` and the hint follows it inline after a dot. Clicking anywhere on the row toggles it. The label alone is the accessible name; the hint is the description.
|
|
266
|
+
|
|
267
|
+
#### `fieldClasses`
|
|
268
|
+
|
|
269
|
+
`fieldClasses(className?: string): string` returns the field look (`b6` background, `b3` border, `h1` border on focus, rose border when `aria-invalid`, and an `h1` border and ring when an invalid field has focus). Use it on a bare control that labels itself:
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
<select aria-label="Move to" className={fieldClasses("w-auto")}>...</select>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Actions
|
|
276
|
+
|
|
277
|
+
#### `CopyButton` (client)
|
|
278
|
+
|
|
279
|
+
A button that copies text, with the result in an `<output>` beside it that screen readers announce. Each press clears the message first, so a second copy is announced too. If the clipboard is missing or refuses (an insecure page, say), it shows the failure message. Every `Button` prop except `onClick` and `children`; `className` and the native props go on the button.
|
|
280
|
+
|
|
281
|
+
| Prop | Type | Default | What it does |
|
|
282
|
+
| --- | --- | --- | --- |
|
|
283
|
+
| `text` | `string` | required | The text to copy. |
|
|
284
|
+
| `label` | `ReactNode` | `"Copy"` | The button's text. |
|
|
285
|
+
| `copiedMessage` | `ReactNode` | `"Copied."` | Shown after a copy works. |
|
|
286
|
+
| `failedMessage` | `ReactNode` | `"Couldn't copy. Select the text and copy it by hand."` | Shown when it doesn't. |
|
|
287
|
+
| `variant` | `"primary" \| "secondary" \| "ghost"` | `"secondary"` | As on `Button`. |
|
|
288
|
+
| `size` | `"md" \| "lg"` | `"md"` | As on `Button`. |
|
|
289
|
+
| `wrapperClassName` | `string` | none | Classes for the wrapper around the button and the message. |
|
|
290
|
+
|
|
291
|
+
#### `Pagination`
|
|
292
|
+
|
|
293
|
+
Previous and next pill links around "Page X of Y". Renders nothing when there is one page or none. Every native `<nav>` prop; `aria-label` defaults to `"Pages"`.
|
|
294
|
+
|
|
295
|
+
| Prop | Type | Default | What it does |
|
|
296
|
+
| --- | --- | --- | --- |
|
|
297
|
+
| `page` | `number` | required | The current page, starting at 1. |
|
|
298
|
+
| `pageCount` | `number` | required | How many pages there are. |
|
|
299
|
+
| `hrefFor` | `(page: number) => string` | required | Builds a page's URL, e.g. `` (p) => `/packs?page=${p}` ``. |
|
|
300
|
+
| `previousLabel` | `ReactNode` | `"Previous"` | Text of the link to the page before. |
|
|
301
|
+
| `nextLabel` | `ReactNode` | `"Next"` | Text of the link to the page after. |
|
|
302
|
+
| `formatStatus` | `(page: number, pageCount: number) => ReactNode` | `"Page X of Y"` | The text in the middle. |
|
|
303
|
+
|
|
304
|
+
The links use `next/link` with `rel="prev"` and `rel="next"`. On the first and last page one link goes away. If it had keyboard focus (Next pressed on page 4 of 5), focus moves to the "Page X of Y" text instead of falling back to the top of the page. `Pagination` stays a Server Component; that text is a small client component inside it.
|
|
305
|
+
|
|
306
|
+
#### `JsonLd`
|
|
307
|
+
|
|
308
|
+
schema.org structured data in a `<script type="application/ld+json">`. Every `<` in the output is escaped, so a string in the data can't close the tag. Native `<script>` props such as `id` and `nonce` pass through.
|
|
309
|
+
|
|
310
|
+
| Prop | Type | Default | What it does |
|
|
311
|
+
| --- | --- | --- | --- |
|
|
312
|
+
| `data` | `Record<string, unknown>` | required | The schema.org object. `@context` defaults to `https://schema.org`; set it in `data` to change it. |
|
|
313
|
+
|
|
314
|
+
### Icons
|
|
315
|
+
|
|
316
|
+
#### `GitHubIcon`
|
|
317
|
+
|
|
318
|
+
The GitHub mark as an inline SVG in the current text color. Always hidden from screen readers, so put a label on the link around it. Every native `<svg>` prop.
|
|
319
|
+
|
|
320
|
+
| Prop | Type | Default | What it does |
|
|
321
|
+
| --- | --- | --- | --- |
|
|
322
|
+
| `className` | `string` | `"size-5"` | Replaces the default size. |
|
|
323
|
+
|
|
324
|
+
#### `HaruhimeWordmark`
|
|
325
|
+
|
|
326
|
+
The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and pink whatever `--hue` is. Every native `<svg>` prop.
|
|
327
|
+
|
|
328
|
+
| Prop | Type | Default | What it does |
|
|
329
|
+
| --- | --- | --- | --- |
|
|
330
|
+
| `title` | `string` | `"haruhime.moe"` | The accessible name (`role="img"` with a `<title>`). |
|
|
331
|
+
| `decorative` | `boolean` | `false` | Hide it from screen readers, for use inside a labelled link. |
|
|
332
|
+
| `className` | `string` | `"h-6 w-auto"` | Replaces the default size. The width follows the height. |
|
|
333
|
+
|
|
334
|
+
#### `HaruhimeWordmarkLink`
|
|
335
|
+
|
|
336
|
+
A plain `<a>` around a decorative `HaruhimeWordmark`, dimmed until hovered. Every native `<a>` prop.
|
|
337
|
+
|
|
338
|
+
| Prop | Type | Default | What it does |
|
|
339
|
+
| --- | --- | --- | --- |
|
|
340
|
+
| `href` | `string` | `"https://www.haruhime.moe"` | Where it links. |
|
|
341
|
+
| `aria-label` | `string` | `"haruhime.moe"` | The link's accessible name. |
|
|
342
|
+
| `wordmarkClassName` | `string` | `"h-6 w-auto"` | Replaces the wordmark's size. |
|
|
343
|
+
|
|
344
|
+
### Filters
|
|
345
|
+
|
|
346
|
+
The osu! beatmap listing layout: a panel of rows, each with a label on the left and controls on the right. The interactive pieces take callbacks, so render them from a `"use client"` file:
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
"use client";
|
|
350
|
+
|
|
351
|
+
import {
|
|
352
|
+
ChipGroup,
|
|
353
|
+
type ChipOption,
|
|
354
|
+
FilterPanel,
|
|
355
|
+
FilterRow,
|
|
356
|
+
RangeSlider,
|
|
357
|
+
type RangeSliderValue,
|
|
358
|
+
} from "@haruhimemoe/ui";
|
|
359
|
+
import { useState } from "react";
|
|
360
|
+
|
|
361
|
+
const MODS: ChipOption[] = [
|
|
362
|
+
{ value: "HD", label: "HD" },
|
|
363
|
+
{ value: "HR", label: "HR" },
|
|
364
|
+
{ value: "DT", label: "DT" },
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
export function PackFilters({ count }: { count: number }) {
|
|
368
|
+
const [mods, setMods] = useState<string[]>([]);
|
|
369
|
+
const [stars, setStars] = useState<RangeSliderValue>([0, null]);
|
|
370
|
+
const active = mods.length > 0 || stars[0] > 0 || stars[1] !== null;
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
<FilterPanel
|
|
374
|
+
title="Filters"
|
|
375
|
+
resultCount={`${count} packs`}
|
|
376
|
+
active={active}
|
|
377
|
+
onClear={() => {
|
|
378
|
+
setMods([]);
|
|
379
|
+
setStars([0, null]);
|
|
380
|
+
}}
|
|
381
|
+
>
|
|
382
|
+
<FilterRow label="Mods">
|
|
383
|
+
<ChipGroup label="Mods" hideLabel options={MODS} value={mods} onChange={setMods} />
|
|
384
|
+
</FilterRow>
|
|
385
|
+
<FilterRow label="Star rating">
|
|
386
|
+
<RangeSlider
|
|
387
|
+
label="Star rating"
|
|
388
|
+
hideLabel
|
|
389
|
+
min={0}
|
|
390
|
+
max={10}
|
|
391
|
+
step={0.1}
|
|
392
|
+
openEnded
|
|
393
|
+
value={stars}
|
|
394
|
+
onChange={setStars}
|
|
395
|
+
/>
|
|
396
|
+
</FilterRow>
|
|
397
|
+
</FilterPanel>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Give the `ChipGroup` or `RangeSlider` inside a `FilterRow` `hideLabel`. The row's label then shows once, and screen readers hear the row's name once instead of two nested groups with the same name.
|
|
403
|
+
|
|
404
|
+
#### `Chip` (client)
|
|
405
|
+
|
|
406
|
+
A toggle pill: a `<button>` with `aria-pressed`, `h1` when on. Every native `<button>` prop.
|
|
407
|
+
|
|
408
|
+
| Prop | Type | Default | What it does |
|
|
409
|
+
| --- | --- | --- | --- |
|
|
410
|
+
| `pressed` | `boolean` | required | Whether it is on. |
|
|
411
|
+
| `onPressedChange` | `(pressed: boolean) => void` | none | Called with the new state on click, Enter or Space. |
|
|
412
|
+
| `type` | `"button" \| "submit" \| "reset"` | `"button"` | Never submits a form by default. |
|
|
413
|
+
|
|
414
|
+
Your own `onClick` runs first. Call `event.preventDefault()` in it to skip the toggle.
|
|
415
|
+
|
|
416
|
+
#### `ChipGroup` (client)
|
|
417
|
+
|
|
418
|
+
A labelled row of chips for picking several values (mods, game modes). A `<fieldset>`; every native `<fieldset>` prop except `onChange` and `children`. `disabled` turns off every chip.
|
|
419
|
+
|
|
420
|
+
| Prop | Type | Default | What it does |
|
|
421
|
+
| --- | --- | --- | --- |
|
|
422
|
+
| `label` | `ReactNode` | required | Names the group. |
|
|
423
|
+
| `hideLabel` | `boolean` | `false` | For use inside a `FilterRow`, which names the row: the label doesn't render and the fieldset isn't a group of its own (`role="none"`). `disabled` still reaches every chip. |
|
|
424
|
+
| `options` | `readonly ChipOption[]` | required | `{ value: string; label: ReactNode; disabled?: boolean }` for each chip. |
|
|
425
|
+
| `value` | `readonly string[]` | required | The picked values. |
|
|
426
|
+
| `onChange` | `(value: string[]) => void` | required | Gets the new picked values, in the options' order, without duplicates. |
|
|
427
|
+
|
|
428
|
+
#### `RangeSlider` (client)
|
|
429
|
+
|
|
430
|
+
Two thumbs on one track with an editable box at each end, for star rating, length or BPM. A `<fieldset>`; every native `<fieldset>` prop except `onChange` and `children`. Type: `RangeSliderValue` (`[number, number | null]`), so `useState<RangeSliderValue>` can pass its setter straight in.
|
|
431
|
+
|
|
432
|
+
| Prop | Type | Default | What it does |
|
|
433
|
+
| --- | --- | --- | --- |
|
|
434
|
+
| `label` | `string` | required | Names the group, and the ends as "Minimum *label*" and "Maximum *label*". |
|
|
435
|
+
| `hideLabel` | `boolean` | `false` | For use inside a `FilterRow`, which names the row: the label doesn't show and the fieldset isn't a group of its own (`role="none"`). The ends keep their "Minimum *label*" and "Maximum *label*" names. |
|
|
436
|
+
| `min`, `max` | `number` | required | The bounds. |
|
|
437
|
+
| `step` | `number` | `1` | Step between values. Typed values snap to it. |
|
|
438
|
+
| `value` | `readonly [number, number \| null]` | required | The range. A `null` top means no upper limit. |
|
|
439
|
+
| `onChange` | `(value: [number, number \| null]) => void` | required | Gets the new range. |
|
|
440
|
+
| `openEnded` | `boolean` | `false` | The top end at `max` means "no upper limit": it shows `max+` (like `10+`) and reports `null`. |
|
|
441
|
+
| `format` | `(n: number) => string` | `String` | Display text for the boxes and screen readers. |
|
|
442
|
+
| `parse` | `(text: string) => number \| null` | plain number | Reads a typed value back (without a trailing `+`). The default takes a comma as the decimal point (`5,5`). Pair it with `format` for `m:ss` lengths. |
|
|
443
|
+
| `inputMode` | `"decimal" \| "text" \| "numeric" \| ...` | `"decimal"`, or `"text"` with a custom `parse` | The on-screen keyboard for the two boxes. Phone decimal keypads have no `:`, so a custom `parse` gets the full keyboard. |
|
|
444
|
+
| `minLabel`, `maxLabel` | `string` | "Minimum *label*", "Maximum *label*" | The accessible names of the two ends. |
|
|
445
|
+
| `disabled` | `boolean` | `false` | Turns off both thumbs and both boxes. |
|
|
446
|
+
|
|
447
|
+
The thumbs can't cross. Arrow keys move one step, Page Up and Page Down ten, Home and End as far as the thumb can go. A box commits on blur or Enter, and Escape undoes the typing. An empty low box means `min`; an empty top box means open (with `openEnded`) or `max`. Values that come in out of range or crossed (from a URL, say) are shown clamped, and a `NaN` or infinite end counts as no limit on that end. When both thumbs sit on one value, dragging moves whichever end can go that way.
|
|
448
|
+
|
|
449
|
+
#### `FilterRow`
|
|
450
|
+
|
|
451
|
+
One labelled row: the label above the controls on phones, in a `w-28` column on the left from `sm` up. A `<fieldset>`; every native `<fieldset>` prop. Server-safe.
|
|
452
|
+
|
|
453
|
+
| Prop | Type | Default | What it does |
|
|
454
|
+
| --- | --- | --- | --- |
|
|
455
|
+
| `label` | `ReactNode` | required | The row's label. Also names the group. |
|
|
456
|
+
|
|
457
|
+
#### `FilterPanel` (client)
|
|
458
|
+
|
|
459
|
+
A titled panel of `FilterRow`s with a live result count and a "Clear filters" button. On phones the rows fold behind a button next to the title; from `sm` up they always show. Every native `<section>` prop except `title`.
|
|
460
|
+
|
|
461
|
+
| Prop | Type | Default | What it does |
|
|
462
|
+
| --- | --- | --- | --- |
|
|
463
|
+
| `title` | `ReactNode` | required | The heading. It also names the panel and the phone toggle. |
|
|
464
|
+
| `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | The heading's level. |
|
|
465
|
+
| `resultCount` | `ReactNode` | none | Shown in a polite live region, so each new count is announced. |
|
|
466
|
+
| `active` | `boolean` | `false` | Whether any filter is set. |
|
|
467
|
+
| `onClear` | `() => void` | none | The clear button's action. The button shows only when `active` is true and this is set. |
|
|
468
|
+
| `clearLabel` | `ReactNode` | `"Clear filters"` | The clear button's text. |
|
|
469
|
+
| `defaultOpen` | `boolean` | `false` | Whether the rows start open on phones. |
|
|
470
|
+
|
|
471
|
+
### Shell
|
|
472
|
+
|
|
473
|
+
Links in the header and footer are data (type `SiteLinkItem`):
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
type SiteLinkItem = { label: string; href?: string; note?: string };
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Paths use `next/link`; anything with a scheme (`https:`, `mailto:`) or starting with `//` is a plain `<a>`. An item without `href` shows as muted text with its `note` beside it in small uppercase letters (`{ label: "Pools", note: "soon" }`).
|
|
480
|
+
|
|
481
|
+
#### `SiteHeader`
|
|
482
|
+
|
|
483
|
+
The dark top bar: brand on the left, the nav, and an actions slot on the right. Every native `<header>` prop. A Server Component; the nav list inside is `NavLinks`.
|
|
484
|
+
|
|
485
|
+
| Prop | Type | Default | What it does |
|
|
486
|
+
| --- | --- | --- | --- |
|
|
487
|
+
| `brand` | `ReactNode` | required | The left side, usually a home link with the site's name or wordmark. |
|
|
488
|
+
| `links` | `readonly SiteLinkItem[]` | `[]` | The nav entries. No nav renders when empty. |
|
|
489
|
+
| `navLabel` | `string` | `"Main"` | The nav landmark's accessible name. |
|
|
490
|
+
| `navAlign` | `"start" \| "center"` | `"start"` | `"start"` puts small `text-c3` links right after the brand. `"center"` centers larger `text-c2` links in the free space. |
|
|
491
|
+
| `actions` | `ReactNode` | none | The right side, e.g. an account menu. |
|
|
492
|
+
|
|
493
|
+
The link for the current page gets `aria-current="page"` and lights up. A section link gets `aria-current="true"` on pages under it (`/packs` while on `/packs/123`). `/` only matches itself.
|
|
494
|
+
|
|
495
|
+
#### `NavLinks` (client)
|
|
496
|
+
|
|
497
|
+
The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop. Type: `SiteNavAlign`.
|
|
498
|
+
|
|
499
|
+
| Prop | Type | Default | What it does |
|
|
500
|
+
| --- | --- | --- | --- |
|
|
501
|
+
| `links` | `readonly SiteLinkItem[]` | required | The entries. |
|
|
502
|
+
| `align` | `"start" \| "center"` | `"start"` | As `navAlign` on `SiteHeader`. |
|
|
503
|
+
|
|
504
|
+
#### `SiteFooter`
|
|
505
|
+
|
|
506
|
+
Link columns, an extra slot, fine print, the haruhime.moe wordmark and a GitHub icon link. Every native `<footer>` prop. Type: `SiteFooterColumn` (`{ title: string; items: readonly SiteLinkItem[] }`).
|
|
507
|
+
|
|
508
|
+
| Prop | Type | Default | What it does |
|
|
509
|
+
| --- | --- | --- | --- |
|
|
510
|
+
| `columns` | `readonly SiteFooterColumn[]` | `[]` | Each column is a `<nav>` named by its title, which shows above the list. Up to four columns side by side from `sm` up. |
|
|
511
|
+
| `extra` | `ReactNode` | none | Shown above the fine print, e.g. a "clear local data" button. |
|
|
512
|
+
| `finePrint` | `ReactNode` | none | One line of small print, in a `<p>`. |
|
|
513
|
+
| `parentLink` | `boolean` | `true` | Show the haruhime.moe wordmark linking the parent site. With it, the last row holds the wordmark and the GitHub icon, and the fine print sits above. Without it, the fine print shares the row with the icon. |
|
|
514
|
+
| `parentHref` | `string` | `"https://www.haruhime.moe"` | Where the wordmark links. |
|
|
515
|
+
| `githubHref` | `string \| false` | `"https://github.com/haruhimemoe"` | Where the GitHub icon links. `false` leaves it out. |
|
|
516
|
+
| `githubLabel` | `string` | `"haruhimemoe on GitHub"` | The GitHub link's accessible name. |
|
|
517
|
+
|
|
518
|
+
#### `PageShell`
|
|
519
|
+
|
|
520
|
+
The page frame: a skip link, the header, `<main>` and the footer, with the footer held to the bottom on short pages. Every native `<div>` prop; they and `ref` go on the outer wrapper `<div>`. Use `mainId` and `mainClassName` for `<main>`, and `document.getElementById(mainId)` to reach it from script.
|
|
521
|
+
|
|
522
|
+
| Prop | Type | Default | What it does |
|
|
523
|
+
| --- | --- | --- | --- |
|
|
524
|
+
| `children` | `ReactNode` | none | The page, inside `<main>` (`max-w-5xl`, centered, `px-4 py-10`). |
|
|
525
|
+
| `header` | `ReactNode` | none | Above `<main>`, usually a `SiteHeader`. |
|
|
526
|
+
| `footer` | `ReactNode` | none | Below `<main>`, usually a `SiteFooter`. |
|
|
527
|
+
| `skipLabel` | `string` | `"Skip to content"` | The skip link's text. It is the first thing Tab reaches and shows only when focused. |
|
|
528
|
+
| `mainId` | `string` | `"main"` | `<main>`'s id, which the skip link targets. |
|
|
529
|
+
| `mainClassName` | `string` | none | Extra classes for `<main>`, e.g. `"max-w-7xl"`. |
|
|
530
|
+
|
|
531
|
+
## Accessibility
|
|
532
|
+
|
|
533
|
+
- Every component is checked with axe against the WCAG 2.2 A and AA rules in the test suite (all but color contrast, which needs a real browser). Interactive ones also have keyboard tests.
|
|
534
|
+
- Focus is always visible: the theme draws an `h1` outline on `:focus-visible`. Fields show focus with an `h1` border instead (plus an `h1` ring when invalid), and `RangeSlider` thumbs with a solid `h1` ring. Those keep a transparent outline, so Windows high contrast mode (forced colors) still shows focus.
|
|
535
|
+
- In forced colors mode, a pressed `Chip` takes the system highlight colors, so on and off still look different.
|
|
536
|
+
- Form fields link their label, hint and error. An error sets `aria-invalid` and is announced.
|
|
537
|
+
- `Chip` uses `aria-pressed`. `ChipGroup`, `RangeSlider` and `FilterRow` are fieldsets named by their label. Inside a `FilterRow`, `hideLabel` leaves the naming to the row, so each row is announced once. `RangeSlider`'s thumbs are native range inputs with `aria-valuetext`, so "10+" reads as it shows.
|
|
538
|
+
- `FilterPanel`'s phone toggle carries `aria-expanded` and `aria-controls`. The result count is a live region. When "Clear filters" disappears after use, focus moves to the panel's heading instead of getting lost.
|
|
539
|
+
- `CopyButton` announces "Copied." (or the failure) through an `<output>`, on every press.
|
|
540
|
+
- `Pagination` moves focus to its "Page X of Y" text when the link you pressed goes away on the first or last page.
|
|
541
|
+
- `SiteHeader` marks the current page with `aria-current`. `PageShell` starts with a skip link to `<main>`.
|
|
542
|
+
- `GitHubIcon` is always hidden from screen readers: give the link around it an `aria-label`, as `SiteFooter` does.
|
|
543
|
+
- You supply the text, so you also supply labels: give icon-only buttons an `aria-label`, and keep `label` props meaningful.
|
|
544
|
+
|
|
545
|
+
## Compatibility
|
|
546
|
+
|
|
547
|
+
| | Supported |
|
|
548
|
+
| --- | --- |
|
|
549
|
+
| Next.js | 16 (app router). Components use `next/link` and `next/navigation`. |
|
|
550
|
+
| React | 19 |
|
|
551
|
+
| Tailwind CSS | 4.1 or later (4.x), through `@tailwindcss/postcss` |
|
|
552
|
+
| Module format | ESM only. Plain Node and Vitest can import it (for component tests in your app). |
|
|
553
|
+
| Theme | Dark only |
|
|
554
|
+
|
|
555
|
+
## Changelog and contributing
|
|
556
|
+
|
|
557
|
+
See [CHANGELOG.md](./CHANGELOG.md) for what changed in each version and [CONTRIBUTING.md](./CONTRIBUTING.md) to work on the package. Report security issues as described in [SECURITY.md](./SECURITY.md).
|
|
558
|
+
|
|
559
|
+
## License
|
|
560
|
+
|
|
561
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/CopyButton.tsx
|
|
3
|
+
* @desc A button that copies text to the clipboard and reports the result in an <output> beside
|
|
4
|
+
* it (a polite live region), like the packs export and doc pages. If the clipboard is
|
|
5
|
+
* missing or refuses, it says so and tells the reader to copy by hand. Each press empties
|
|
6
|
+
* the status first and then writes the result as a new node, so a second copy is announced
|
|
7
|
+
* too.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Wed Sep 23, 2026
|
|
10
|
+
* @modified Wed Sep 23, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { type ReactNode } from "react";
|
|
13
|
+
import { type ButtonProps } from "../basics/Button.js";
|
|
14
|
+
/** Every Button prop (native button props, variant, size) except children and onClick. */
|
|
15
|
+
export type CopyButtonProps = Omit<ButtonProps, "children" | "onClick"> & {
|
|
16
|
+
/** The text to copy. */
|
|
17
|
+
text: string;
|
|
18
|
+
/** The button's text. Defaults to "Copy". */
|
|
19
|
+
label?: ReactNode | undefined;
|
|
20
|
+
/** Status after a successful copy. Defaults to "Copied.". */
|
|
21
|
+
copiedMessage?: ReactNode | undefined;
|
|
22
|
+
/** Status when the clipboard is missing or refuses. */
|
|
23
|
+
failedMessage?: ReactNode | undefined;
|
|
24
|
+
/** Classes for the wrapper around the button and its status. */
|
|
25
|
+
wrapperClassName?: string | undefined;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* @function CopyButton
|
|
29
|
+
* @param props {CopyButtonProps} the text to copy, optional label and status messages, plus
|
|
30
|
+
* Button props (`variant` defaults to "secondary"; `className` styles the button itself)
|
|
31
|
+
* @returns {JSX.Element} the button and an `<output>` that announces "Copied." or the failure
|
|
32
|
+
*/
|
|
33
|
+
export declare function CopyButton({ text, label, copiedMessage, failedMessage, wrapperClassName, variant, ...props }: CopyButtonProps): import("react").JSX.Element;
|