@haruhimemoe/ui 0.3.0 → 0.5.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +336 -27
  3. package/dist/components/actions/AsyncButton.d.ts +30 -0
  4. package/dist/components/actions/AsyncButton.js +48 -0
  5. package/dist/components/actions/CopyButton.d.ts +3 -3
  6. package/dist/components/actions/CopyButton.js +9 -12
  7. package/dist/components/actions/InlineConfirm.d.ts +45 -0
  8. package/dist/components/actions/InlineConfirm.js +80 -0
  9. package/dist/components/actions/Pagination.d.ts +40 -17
  10. package/dist/components/actions/Pagination.js +25 -18
  11. package/dist/components/actions/PaginationButton.d.ts +25 -0
  12. package/dist/components/actions/PaginationButton.js +23 -0
  13. package/dist/components/actions/PaginationStatus.d.ts +7 -4
  14. package/dist/components/actions/PaginationStatus.js +7 -5
  15. package/dist/components/actions/StatusOutput.d.ts +20 -0
  16. package/dist/components/actions/StatusOutput.js +9 -0
  17. package/dist/components/actions/pages.d.ts +21 -0
  18. package/dist/components/actions/pages.js +21 -0
  19. package/dist/components/actions/useLatestStatus.d.ts +26 -0
  20. package/dist/components/actions/useLatestStatus.js +32 -0
  21. package/dist/components/basics/AutoLink.d.ts +21 -0
  22. package/dist/components/basics/AutoLink.js +26 -0
  23. package/dist/components/basics/Badge.d.ts +22 -0
  24. package/dist/components/basics/Badge.js +18 -0
  25. package/dist/components/basics/ButtonLink.d.ts +5 -6
  26. package/dist/components/basics/ButtonLink.js +5 -11
  27. package/dist/components/basics/Card.d.ts +4 -3
  28. package/dist/components/basics/Card.js +3 -2
  29. package/dist/components/basics/Disclosure.d.ts +34 -0
  30. package/dist/components/basics/Disclosure.js +30 -0
  31. package/dist/components/basics/Notice.d.ts +2 -1
  32. package/dist/components/basics/Tabs.d.ts +37 -0
  33. package/dist/components/basics/Tabs.js +47 -0
  34. package/dist/components/basics/TextLink.d.ts +21 -0
  35. package/dist/components/basics/TextLink.js +19 -0
  36. package/dist/components/basics/buttonStyles.d.ts +3 -1
  37. package/dist/components/basics/buttonStyles.js +1 -1
  38. package/dist/components/basics/cardStyles.d.ts +14 -0
  39. package/dist/components/basics/cardStyles.js +12 -0
  40. package/dist/components/basics/linkStyles.d.ts +23 -0
  41. package/dist/components/basics/linkStyles.js +23 -0
  42. package/dist/components/basics/tabIds.d.ts +23 -0
  43. package/dist/components/basics/tabIds.js +23 -0
  44. package/dist/components/filters/Chip.d.ts +13 -5
  45. package/dist/components/filters/Chip.js +20 -11
  46. package/dist/components/filters/ChipGroup.d.ts +11 -12
  47. package/dist/components/filters/ChipGroup.js +5 -10
  48. package/dist/components/filters/ChoiceChips.d.ts +38 -0
  49. package/dist/components/filters/ChoiceChips.js +31 -0
  50. package/dist/components/filters/FilterPanel.d.ts +5 -4
  51. package/dist/components/filters/FilterPanel.js +5 -4
  52. package/dist/components/filters/FilterRow.d.ts +1 -1
  53. package/dist/components/filters/FilterRow.js +3 -2
  54. package/dist/components/filters/GroupFrame.d.ts +27 -0
  55. package/dist/components/filters/GroupFrame.js +27 -0
  56. package/dist/components/filters/RangeBox.d.ts +28 -0
  57. package/dist/components/filters/RangeBox.js +41 -0
  58. package/dist/components/filters/RangeSlider.d.ts +13 -14
  59. package/dist/components/filters/RangeSlider.js +40 -103
  60. package/dist/components/filters/chipStyles.d.ts +17 -0
  61. package/dist/components/filters/chipStyles.js +17 -0
  62. package/dist/components/filters/rangeMath.d.ts +106 -0
  63. package/dist/components/filters/rangeMath.js +146 -0
  64. package/dist/components/forms/CharCounter.d.ts +25 -0
  65. package/dist/components/forms/CharCounter.js +12 -0
  66. package/dist/components/forms/Checkbox.d.ts +5 -4
  67. package/dist/components/forms/Checkbox.js +3 -3
  68. package/dist/components/forms/FieldFrame.d.ts +28 -2
  69. package/dist/components/forms/FieldFrame.js +14 -1
  70. package/dist/components/forms/RadioGroup.d.ts +46 -0
  71. package/dist/components/forms/RadioGroup.js +32 -0
  72. package/dist/components/forms/ReportDisclosure.d.ts +52 -0
  73. package/dist/components/forms/ReportDisclosure.js +54 -0
  74. package/dist/components/forms/Select.d.ts +1 -1
  75. package/dist/components/forms/Select.js +2 -2
  76. package/dist/components/forms/TextInput.d.ts +1 -1
  77. package/dist/components/forms/TextInput.js +2 -2
  78. package/dist/components/forms/Textarea.d.ts +1 -1
  79. package/dist/components/forms/Textarea.js +2 -2
  80. package/dist/components/forms/TypeToConfirm.d.ts +40 -0
  81. package/dist/components/forms/TypeToConfirm.js +46 -0
  82. package/dist/components/forms/VisibilitySelect.d.ts +52 -0
  83. package/dist/components/forms/VisibilitySelect.js +45 -0
  84. package/dist/components/forms/fieldStyles.d.ts +5 -2
  85. package/dist/components/forms/fieldStyles.js +5 -2
  86. package/dist/components/{actions → meta}/JsonLd.d.ts +2 -2
  87. package/dist/components/osu/BeatmapStats.d.ts +36 -0
  88. package/dist/components/osu/BeatmapStats.js +44 -0
  89. package/dist/components/osu/ModBadge.d.ts +23 -0
  90. package/dist/components/osu/ModBadge.js +22 -0
  91. package/dist/components/osu/StarRating.d.ts +26 -0
  92. package/dist/components/osu/StarRating.js +17 -0
  93. package/dist/components/osu/starColors.d.ts +23 -0
  94. package/dist/components/osu/starColors.js +48 -0
  95. package/dist/components/shell/HeaderMenu.d.ts +39 -0
  96. package/dist/components/shell/HeaderMenu.js +58 -0
  97. package/dist/components/shell/LinkNote.d.ts +16 -0
  98. package/dist/components/shell/LinkNote.js +17 -0
  99. package/dist/components/shell/LinkTabs.d.ts +29 -0
  100. package/dist/components/shell/LinkTabs.js +15 -0
  101. package/dist/components/shell/NavItem.d.ts +1 -7
  102. package/dist/components/shell/NavItem.js +5 -10
  103. package/dist/components/shell/NavLinks.d.ts +1 -1
  104. package/dist/components/shell/NavLinks.js +3 -3
  105. package/dist/components/shell/NavListClient.d.ts +1 -1
  106. package/dist/components/shell/NavListClient.js +4 -4
  107. package/dist/components/shell/SiteFooter.d.ts +6 -4
  108. package/dist/components/shell/SiteFooter.js +8 -6
  109. package/dist/components/shell/links.d.ts +8 -1
  110. package/dist/components/shell/links.js +8 -1
  111. package/dist/components/tables/TBody.d.ts +16 -0
  112. package/dist/components/tables/TBody.js +10 -0
  113. package/dist/components/tables/THead.d.ts +16 -0
  114. package/dist/components/tables/THead.js +10 -0
  115. package/dist/components/tables/Table.d.ts +26 -0
  116. package/dist/components/tables/Table.js +12 -0
  117. package/dist/components/tables/Td.d.ts +19 -0
  118. package/dist/components/tables/Td.js +11 -0
  119. package/dist/components/tables/Th.d.ts +20 -0
  120. package/dist/components/tables/Th.js +11 -0
  121. package/dist/components/tables/tableStyles.d.ts +12 -0
  122. package/dist/components/tables/tableStyles.js +12 -0
  123. package/dist/index.d.ts +28 -2
  124. package/dist/index.js +32 -2
  125. package/dist/utils/href.d.ts +7 -4
  126. package/dist/utils/href.js +23 -7
  127. package/package.json +3 -3
  128. package/dist/components/shell/AutoLink.d.ts +0 -19
  129. package/dist/components/shell/AutoLink.js +0 -23
  130. /package/dist/components/{actions → meta}/JsonLd.js +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-09-28
10
+
11
+ ### Added
12
+
13
+ - `Tabs`: an ARIA tab list for panels on the same page. The chosen tab is the only one in the Tab order; Left and Right (wrapping), Home and End pick and focus a tab. `tabId` and `tabPanelId` give the ids that tie a tab to its panel. Moved from bb.haruhime.moe.
14
+ - `CharCounter`: "1,234 / 60,000 characters", bold rose with how many to cut once over the limit. The caller counts. Moved from bb.haruhime.moe.
15
+ - `VisibilitySelect`: private, unlisted or public with a line each saying who sees it, as radios or a select, with the words overridable. Also `VISIBILITIES`, `VISIBILITY_TEXT` and the `Visibility` and `VisibilityText` types. It replaces the pools pool editor's radios and bb's template selects.
16
+ - `ReportDisclosure`: a report reason in a disclosure, sent through `onSubmit`, which returns a `ReportResult`; done replaces the form with a status line, an error stays on the field for a retry. Moved from bb.haruhime.moe (ReportForm).
17
+
18
+ ## [0.4.0] - 2026-09-28
19
+
20
+ ### Added
21
+
22
+ - `SiteFooter` takes `discordLabel` (default "Discord"), the Discord link's accessible name, as `githubLabel` does for GitHub.
23
+ - `HeadingLevel` (`2 | 3 | 4 | 5 | 6`), the type of `headingLevel` on `Card` and `FilterPanel`.
24
+ - `TextLink` and `linkClasses`: a text link in two looks, `accent` (`h1`, underlined, for running text) and `plain` (bold `c1`, underlined on hover, for names in lists). Like `ButtonLink`, it is `next/link` inside the app and a plain `<a>` off-site.
25
+ - `Badge`: a small pill for a status or tag, in `neutral`, `accent`, `warning` or `muted` (an outlined "beta" tag).
26
+ - Table primitives: `Table` (a sideways-scrolling wrapper, and a caption that can be for screen readers only), `THead`, `TBody`, `Th` (`scope="col"` by default, bold for `scope="row"`) and `Td`, with `numeric` for `tabular-nums` cells. They carry the look the apps' tables share.
27
+ - `cx`, the class merger the components use (tailwind-merge), and its `ClassValue` type.
28
+ - `InlineConfirm`: a two-step confirm in the page. Opening moves focus to cancel; cancel, Escape or a confirm that resolves puts it back on the trigger, so focus never falls to the page body. The open confirm is a group named by its question. A pending confirm keeps focus and ignores presses; a failed one stays open.
29
+ - `AsyncButton`: runs an async action and announces its result (or a failure, in rose) in an `<output>`, one run at a time, with an optional pending label.
30
+ - `Disclosure`: a button with `aria-expanded` and `aria-controls` that shows and hides a panel, which stays in the page while hidden. Uncontrolled, or controlled with `open` and `onOpenChange`.
31
+ - `RadioGroup`: a native radio fieldset on the `Checkbox` look, with a legend, per-option hints, and a group hint and error. Controlled or uncontrolled.
32
+ - `TypeToConfirm`: a form whose submit stays off until a name is typed exactly, for actions that can't be undone.
33
+ - `ChoiceChips`: single-select chips as native radios (arrow keys move and pick), with `Chip`'s look and `ChipGroup`'s `label` and `hideLabel`.
34
+ - `Chip` and `ChipOption` take `unavailableReason`: the chip is blocked but stays focusable (`aria-disabled`), with the reason as its description and title.
35
+ - `LinkTabs`: a named nav of pill links with `aria-current="page"` on the current one.
36
+ - `HeaderMenu`: the header's account disclosure (button, links, extra controls), closed by Escape (focus back on the button), a click outside, a link, or focus leaving it.
37
+ - osu! display pieces: `StarRating` (a pill on osu!'s star-rating spectrum that reads "5.23 stars"), `BeatmapStats` (CS, AR, OD, HP, BPM and length from plain numbers) and `ModBadge` (a slot pill colored by its mod bucket).
38
+ - `Pagination` button mode: `onPageChange` instead of `hrefFor`, for results fetched in place. `pageCount` can be `null` (the status reads "Page X", and `hasNext` says whether Next works). The ends stay in place with `aria-disabled`, and the status is a polite live region.
39
+
40
+ ### Changed
41
+
42
+ - `Card` takes `headingLevel` `5` and `6` too, like `FilterPanel`.
43
+
44
+ ### Fixed
45
+
46
+ - Hrefs that browsers read as off-site now count as external: `/\host`, `\\host`, and a URL behind leading spaces or control characters. Before, `ButtonLink` sent them through `next/link` without its `rel="noreferrer"` default, and a nav hydrated its client list for them.
47
+ - `Pagination` normalizes `page` and `pageCount`: `NaN` reads as page 1 (a `NaN` count as one page), a page past either end is pulled back inside, and fractions are dropped. Before, `page={NaN}` showed "Page NaN of 5" with no links, and page 99 of 5 linked to page 98.
48
+ - `CopyButton` reports only the latest press. A slow earlier copy that failed after a later one worked no longer replaces "Copied." with the failure.
49
+ - `SiteHeader` and `NavLinks` key entries by label and href, as `SiteFooter` does, so two entries with the same href no longer share a React key.
50
+ - `Checkbox` keeps an `aria-labelledby` you pass, after its own label. Before, it was dropped.
51
+ - `RangeSlider` counts a `step` of `0`, below `0` or not finite as `1`, and swaps `min` and `max` given the wrong way round. Before, `step={0}` sent `NaN` to `onChange` on every key press or drag.
52
+
53
+ ### Security
54
+
55
+ - The release workflow pins every GitHub Action to a full commit SHA and npm to an exact version, since that job holds the npm publish token. It also runs the coverage floor and the consumer check before publishing, as CI does. Dependabot (the `bun` and `github-actions` ecosystems) keeps the pins and the exact dependency versions current.
56
+ - Report vulnerabilities through GitHub's private vulnerability reporting first, or by email (SECURITY.md).
57
+
9
58
  ## [0.3.0] - 2026-09-25
10
59
 
11
60
  ### Added
@@ -37,7 +86,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
37
86
  - `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`).
38
87
  - 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).
39
88
 
40
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.3.0...HEAD
89
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.5.0...HEAD
90
+ [0.5.0]: https://github.com/haruhimemoe/ui/compare/v0.4.0...v0.5.0
91
+ [0.4.0]: https://github.com/haruhimemoe/ui/compare/v0.3.0...v0.4.0
41
92
  [0.3.0]: https://github.com/haruhimemoe/ui/compare/v0.2.0...v0.3.0
42
93
  [0.2.0]: https://github.com/haruhimemoe/ui/compare/v0.1.0...v0.2.0
43
94
  [0.1.0]: https://github.com/haruhimemoe/ui/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
+ <p align="center"><a href="https://github.com/haruhimemoe/ui"><picture><source media="(prefers-color-scheme: light)" srcset="https://www.haruhime.moe/brand/repos/ui-banner-on-light.svg"><img alt="@haruhimemoe/ui" src="https://www.haruhime.moe/brand/repos/ui-banner.svg" width="640"></picture></a></p>
2
+
1
3
  # @haruhimemoe/ui
2
4
 
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.
5
+ 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, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces and the site header, footer, tabs, account menu 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
6
 
5
7
  See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
6
8
 
7
- This README describes version 0.3.0. Anything marked "since 0.3.0" is not in 0.2.0, and anything marked "since 0.2.0" is not in 0.1.0. [CHANGELOG.md](./CHANGELOG.md) lists what changed in each version.
9
+ This README describes version 0.5.0. Anything marked "since 0.5.0" is not in 0.4.0, anything marked "since 0.4.0" is not in 0.3.0, anything marked "since 0.3.0" is not in 0.2.0, and anything marked "since 0.2.0" is not in 0.1.0. [CHANGELOG.md](./CHANGELOG.md) lists what changed in each version.
8
10
 
9
11
  ## Requirements
10
12
 
@@ -151,11 +153,11 @@ export default function Home() {
151
153
 
152
154
  Import every component from `@haruhimemoe/ui`, in Server and Client Components alike.
153
155
 
154
- - **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider` and `FilterPanel`. Each file starts with `"use client"`.
156
+ - **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider` and `FilterPanel`, and since 0.4.0 `AsyncButton`, `InlineConfirm`, `Disclosure`, `ChoiceChips`, `RadioGroup`, `TypeToConfirm` and `HeaderMenu`, and since 0.5.0 `Tabs`, `VisibilitySelect` and `ReportDisclosure`. Each file starts with `"use client"`. They merge their classes with tailwind-merge in the browser, so a page that renders any of them loads tailwind-merge (about 9 KB gzipped), however its header renders.
155
157
  - **`SiteHeader` and `NavLinks`** are Server Components with a small client part (since 0.2.0; in 0.1.0 `NavLinks` is a client component). When a nav link can be the current page (a path such as `/packs`), a client list reads the path to set `aria-current`. With only external or text-only links, the nav renders on the server alone and nothing in it hydrates. Relative hrefs (`#main`) skip the client list too, but they render `next/link`, which hydrates.
156
158
  - **Everything else is server-safe:** no state, no effects, no browser APIs.
157
159
 
158
- 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.
160
+ 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. Its button mode (`onPageChange`) is a callback, so render that from a `"use client"` file.
159
161
 
160
162
  ## Props, classes and refs
161
163
 
@@ -181,7 +183,7 @@ A pill button. Every native `<button>` prop.
181
183
 
182
184
  A link that looks like `Button`. Every `next/link` prop (`href`, `prefetch`, `replace`, `scroll`, `target`, `rel`...), plus `variant` and `size` as on `Button`.
183
185
 
184
- - A string `href` with a scheme (`https:`, `mailto:`) or starting with `//` renders a plain `<a>`, and `next/link`'s own props are dropped.
186
+ - A string `href` with a scheme (`https:`, `mailto:`) or starting with `//` renders a plain `<a>`, and `next/link`'s own props are dropped. The href is read the way the browser reads it: leading spaces don't count and a backslash counts as a slash, so `/\host` and `\\host` are off-site too (since 0.4.0).
185
187
  - That plain `<a>` with `target="_blank"` and no `rel` gets `rel="noreferrer"`. A `rel` you pass always wins. Internal links get only the `rel` you pass.
186
188
 
187
189
  #### `buttonClasses`
@@ -199,7 +201,7 @@ The osu!-web panel: rounded, `b4` background, `p-5`. Every native `<section>` pr
199
201
  | Prop | Type | Default | What it does |
200
202
  | --- | --- | --- | --- |
201
203
  | `title` | `ReactNode` | none | Rendered as a heading at the top (`<h2>` by default). It also names the section (`aria-labelledby`), which makes the card a region landmark. |
202
- | `headingLevel` | `2 \| 3 \| 4` | `2` | The title's heading level. Use `3` or `4` for a card that sits under another heading, such as a card inside a titled card. Since 0.2.0. |
204
+ | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | The title's heading level (type `HeadingLevel`). Use `3` or lower for a card that sits under another heading, such as a card inside a titled card. Since 0.2.0; `5` and `6` since 0.4.0. |
203
205
 
204
206
  #### `PageHeader`
205
207
 
@@ -251,6 +253,64 @@ The first element inside gets no top margin (`[&>:first-child]:mt-0`, since 0.2.
251
253
 
252
254
  Later sections keep their heading margin, which spaces them apart.
253
255
 
256
+ #### `Disclosure` (client)
257
+
258
+ Since 0.4.0. A button that shows and hides a panel below it, with `aria-expanded` and `aria-controls` and a ▾ / ▴ arrow. The closed panel stays in the page, hidden, so fields inside keep their values. Every native `<div>` prop except `children`, for the wrapper.
259
+
260
+ | Prop | Type | Default | What it does |
261
+ | --- | --- | --- | --- |
262
+ | `summary` | `ReactNode` | required | The button's text. It can change with the state. |
263
+ | `children` | `ReactNode` | required | The panel. |
264
+ | `defaultOpen` | `boolean` | `false` | Whether it starts open. |
265
+ | `open`, `onOpenChange` | `boolean`, `(open: boolean) => void` | none | Control the state from outside. |
266
+ | `buttonClassName`, `panelClassName` | `string` | none | Classes for the button and the panel, merged last. |
267
+
268
+ #### `TextLink`
269
+
270
+ Since 0.4.0. A text link: `next/link` inside the app, a plain `<a>` off-site (with `rel="noreferrer"` in a new tab), like `ButtonLink`. Every `next/link` prop. Type: `TextLinkVariant`.
271
+
272
+ | Prop | Type | Default | What it does |
273
+ | --- | --- | --- | --- |
274
+ | `variant` | `"accent" \| "plain"` | `"accent"` | `accent` is `h1` and underlined, for links in running text (it doesn't rely on color alone). `plain` is bold `c1`, underlined on hover, for a name or title in a list or table. |
275
+
276
+ ```tsx
277
+ <p>
278
+ Scripts can read public packs. <TextLink href="/docs/api">Read the API docs</TextLink>.
279
+ </p>
280
+ <TextLink href={`/packs/${pack.id}`} variant="plain">{pack.name}</TextLink>
281
+ ```
282
+
283
+ #### `linkClasses`
284
+
285
+ Since 0.4.0. `linkClasses({ variant?, className? }): string` returns the `TextLink` classes, for an element that should look like one (a `<button>` that reads as a link, say). Type: `LinkClassOptions`.
286
+
287
+ #### `Badge`
288
+
289
+ Since 0.4.0. A small pill for a status or tag ("Unranked", "beta", a count). A `<span>` with no role, so screen readers read it in line with the text around it. Every native `<span>` prop. Type: `BadgeTone`.
290
+
291
+ | Prop | Type | Default | What it does |
292
+ | --- | --- | --- | --- |
293
+ | `tone` | `"neutral" \| "accent" \| "warning" \| "muted"` | `"neutral"` | `neutral` is `b3` with `c2` text, `accent` is `h1` with dark bold text, `warning` is a faint amber with bold amber text, `muted` is an outlined pill in small bold capitals (a "beta" tag). |
294
+
295
+ #### `Tabs` (client)
296
+
297
+ Since 0.5.0. A tab list for panels on the same page (use `LinkTabs` when each tab is its own URL): pill buttons with `role="tab"` in a `role="tablist"` named by `label`. The chosen tab is `aria-selected` and the only one in the Tab order (the first one when none is chosen). Left and Right move and wrap, Home and End jump to the ends; each picks the tab and focuses it. Controlled. Every native `<div>` prop except `onChange` and `children`, for the tablist. Types: `TabItem<T>`, `TabsProps<T>`.
298
+
299
+ | Prop | Type | Default | What it does |
300
+ | --- | --- | --- | --- |
301
+ | `label` | `string` | required | The tablist's accessible name. |
302
+ | `idPrefix` | `string` | required | Prefix for the tabs' and panels' ids. |
303
+ | `tabs` | `readonly { id: T; label: ReactNode }[]` | required | The tabs, left to right. |
304
+ | `value` | `T` | required | The chosen tab's id. |
305
+ | `onChange` | `(id: T) => void` | required | Gets the picked tab's id. |
306
+
307
+ The panels are yours. `tabId(prefix, tab)` and `tabPanelId(prefix, tab)` (server-safe) give the ids that tie them together (`<prefix>-tab-<tab>`, `<prefix>-panel-<tab>`):
308
+
309
+ ```tsx
310
+ <Tabs label="Editor view" idPrefix="ed" tabs={TABS} value={tab} onChange={setTab} />
311
+ <div role="tabpanel" id={tabPanelId("ed", tab)} aria-labelledby={tabId("ed", tab)}>...</div>
312
+ ```
313
+
254
314
  ### Forms
255
315
 
256
316
  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.
@@ -285,7 +345,106 @@ Every native `<select>` prop, plus the field props. Pass `<option>` elements as
285
345
 
286
346
  #### `Checkbox`
287
347
 
288
- 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.
348
+ 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. An `aria-labelledby` you pass is added after the label (since 0.4.0; 0.3.0 dropped it).
349
+
350
+ #### `RadioGroup` (client)
351
+
352
+ Since 0.4.0. A native radio group on the `Checkbox` look: a `<fieldset>` named by its `<legend>`, one radio per option with a bold label and an inline hint, then the group's hint and error. Each option's label is its accessible name and its hint its description. Arrow keys move and pick, as native radios do. Every native `<fieldset>` prop except `onChange`, `children` and `defaultValue`; `disabled` turns off every radio. Types: `RadioOption`.
353
+
354
+ | Prop | Type | Default | What it does |
355
+ | --- | --- | --- | --- |
356
+ | `label` | `ReactNode` | required | The legend. |
357
+ | `options` | `readonly RadioOption[]` | required | `{ value: string; label: ReactNode; hint?: ReactNode; disabled?: boolean }` for each radio. |
358
+ | `value` / `defaultValue` | `string` | none | The picked value, held by you (`value`, with `onChange`) or by the group (`defaultValue`). |
359
+ | `onChange` | `(value: string) => void` | none | Gets the picked option's value. |
360
+ | `name` | `string` | generated | The radios' name, for a form. |
361
+ | `hint`, `error` | `ReactNode` | none | Under the options, linked to the group with `aria-describedby`. An error is a `role="alert"` and marks the radios `aria-invalid`. |
362
+ | `required` | `boolean` | `false` | Every radio gets `required`. |
363
+
364
+ #### `TypeToConfirm` (client)
365
+
366
+ Since 0.4.0. A confirm for something that can't be undone: a `<form>` whose submit button stays off until the expected text is typed exactly (spaces around it don't count). The field has no autocomplete, autocapitalize or spellcheck. Enter submits once it matches. Children show above the field, to say what the action does. Every native `<form>` prop except `onSubmit`.
367
+
368
+ | Prop | Type | Default | What it does |
369
+ | --- | --- | --- | --- |
370
+ | `id` | `string` | required | The text field's id, as on `TextInput`. |
371
+ | `expected` | `string` | required | What has to be typed. |
372
+ | `label` | `ReactNode` | `"Type <expected> to confirm"` | The field's label. |
373
+ | `hint`, `error` | `ReactNode` | none | As on `TextInput`. Pass `error` when the action fails. |
374
+ | `submitLabel` | `ReactNode` | required | The button's text. |
375
+ | `pendingLabel` | `ReactNode` | `submitLabel` | The button's text while `onConfirm` runs. It runs once at a time. |
376
+ | `onConfirm` | `() => void \| Promise<void>` | required | Runs on submit once the text matches. If it throws or rejects, the form stays as typed. |
377
+ | `variant` | `"primary" \| "secondary" \| "ghost"` | `"secondary"` | The button's look. |
378
+
379
+ ```tsx
380
+ <TypeToConfirm id="delete-pool" expected={pool.name} submitLabel="Delete this pool" onConfirm={remove} error={error}>
381
+ <p>This deletes the pool for everyone who edits it. It can't be undone.</p>
382
+ </TypeToConfirm>
383
+ ```
384
+
385
+ #### `CharCounter`
386
+
387
+ Since 0.5.0. How much of a length limit a text uses: "1,234 / 60,000 characters" in `c3`, then bold rose with ": 1,500 over the limit" once past it (at the limit is not over). You count, so any rule works (a string's length, a BBCode counter). Every native `<p>` prop except `children`.
388
+
389
+ | Prop | Type | Default | What it does |
390
+ | --- | --- | --- | --- |
391
+ | `count` | `number` | required | How many are used. |
392
+ | `limit` | `number` | required | The most allowed. |
393
+ | `unit` | `string` | `"characters"` | What is counted. |
394
+
395
+ #### `VisibilitySelect` (client)
396
+
397
+ Since 0.5.0. Who can see something: private, unlisted or public, each with a line that says who that is. As radios (a `RadioGroup`, every line shown as its option's description) or as a native select (a `Select`, the picked option's line as the hint). Controlled. Also exported: `VISIBILITIES` (`["private", "unlisted", "public"]`), the `Visibility` type, `VisibilityText` (`{ label: string; hint?: ReactNode }`) and `VISIBILITY_TEXT`, the default words ("Only you can see it.", "Anyone with the link can see it. It isn't listed.", "Anyone can see it, and it's listed.").
398
+
399
+ | Prop | Type | Default | What it does |
400
+ | --- | --- | --- | --- |
401
+ | `value` | `Visibility` | required | The picked visibility. |
402
+ | `onChange` | `(value: Visibility) => void` | required | Gets the new one. |
403
+ | `label` | `ReactNode` | `"Who can see it"` | The legend, or the select's label. |
404
+ | `as` | `"radio" \| "select"` | `"radio"` | Radios, or a dropdown. |
405
+ | `text` | `Partial<Record<Visibility, Partial<VisibilityText>>>` | none | Your words, merged over `VISIBILITY_TEXT` per visibility. |
406
+ | `id` | `string` | generated | The select's id, or the radios' name. |
407
+ | `hint` | `ReactNode` | none | Under the radios. On a select it replaces the picked option's line. |
408
+ | `error` | `ReactNode` | none | As on the fields. |
409
+ | `disabled` | `boolean` | `false` | Turns it off. |
410
+ | `className` | `string` | none | Classes for the fieldset, or the select's wrapper. |
411
+
412
+ ```tsx
413
+ <VisibilitySelect
414
+ label="Who can see this pool"
415
+ value={visibility}
416
+ onChange={setVisibility}
417
+ text={{ private: { hint: "Only you and your editors." } }}
418
+ />
419
+ ```
420
+
421
+ #### `ReportDisclosure` (client)
422
+
423
+ Since 0.5.0. "Report this": a `Disclosure` holding a reason `Textarea` (required) and a submit button. `onSubmit` gets the trimmed reason and says how it went: `{ ok: true, message? }` replaces the form with a `role="status"` line (your message, like "You already reported it.", or `sentMessage`); `{ ok: false, message }` shows the message as the field's error and keeps what was typed for a retry. A throw reads as `failedMessage`. One send at a time. Type: `ReportResult`.
424
+
425
+ | Prop | Type | Default | What it does |
426
+ | --- | --- | --- | --- |
427
+ | `onSubmit` | `(reason: string) => Promise<ReportResult>` | required | Sends the report. |
428
+ | `summary` | `ReactNode` | `"Report"` | The disclosure button's text. |
429
+ | `label` | `ReactNode` | `"What's wrong with it?"` | The reason field's label. |
430
+ | `hint` | `ReactNode` | none | Help under the field. |
431
+ | `submitLabel`, `pendingLabel` | `ReactNode` | `"Send report"`, `"Sending…"` | The button's text, and while sending. |
432
+ | `sentMessage` | `ReactNode` | `"Thanks. Your report was sent."` | Said when the result has no message. |
433
+ | `failedMessage` | `ReactNode` | `"Couldn't send the report. Try again."` | Said when `onSubmit` throws. |
434
+ | `minLength`, `maxLength` | `number` | `3`, none | The reason's length limits. |
435
+ | `rows` | `number` | `3` | The field's height. |
436
+ | `className` | `string` | none | Classes for the wrapper (or the status line once sent). |
437
+
438
+ ```tsx
439
+ <ReportDisclosure
440
+ summary="Report this template"
441
+ maxLength={500}
442
+ onSubmit={async (reason) => {
443
+ const response = await fetch(`/api/templates/${id}/report`, { method: "POST", body: JSON.stringify({ reason }) });
444
+ return response.ok ? { ok: true } : { ok: false, message: "Reporting failed." };
445
+ }}
446
+ />
447
+ ```
289
448
 
290
449
  #### `fieldClasses`
291
450
 
@@ -299,7 +458,7 @@ Every native `<input>` prop except `type` (`checked`, `defaultChecked`, `onChang
299
458
 
300
459
  #### `CopyButton` (client)
301
460
 
302
- 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.
461
+ 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. Only the latest press reports: an earlier copy that settles later (behind a permission prompt, say) doesn't overwrite it (since 0.4.0). 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.
303
462
 
304
463
  | Prop | Type | Default | What it does |
305
464
  | --- | --- | --- | --- |
@@ -313,26 +472,56 @@ A button that copies text, with the result in an `<output>` beside it that scree
313
472
 
314
473
  #### `Pagination`
315
474
 
316
- 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"`.
475
+ Previous and next pills around "Page X of Y": links (`hrefFor`), or since 0.4.0 buttons (`onPageChange`). Renders nothing when there is one page or none. Every native `<nav>` prop except `children`; `aria-label` defaults to `"Pages"`.
317
476
 
318
477
  | Prop | Type | Default | What it does |
319
478
  | --- | --- | --- | --- |
320
479
  | `page` | `number` | required | The current page, starting at 1. |
321
480
  | `pageCount` | `number` | required | How many pages there are. |
322
- | `hrefFor` | `(page: number) => string` | required | Builds a page's URL, e.g. `` (p) => `/packs?page=${p}` ``. |
481
+ | `hrefFor` | `(page: number) => string` | one of the two | Link mode: builds a page's URL, e.g. `` (p) => `/packs?page=${p}` ``. |
482
+ | `onPageChange` | `(page: number) => void` | one of the two | Button mode (since 0.4.0): called with the page to show, for results fetched in place. `pageCount` may be `null` there. |
483
+ | `hasNext` | `boolean` | `false` | Button mode with `pageCount={null}`: whether a page comes after this one. |
323
484
  | `previousLabel` | `ReactNode` | `"Previous"` | Text of the link to the page before. |
324
485
  | `nextLabel` | `ReactNode` | `"Next"` | Text of the link to the page after. |
325
- | `formatStatus` | `(page: number, pageCount: number) => ReactNode` | `"Page X of Y"` | The text in the middle. |
486
+ | `formatStatus` | `(page: number, pageCount: number) => ReactNode` | `"Page X of Y"` | The text in the middle. In button mode `pageCount` can be `null`, and the default reads "Page X". |
487
+
488
+ A `page` or `pageCount` straight from a URL is safe to pass (since 0.4.0): `NaN` reads as page 1 (and a `NaN` count as one page), a page past either end is pulled back inside, and fractions are dropped. `Number("abc")` shows page 1 with a Next link, and page 99 of 5 shows page 5.
326
489
 
327
490
  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.
328
491
 
329
- #### `JsonLd`
492
+ In button mode, Previous and Next are `<button>`s. At the first or last page they stay in place, dimmed, with `aria-disabled="true"`: they keep focus and ignore presses. The status is a polite live region there, so each new page is announced.
493
+
494
+ ```tsx
495
+ <Pagination page={page} pageCount={null} hasNext={data.hasMore} onPageChange={setPage} />
496
+ ```
330
497
 
331
- 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.
498
+ #### `AsyncButton` (client)
499
+
500
+ Since 0.4.0. A button that runs an async action and reports how it went in an `<output>` beside it that screen readers announce, like `CopyButton`. While it runs, the button keeps focus but ignores presses (`aria-disabled`). Every `Button` prop except `onClick`; the children are its text.
332
501
 
333
502
  | Prop | Type | Default | What it does |
334
503
  | --- | --- | --- | --- |
335
- | `data` | `Record<string, unknown>` | required | The schema.org object. `@context` defaults to `https://schema.org`; set it in `data` to change it. |
504
+ | `action` | `() => ReactNode \| Promise<ReactNode>` | required | Runs on press. What it returns is the message ("Done."). |
505
+ | `pendingLabel` | `ReactNode` | the children | The button's text while the action runs. |
506
+ | `failedMessage` | `ReactNode \| ((error: unknown) => ReactNode)` | `"Something went wrong. Try again."` | Shown in `text-rose-300` when the action throws or rejects. |
507
+ | `wrapperClassName` | `string` | none | Classes for the wrapper around the button and the message. |
508
+
509
+ #### `InlineConfirm` (client)
510
+
511
+ Since 0.4.0. A two-step confirm in the page, no dialog: a trigger button, then the question with a cancel and a confirm button in its place. Opening moves focus to cancel, the safe choice. Cancel or Escape closes it and puts focus back on the trigger, and so does a confirm that resolves. The open confirm is a `<fieldset>` (a group) named by the question, so screen readers hear the question when focus arrives. Every native `<div>` prop except `children`, for the wrapper.
512
+
513
+ | Prop | Type | Default | What it does |
514
+ | --- | --- | --- | --- |
515
+ | `trigger` | `ReactNode` | required | The first button's text. |
516
+ | `question` | `ReactNode` | required | Shown once the trigger is pressed. |
517
+ | `onConfirm` | `() => void \| Promise<void>` | required | Runs on confirm. While it runs, both buttons keep focus but ignore presses. If it throws or rejects, the confirm stays open: show the error yourself. |
518
+ | `confirmLabel`, `cancelLabel` | `ReactNode` | `"Confirm"`, `"Cancel"` | The two buttons' text. |
519
+ | `pendingLabel` | `ReactNode` | `confirmLabel` | The confirm button's text while `onConfirm` runs. |
520
+ | `onCancel` | `() => void` | none | Called when it closes without confirming. |
521
+ | `triggerProps` | `ButtonProps` | none | Props for the trigger (`aria-label`, `variant`, `disabled`). It is `secondary` by default. |
522
+ | `confirmVariant` | `"primary" \| "secondary" \| "ghost"` | `"secondary"` | The confirm button's look. Cancel is always `ghost`. |
523
+
524
+ If a confirm removes the item (and the `InlineConfirm` with it), move focus somewhere sensible yourself, such as the list's heading.
336
525
 
337
526
  ### Icons
338
527
 
@@ -366,7 +555,7 @@ The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and p
366
555
 
367
556
  #### `HaruhimeWordmarkLink`
368
557
 
369
- A plain `<a>` around a decorative `HaruhimeWordmark`, dimmed until hovered. Every native `<a>` prop.
558
+ A plain `<a>` around a decorative `HaruhimeWordmark`, dimmed until hovered. Every native `<a>` prop except `children`.
370
559
 
371
560
  | Prop | Type | Default | What it does |
372
561
  | --- | --- | --- | --- |
@@ -436,13 +625,14 @@ Give the `ChipGroup` or `RangeSlider` inside a `FilterRow` `hideLabel`. The row'
436
625
 
437
626
  #### `Chip` (client)
438
627
 
439
- A toggle pill: a `<button>` with `aria-pressed`, `h1` when on. Every native `<button>` prop.
628
+ A toggle pill: a `<button>` with `aria-pressed`, `h1` when on. Every native `<button>` prop except `aria-pressed`, which `pressed` sets.
440
629
 
441
630
  | Prop | Type | Default | What it does |
442
631
  | --- | --- | --- | --- |
443
632
  | `pressed` | `boolean` | required | Whether it is on. |
444
633
  | `onPressedChange` | `(pressed: boolean) => void` | none | Called with the new state on click, Enter or Space. |
445
634
  | `type` | `"button" \| "submit" \| "reset"` | `"button"` | Never submits a form by default. |
635
+ | `unavailableReason` | `ReactNode` | none | Since 0.4.0. Why the chip can't be pressed right now (EZ with HR picked). The chip is dimmed and never toggles, but stays in the tab order (`aria-disabled="true"`, not `disabled`), so keyboard and screen reader users find it and hear the reason as its description. A string reason is also its `title`. |
446
636
 
447
637
  Your own `onClick` runs first. Call `event.preventDefault()` in it to skip the toggle.
448
638
 
@@ -454,20 +644,33 @@ A labelled row of chips for picking several values (mods, game modes). A `<field
454
644
  | --- | --- | --- | --- |
455
645
  | `label` | `ReactNode` | required | Names the group. |
456
646
  | `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. |
457
- | `options` | `readonly ChipOption[]` | required | `{ value: string; label: ReactNode; disabled?: boolean }` for each chip. |
647
+ | `options` | `readonly ChipOption[]` | required | `{ value: string; label: ReactNode; disabled?: boolean; unavailableReason?: ReactNode }` for each chip (`unavailableReason` since 0.4.0, as on `Chip`). |
458
648
  | `value` | `readonly string[]` | required | The picked values. |
459
649
  | `onChange` | `(value: string[]) => void` | required | Gets the new picked values, in the options' order, without duplicates. |
460
650
 
651
+ #### `ChoiceChips` (client)
652
+
653
+ Since 0.4.0. One choice from a few, as a real radio group drawn as chips (a status or type filter): native radios under one name, so Tab reaches the checked chip and the arrow keys move and pick. Same look as `Chip`, with the focus ring on the chip. A `<fieldset>` like `ChipGroup`, with the same `label` and `hideLabel`; every native `<fieldset>` prop except `onChange`, `children` and `defaultValue`. Type: `ChoiceChipOption`.
654
+
655
+ | Prop | Type | Default | What it does |
656
+ | --- | --- | --- | --- |
657
+ | `label` | `ReactNode` | required | Names the group. |
658
+ | `hideLabel` | `boolean` | `false` | As on `ChipGroup`, inside a `FilterRow`. |
659
+ | `options` | `readonly ChoiceChipOption<T>[]` | required | `{ value: T; label: ReactNode; disabled?: boolean }` for each chip. |
660
+ | `value` | `T` | required | The picked value. |
661
+ | `onChange` | `(value: T) => void` | required | Gets the picked value. |
662
+ | `name` | `string` | generated | The radios' name, for a form. |
663
+
461
664
  #### `RangeSlider` (client)
462
665
 
463
- 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.
666
+ 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`, `children` and `inputMode`. Type: `RangeSliderValue` (`[number, number | null]`), so `useState<RangeSliderValue>` can pass its setter straight in.
464
667
 
465
668
  | Prop | Type | Default | What it does |
466
669
  | --- | --- | --- | --- |
467
670
  | `label` | `string` | required | Names the group, and the ends as "Minimum *label*" and "Maximum *label*". |
468
671
  | `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. |
469
- | `min`, `max` | `number` | required | The bounds. |
470
- | `step` | `number` | `1` | Step between values. Typed values snap to it. |
672
+ | `min`, `max` | `number` | required | The bounds. Given the wrong way round (`max` below `min`), they swap (since 0.4.0). |
673
+ | `step` | `number` | `1` | Step between values. Typed values snap to it. A step of `0`, below `0` or not finite counts as `1` (since 0.4.0), so `onChange` never gets `NaN`. |
471
674
  | `value` | `readonly [number, number \| null]` | required | The range. A `null` top means no upper limit. |
472
675
  | `onChange` | `(value: [number, number \| null]) => void` | required | Gets the new range. |
473
676
  | `openEnded` | `boolean` | `false` | The top end at `max` means "no upper limit": it shows `max+` (like `10+`) and reports `null`. |
@@ -494,13 +697,23 @@ A titled panel of `FilterRow`s with a live result count and a "Clear filters" bu
494
697
  | Prop | Type | Default | What it does |
495
698
  | --- | --- | --- | --- |
496
699
  | `title` | `ReactNode` | required | The heading. It also names the panel and the phone toggle. |
497
- | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | The heading's level. |
700
+ | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | The heading's level (type `HeadingLevel`). |
498
701
  | `resultCount` | `ReactNode` | none | Shown in a polite live region, so each new count is announced. |
499
702
  | `active` | `boolean` | `false` | Whether any filter is set. |
500
703
  | `onClear` | `() => void` | none | The clear button's action. The button shows only when `active` is true and this is set. |
501
704
  | `clearLabel` | `ReactNode` | `"Clear filters"` | The clear button's text. |
502
705
  | `defaultOpen` | `boolean` | `false` | Whether the rows start open on phones. |
503
706
 
707
+ ### Meta
708
+
709
+ #### `JsonLd`
710
+
711
+ 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. Every native `<script>` prop except `children`, `dangerouslySetInnerHTML`, `type` and `src` (`id` and `nonce` pass through).
712
+
713
+ | Prop | Type | Default | What it does |
714
+ | --- | --- | --- | --- |
715
+ | `data` | `Record<string, unknown>` | required | The schema.org object. `@context` defaults to `https://schema.org`; set it in `data` to change it. |
716
+
504
717
  ### Shell
505
718
 
506
719
  Links in the header and footer are data (type `SiteLinkItem`):
@@ -509,11 +722,11 @@ Links in the header and footer are data (type `SiteLinkItem`):
509
722
  type SiteLinkItem = { label: string; href?: string; note?: string };
510
723
  ```
511
724
 
512
- Paths use `next/link`; anything with a scheme (`https:`, `mailto:`) or starting with `//` is a plain `<a>`. An item without `href` shows as plain text. `note` adds a word beside it in small uppercase letters (`{ label: "Pools", note: "soon" }`). The header dims text-only items and shows the note only on them. The footer shows a note beside a link too.
725
+ Paths use `next/link`; anything with a scheme (`https:`, `mailto:`) or starting with `//` is a plain `<a>` (and, since 0.4.0, `/\host`, `\\host` or a URL behind leading spaces, which browsers also read as off-site). An item without `href` shows as plain text. `note` adds a word beside it in small uppercase letters (`{ label: "Sheets", note: "soon" }`). The header dims text-only items and shows the note only on them. The footer shows a note beside a link too.
513
726
 
514
727
  #### `SiteHeader`
515
728
 
516
- 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`.
729
+ The dark top bar: brand on the left, the nav, and an actions slot on the right. Every native `<header>` prop except `children`. A Server Component; the nav list inside is `NavLinks`.
517
730
 
518
731
  | Prop | Type | Default | What it does |
519
732
  | --- | --- | --- | --- |
@@ -533,7 +746,7 @@ Next bundles every client component a route imports, rendered or not. So a page
533
746
 
534
747
  #### `NavLinks`
535
748
 
536
- The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop. Type: `SiteNavAlign`. It works in Server and Client Components. Since 0.2.0 it is a Server Component with a small client part (in 0.1.0 it is a client component). From a Server Component it behaves like `SiteHeader`'s nav. Inside a Client Component (a header with a menu toggle, say), it renders in the browser with the rest of that component: it merges its classes there, so tailwind-merge ships in that page's bundle.
749
+ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop except `children`. Type: `SiteNavAlign`. It works in Server and Client Components. Since 0.2.0 it is a Server Component with a small client part (in 0.1.0 it is a client component). From a Server Component it behaves like `SiteHeader`'s nav. Inside a Client Component (a header with a menu toggle, say), it renders in the browser with the rest of that component: it merges its classes there, so tailwind-merge ships in that page's bundle.
537
750
 
538
751
  | Prop | Type | Default | What it does |
539
752
  | --- | --- | --- | --- |
@@ -542,7 +755,7 @@ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it insi
542
755
 
543
756
  #### `SiteFooter`
544
757
 
545
- Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub icon link and an optional Discord icon link. Every native `<footer>` prop. Type: `SiteFooterColumn` (`{ title: string; items: readonly SiteLinkItem[] }`).
758
+ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub icon link and an optional Discord icon link. Every native `<footer>` prop except `children`. Type: `SiteFooterColumn` (`{ title: string; items: readonly SiteLinkItem[] }`).
546
759
 
547
760
  | Prop | Type | Default | What it does |
548
761
  | --- | --- | --- | --- |
@@ -553,7 +766,30 @@ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub ico
553
766
  | `parentHref` | `string` | `"https://www.haruhime.moe"` | Where the wordmark links. |
554
767
  | `githubHref` | `string \| false` | `"https://github.com/haruhimemoe"` | Where the GitHub icon links. `false` leaves it out. |
555
768
  | `githubLabel` | `string` | `"haruhimemoe on GitHub"` | The GitHub link's accessible name. |
556
- | `discordHref` | `string` | none | Where the Discord icon links, such as your server's invite (`https://discord.gg/...`). Without it there is no Discord icon. The icon sits before the GitHub icon at the same size. It stays white (`text-c1`) and dims on hover instead of changing color, since Discord's brand guidelines ask that the logo not be recolored. The link's accessible name is "Discord". Since 0.3.0. |
769
+ | `discordHref` | `string` | none | Where the Discord icon links, such as your server's invite (`https://discord.gg/...`). Without it there is no Discord icon. The icon sits before the GitHub icon at the same size. It stays white (`text-c1`) and dims on hover instead of changing color, since Discord's brand guidelines ask that the logo not be recolored. Since 0.3.0. |
770
+ | `discordLabel` | `string` | `"Discord"` | The Discord link's accessible name. Since 0.4.0. |
771
+
772
+ #### `LinkTabs`
773
+
774
+ Since 0.4.0. A row of link tabs (Pools / Maps, All / Hidden): a named `<nav>` with a list of pill links. The current one gets `aria-current="page"` and a `b3` pill (underlined in forced colors mode). These are links, not ARIA tabs, since each loads its own URL. A Server Component: you say which is current. Every native `<nav>` prop except `children`. Type: `LinkTabItem`.
775
+
776
+ | Prop | Type | Default | What it does |
777
+ | --- | --- | --- | --- |
778
+ | `label` | `string` | required | The nav landmark's accessible name. |
779
+ | `items` | `readonly LinkTabItem[]` | required | `{ href: string; label: ReactNode; current?: boolean }` for each tab, each with its own href. |
780
+
781
+ #### `HeaderMenu` (client)
782
+
783
+ Since 0.4.0. The header's account menu: a button (an avatar and a name, say) that shows a small panel of links and extra controls such as a sign-out button. It is a disclosure, not an ARIA menu: the button has `aria-expanded` and `aria-controls`, and Tab moves through the panel. Escape closes it and puts focus back on the button; a click outside it, a click on one of its links, or focus leaving it closes it too. Every native `<div>` prop except `children`, for the wrapper. Type: `HeaderMenuItem`.
784
+
785
+ | Prop | Type | Default | What it does |
786
+ | --- | --- | --- | --- |
787
+ | `label` | `ReactNode` | required | The button's content. |
788
+ | `items` | `readonly HeaderMenuItem[]` | `[]` | `{ href: string; label: ReactNode }` links, top to bottom. |
789
+ | `children` | `ReactNode` | none | Shown after the links, such as a sign-out button. |
790
+ | `align` | `"start" \| "end"` | `"end"` | Which edge of the button the panel lines up with. |
791
+ | `buttonLabel` | `string` | none | The button's accessible name, when its content is only an image. |
792
+ | `buttonClassName` | `string` | none | Classes for the button, merged last. |
557
793
 
558
794
  #### `PageShell`
559
795
 
@@ -568,6 +804,78 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
568
804
  | `mainId` | `string` | `"main"` | `<main>`'s id, which the skip link targets. |
569
805
  | `mainClassName` | `string` | none | Extra classes for `<main>`, e.g. `"max-w-7xl"`. |
570
806
 
807
+ ### osu!
808
+
809
+ Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (no osu! API types) and are Server Components.
810
+
811
+ #### `StarRating`
812
+
813
+ A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rating's color, dark text up to 6.5 and pale yellow above. The spectrum is osu!'s own, the same at every `--hue`, so the pill sets its colors inline. Screen readers hear "5.23 stars" and the `label` after it. Every native `<span>` prop except `children`; a `style` you pass merges over the colors.
814
+
815
+ | Prop | Type | Default | What it does |
816
+ | --- | --- | --- | --- |
817
+ | `value` | `number` | required | The rating, shown with two decimals. `NaN` shows as "–" on grey. |
818
+ | `label` | `ReactNode` | none | Read after the rating by screen readers, e.g. "with HR" (what a `title` tells mouse users). |
819
+ | `unit` | `string` | `"stars"` | The word read after the number. |
820
+
821
+ #### `BeatmapStats`
822
+
823
+ A beatmap's CS, AR, OD, HP, BPM and length as a compact `<dl>`, in that order. Stats you leave out (or that aren't finite) don't show. CS, AR, OD, HP and BPM are `<abbr>`s titled with their full names. Every native `<dl>` prop except `children`. Type: `BeatmapStatKey`.
824
+
825
+ | Prop | Type | Default | What it does |
826
+ | --- | --- | --- | --- |
827
+ | `cs`, `ar`, `od`, `hp` | `number \| null` | none | Shown with at most one decimal. |
828
+ | `bpm` | `number \| null` | none | Shown whole. |
829
+ | `lengthSeconds` | `number \| null` | none | Shown as `m:ss`, or `h:mm:ss` from an hour. |
830
+ | `labels` | `Partial<Record<BeatmapStatKey, ReactNode>>` | none | Replaces a label (`{ length: "Länge" }`). |
831
+
832
+ #### `ModBadge`
833
+
834
+ A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: NM sky, HD amber, HR rose, DT and NC violet, FM emerald, TB orange, with dark text. Anything else is a `b3` pill. Every native `<span>` prop; `children` replace the text, and `className` recolors it (`bg-pink-300` for a custom bucket).
835
+
836
+ | Prop | Type | Default | What it does |
837
+ | --- | --- | --- | --- |
838
+ | `mod` | `string` | required | The mod or slot label. |
839
+
840
+ ### Tables
841
+
842
+ Since 0.4.0. `Table`, `THead`, `TBody`, `Th` and `Td` give a data table the apps' look: full width, small left-aligned text, muted capitals in the head and a rule above each body row. They are Server Components, and each takes its element's native props, `className` (merged last) and `ref`. Use plain `<tr>` for rows.
843
+
844
+ ```tsx
845
+ <Table caption="The pool's maps" hideCaption>
846
+ <THead>
847
+ <tr>
848
+ <Th>Slot</Th>
849
+ <Th numeric>Stars</Th>
850
+ </tr>
851
+ </THead>
852
+ <TBody>
853
+ {slots.map((slot) => (
854
+ <tr key={slot.label}>
855
+ <Th scope="row">{slot.label}</Th>
856
+ <Td numeric>{slot.stars}</Td>
857
+ </tr>
858
+ ))}
859
+ </TBody>
860
+ </Table>
861
+ ```
862
+
863
+ | Component | Extra props | What it renders |
864
+ | --- | --- | --- |
865
+ | `Table` | `caption?: ReactNode`, `hideCaption?: boolean` (default `false`), `wrapperClassName?: string` | A `<div>` that scrolls sideways on phones, around the `<table>`. `className` and `ref` go on the `<table>`. The caption names the table; `hideCaption` keeps it for screen readers only, when a heading already shows. |
866
+ | `THead` | none | A `<thead>` in `c3`, `text-xs`, uppercase. |
867
+ | `TBody` | none | A `<tbody>` whose rows get a `b4` top border. Add `[&>tr]:align-top` for rows of mixed height. |
868
+ | `Th` | `numeric?: boolean` | A `<th>` with `scope="col"` by default. With `scope="row"` it is a row's heading, in bold `c1`. |
869
+ | `Td` | `numeric?: boolean` | A `<td>`. `numeric` lines up digits (`tabular-nums`). |
870
+
871
+ Cells get `py-2 pr-3`, and the last cell of a row no right padding.
872
+
873
+ ### Utilities
874
+
875
+ #### `cx`
876
+
877
+ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every component uses: it skips falsy values and resolves Tailwind conflicts with tailwind-merge, so a later class wins (`cx("w-full", cond && "w-auto")`). Type: `ClassValue` (`string | false | null | undefined | 0`). It imports tailwind-merge, so a client file that uses it sends tailwind-merge to the browser.
878
+
571
879
  ## Accessibility
572
880
 
573
881
  - Every component is checked in the test suite with axe-core's WCAG 2.0, 2.1 and 2.2 A and AA rules (all but color contrast, which needs a real browser). Interactive ones also have keyboard tests. The tests also calculate the contrast figures under Setup (`c1` on `h2`, `h1` on `b4`, and the `--h1-l` and `--h2-l` values).
@@ -578,6 +886,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
578
886
  - `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.
579
887
  - `CopyButton` announces "Copied." (or the failure) through an `<output>`, on every press.
580
888
  - `Pagination` moves focus to its "Page X of Y" text when the link you pressed goes away on the first or last page.
889
+ - `Tabs` follows the ARIA tabs pattern: one tab in the Tab order, arrows, Home and End to move, `aria-controls` to its panel. `ReportDisclosure` says the outcome in a `role="status"` line and keeps a failed reason in the field.
581
890
  - `SiteHeader` marks the current page with `aria-current`. `PageShell` starts with a skip link to `<main>`.
582
891
  - `DiscordIcon` and `GitHubIcon` are hidden from screen readers by default: give the link around each one an `aria-label`, as `SiteFooter` does.
583
892
  - You supply the text, so you also supply labels: give icon-only buttons an `aria-label`, and keep `label` props meaningful.
@@ -595,7 +904,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
595
904
 
596
905
  ## Changelog and contributing
597
906
 
598
- 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).
907
+ 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). Bring questions and feedback to the haruhime.moe [Discord server](https://discord.gg/bKy9kjMV4y).
599
908
 
600
909
  ## License
601
910