@haruhimemoe/ui 0.3.0 → 0.4.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 (120) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +252 -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/TextLink.d.ts +21 -0
  33. package/dist/components/basics/TextLink.js +19 -0
  34. package/dist/components/basics/buttonStyles.d.ts +3 -1
  35. package/dist/components/basics/buttonStyles.js +1 -1
  36. package/dist/components/basics/cardStyles.d.ts +14 -0
  37. package/dist/components/basics/cardStyles.js +12 -0
  38. package/dist/components/basics/linkStyles.d.ts +23 -0
  39. package/dist/components/basics/linkStyles.js +23 -0
  40. package/dist/components/filters/Chip.d.ts +13 -5
  41. package/dist/components/filters/Chip.js +20 -11
  42. package/dist/components/filters/ChipGroup.d.ts +11 -12
  43. package/dist/components/filters/ChipGroup.js +5 -10
  44. package/dist/components/filters/ChoiceChips.d.ts +38 -0
  45. package/dist/components/filters/ChoiceChips.js +31 -0
  46. package/dist/components/filters/FilterPanel.d.ts +5 -4
  47. package/dist/components/filters/FilterPanel.js +5 -4
  48. package/dist/components/filters/FilterRow.d.ts +1 -1
  49. package/dist/components/filters/FilterRow.js +3 -2
  50. package/dist/components/filters/GroupFrame.d.ts +27 -0
  51. package/dist/components/filters/GroupFrame.js +27 -0
  52. package/dist/components/filters/RangeBox.d.ts +28 -0
  53. package/dist/components/filters/RangeBox.js +41 -0
  54. package/dist/components/filters/RangeSlider.d.ts +13 -14
  55. package/dist/components/filters/RangeSlider.js +40 -103
  56. package/dist/components/filters/chipStyles.d.ts +17 -0
  57. package/dist/components/filters/chipStyles.js +17 -0
  58. package/dist/components/filters/rangeMath.d.ts +106 -0
  59. package/dist/components/filters/rangeMath.js +146 -0
  60. package/dist/components/forms/Checkbox.d.ts +5 -4
  61. package/dist/components/forms/Checkbox.js +3 -3
  62. package/dist/components/forms/FieldFrame.d.ts +28 -2
  63. package/dist/components/forms/FieldFrame.js +14 -1
  64. package/dist/components/forms/RadioGroup.d.ts +46 -0
  65. package/dist/components/forms/RadioGroup.js +32 -0
  66. package/dist/components/forms/Select.d.ts +1 -1
  67. package/dist/components/forms/Select.js +2 -2
  68. package/dist/components/forms/TextInput.d.ts +1 -1
  69. package/dist/components/forms/TextInput.js +2 -2
  70. package/dist/components/forms/Textarea.d.ts +1 -1
  71. package/dist/components/forms/Textarea.js +2 -2
  72. package/dist/components/forms/TypeToConfirm.d.ts +40 -0
  73. package/dist/components/forms/TypeToConfirm.js +46 -0
  74. package/dist/components/forms/fieldStyles.d.ts +5 -2
  75. package/dist/components/forms/fieldStyles.js +5 -2
  76. package/dist/components/{actions → meta}/JsonLd.d.ts +2 -2
  77. package/dist/components/osu/BeatmapStats.d.ts +36 -0
  78. package/dist/components/osu/BeatmapStats.js +44 -0
  79. package/dist/components/osu/ModBadge.d.ts +23 -0
  80. package/dist/components/osu/ModBadge.js +22 -0
  81. package/dist/components/osu/StarRating.d.ts +26 -0
  82. package/dist/components/osu/StarRating.js +17 -0
  83. package/dist/components/osu/starColors.d.ts +23 -0
  84. package/dist/components/osu/starColors.js +48 -0
  85. package/dist/components/shell/HeaderMenu.d.ts +39 -0
  86. package/dist/components/shell/HeaderMenu.js +58 -0
  87. package/dist/components/shell/LinkNote.d.ts +16 -0
  88. package/dist/components/shell/LinkNote.js +17 -0
  89. package/dist/components/shell/LinkTabs.d.ts +29 -0
  90. package/dist/components/shell/LinkTabs.js +15 -0
  91. package/dist/components/shell/NavItem.d.ts +1 -7
  92. package/dist/components/shell/NavItem.js +5 -10
  93. package/dist/components/shell/NavLinks.d.ts +1 -1
  94. package/dist/components/shell/NavLinks.js +3 -3
  95. package/dist/components/shell/NavListClient.d.ts +1 -1
  96. package/dist/components/shell/NavListClient.js +4 -4
  97. package/dist/components/shell/SiteFooter.d.ts +6 -4
  98. package/dist/components/shell/SiteFooter.js +8 -6
  99. package/dist/components/shell/links.d.ts +8 -1
  100. package/dist/components/shell/links.js +8 -1
  101. package/dist/components/tables/TBody.d.ts +16 -0
  102. package/dist/components/tables/TBody.js +10 -0
  103. package/dist/components/tables/THead.d.ts +16 -0
  104. package/dist/components/tables/THead.js +10 -0
  105. package/dist/components/tables/Table.d.ts +26 -0
  106. package/dist/components/tables/Table.js +12 -0
  107. package/dist/components/tables/Td.d.ts +19 -0
  108. package/dist/components/tables/Td.js +11 -0
  109. package/dist/components/tables/Th.d.ts +20 -0
  110. package/dist/components/tables/Th.js +11 -0
  111. package/dist/components/tables/tableStyles.d.ts +12 -0
  112. package/dist/components/tables/tableStyles.js +12 -0
  113. package/dist/index.d.ts +23 -2
  114. package/dist/index.js +27 -2
  115. package/dist/utils/href.d.ts +7 -4
  116. package/dist/utils/href.js +23 -7
  117. package/package.json +3 -3
  118. package/dist/components/shell/AutoLink.d.ts +0 -19
  119. package/dist/components/shell/AutoLink.js +0 -23
  120. /package/dist/components/{actions → meta}/JsonLd.js +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-09-28
10
+
11
+ ### Added
12
+
13
+ - `SiteFooter` takes `discordLabel` (default "Discord"), the Discord link's accessible name, as `githubLabel` does for GitHub.
14
+ - `HeadingLevel` (`2 | 3 | 4 | 5 | 6`), the type of `headingLevel` on `Card` and `FilterPanel`.
15
+ - `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.
16
+ - `Badge`: a small pill for a status or tag, in `neutral`, `accent`, `warning` or `muted` (an outlined "beta" tag).
17
+ - 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.
18
+ - `cx`, the class merger the components use (tailwind-merge), and its `ClassValue` type.
19
+ - `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.
20
+ - `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.
21
+ - `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`.
22
+ - `RadioGroup`: a native radio fieldset on the `Checkbox` look, with a legend, per-option hints, and a group hint and error. Controlled or uncontrolled.
23
+ - `TypeToConfirm`: a form whose submit stays off until a name is typed exactly, for actions that can't be undone.
24
+ - `ChoiceChips`: single-select chips as native radios (arrow keys move and pick), with `Chip`'s look and `ChipGroup`'s `label` and `hideLabel`.
25
+ - `Chip` and `ChipOption` take `unavailableReason`: the chip is blocked but stays focusable (`aria-disabled`), with the reason as its description and title.
26
+ - `LinkTabs`: a named nav of pill links with `aria-current="page"` on the current one.
27
+ - `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.
28
+ - 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).
29
+ - `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.
30
+
31
+ ### Changed
32
+
33
+ - `Card` takes `headingLevel` `5` and `6` too, like `FilterPanel`.
34
+
35
+ ### Fixed
36
+
37
+ - 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.
38
+ - `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.
39
+ - `CopyButton` reports only the latest press. A slow earlier copy that failed after a later one worked no longer replaces "Copied." with the failure.
40
+ - `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.
41
+ - `Checkbox` keeps an `aria-labelledby` you pass, after its own label. Before, it was dropped.
42
+ - `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.
43
+
44
+ ### Security
45
+
46
+ - 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.
47
+ - Report vulnerabilities through GitHub's private vulnerability reporting first, or by email (SECURITY.md).
48
+
9
49
  ## [0.3.0] - 2026-09-25
10
50
 
11
51
  ### Added
@@ -37,7 +77,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
37
77
  - `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
78
  - 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
79
 
40
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.3.0...HEAD
80
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.4.0...HEAD
81
+ [0.4.0]: https://github.com/haruhimemoe/ui/compare/v0.3.0...v0.4.0
41
82
  [0.3.0]: https://github.com/haruhimemoe/ui/compare/v0.2.0...v0.3.0
42
83
  [0.2.0]: https://github.com/haruhimemoe/ui/compare/v0.1.0...v0.2.0
43
84
  [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.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`. 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,45 @@ 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
+
254
295
  ### Forms
255
296
 
256
297
  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 +326,42 @@ Every native `<select>` prop, plus the field props. Pass `<option>` elements as
285
326
 
286
327
  #### `Checkbox`
287
328
 
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.
329
+ 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).
330
+
331
+ #### `RadioGroup` (client)
332
+
333
+ 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`.
334
+
335
+ | Prop | Type | Default | What it does |
336
+ | --- | --- | --- | --- |
337
+ | `label` | `ReactNode` | required | The legend. |
338
+ | `options` | `readonly RadioOption[]` | required | `{ value: string; label: ReactNode; hint?: ReactNode; disabled?: boolean }` for each radio. |
339
+ | `value` / `defaultValue` | `string` | none | The picked value, held by you (`value`, with `onChange`) or by the group (`defaultValue`). |
340
+ | `onChange` | `(value: string) => void` | none | Gets the picked option's value. |
341
+ | `name` | `string` | generated | The radios' name, for a form. |
342
+ | `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`. |
343
+ | `required` | `boolean` | `false` | Every radio gets `required`. |
344
+
345
+ #### `TypeToConfirm` (client)
346
+
347
+ 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`.
348
+
349
+ | Prop | Type | Default | What it does |
350
+ | --- | --- | --- | --- |
351
+ | `id` | `string` | required | The text field's id, as on `TextInput`. |
352
+ | `expected` | `string` | required | What has to be typed. |
353
+ | `label` | `ReactNode` | `"Type <expected> to confirm"` | The field's label. |
354
+ | `hint`, `error` | `ReactNode` | none | As on `TextInput`. Pass `error` when the action fails. |
355
+ | `submitLabel` | `ReactNode` | required | The button's text. |
356
+ | `pendingLabel` | `ReactNode` | `submitLabel` | The button's text while `onConfirm` runs. It runs once at a time. |
357
+ | `onConfirm` | `() => void \| Promise<void>` | required | Runs on submit once the text matches. If it throws or rejects, the form stays as typed. |
358
+ | `variant` | `"primary" \| "secondary" \| "ghost"` | `"secondary"` | The button's look. |
359
+
360
+ ```tsx
361
+ <TypeToConfirm id="delete-pool" expected={pool.name} submitLabel="Delete this pool" onConfirm={remove} error={error}>
362
+ <p>This deletes the pool for everyone who edits it. It can't be undone.</p>
363
+ </TypeToConfirm>
364
+ ```
289
365
 
290
366
  #### `fieldClasses`
291
367
 
@@ -299,7 +375,7 @@ Every native `<input>` prop except `type` (`checked`, `defaultChecked`, `onChang
299
375
 
300
376
  #### `CopyButton` (client)
301
377
 
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.
378
+ 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
379
 
304
380
  | Prop | Type | Default | What it does |
305
381
  | --- | --- | --- | --- |
@@ -313,26 +389,56 @@ A button that copies text, with the result in an `<output>` beside it that scree
313
389
 
314
390
  #### `Pagination`
315
391
 
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"`.
392
+ 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
393
 
318
394
  | Prop | Type | Default | What it does |
319
395
  | --- | --- | --- | --- |
320
396
  | `page` | `number` | required | The current page, starting at 1. |
321
397
  | `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}` ``. |
398
+ | `hrefFor` | `(page: number) => string` | one of the two | Link mode: builds a page's URL, e.g. `` (p) => `/packs?page=${p}` ``. |
399
+ | `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. |
400
+ | `hasNext` | `boolean` | `false` | Button mode with `pageCount={null}`: whether a page comes after this one. |
323
401
  | `previousLabel` | `ReactNode` | `"Previous"` | Text of the link to the page before. |
324
402
  | `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. |
403
+ | `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". |
404
+
405
+ 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
406
 
327
407
  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
408
 
329
- #### `JsonLd`
409
+ 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.
330
410
 
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.
411
+ ```tsx
412
+ <Pagination page={page} pageCount={null} hasNext={data.hasMore} onPageChange={setPage} />
413
+ ```
414
+
415
+ #### `AsyncButton` (client)
416
+
417
+ 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
418
 
333
419
  | Prop | Type | Default | What it does |
334
420
  | --- | --- | --- | --- |
335
- | `data` | `Record<string, unknown>` | required | The schema.org object. `@context` defaults to `https://schema.org`; set it in `data` to change it. |
421
+ | `action` | `() => ReactNode \| Promise<ReactNode>` | required | Runs on press. What it returns is the message ("Done."). |
422
+ | `pendingLabel` | `ReactNode` | the children | The button's text while the action runs. |
423
+ | `failedMessage` | `ReactNode \| ((error: unknown) => ReactNode)` | `"Something went wrong. Try again."` | Shown in `text-rose-300` when the action throws or rejects. |
424
+ | `wrapperClassName` | `string` | none | Classes for the wrapper around the button and the message. |
425
+
426
+ #### `InlineConfirm` (client)
427
+
428
+ 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.
429
+
430
+ | Prop | Type | Default | What it does |
431
+ | --- | --- | --- | --- |
432
+ | `trigger` | `ReactNode` | required | The first button's text. |
433
+ | `question` | `ReactNode` | required | Shown once the trigger is pressed. |
434
+ | `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. |
435
+ | `confirmLabel`, `cancelLabel` | `ReactNode` | `"Confirm"`, `"Cancel"` | The two buttons' text. |
436
+ | `pendingLabel` | `ReactNode` | `confirmLabel` | The confirm button's text while `onConfirm` runs. |
437
+ | `onCancel` | `() => void` | none | Called when it closes without confirming. |
438
+ | `triggerProps` | `ButtonProps` | none | Props for the trigger (`aria-label`, `variant`, `disabled`). It is `secondary` by default. |
439
+ | `confirmVariant` | `"primary" \| "secondary" \| "ghost"` | `"secondary"` | The confirm button's look. Cancel is always `ghost`. |
440
+
441
+ If a confirm removes the item (and the `InlineConfirm` with it), move focus somewhere sensible yourself, such as the list's heading.
336
442
 
337
443
  ### Icons
338
444
 
@@ -366,7 +472,7 @@ The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and p
366
472
 
367
473
  #### `HaruhimeWordmarkLink`
368
474
 
369
- A plain `<a>` around a decorative `HaruhimeWordmark`, dimmed until hovered. Every native `<a>` prop.
475
+ A plain `<a>` around a decorative `HaruhimeWordmark`, dimmed until hovered. Every native `<a>` prop except `children`.
370
476
 
371
477
  | Prop | Type | Default | What it does |
372
478
  | --- | --- | --- | --- |
@@ -436,13 +542,14 @@ Give the `ChipGroup` or `RangeSlider` inside a `FilterRow` `hideLabel`. The row'
436
542
 
437
543
  #### `Chip` (client)
438
544
 
439
- A toggle pill: a `<button>` with `aria-pressed`, `h1` when on. Every native `<button>` prop.
545
+ A toggle pill: a `<button>` with `aria-pressed`, `h1` when on. Every native `<button>` prop except `aria-pressed`, which `pressed` sets.
440
546
 
441
547
  | Prop | Type | Default | What it does |
442
548
  | --- | --- | --- | --- |
443
549
  | `pressed` | `boolean` | required | Whether it is on. |
444
550
  | `onPressedChange` | `(pressed: boolean) => void` | none | Called with the new state on click, Enter or Space. |
445
551
  | `type` | `"button" \| "submit" \| "reset"` | `"button"` | Never submits a form by default. |
552
+ | `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
553
 
447
554
  Your own `onClick` runs first. Call `event.preventDefault()` in it to skip the toggle.
448
555
 
@@ -454,20 +561,33 @@ A labelled row of chips for picking several values (mods, game modes). A `<field
454
561
  | --- | --- | --- | --- |
455
562
  | `label` | `ReactNode` | required | Names the group. |
456
563
  | `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. |
564
+ | `options` | `readonly ChipOption[]` | required | `{ value: string; label: ReactNode; disabled?: boolean; unavailableReason?: ReactNode }` for each chip (`unavailableReason` since 0.4.0, as on `Chip`). |
458
565
  | `value` | `readonly string[]` | required | The picked values. |
459
566
  | `onChange` | `(value: string[]) => void` | required | Gets the new picked values, in the options' order, without duplicates. |
460
567
 
568
+ #### `ChoiceChips` (client)
569
+
570
+ 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`.
571
+
572
+ | Prop | Type | Default | What it does |
573
+ | --- | --- | --- | --- |
574
+ | `label` | `ReactNode` | required | Names the group. |
575
+ | `hideLabel` | `boolean` | `false` | As on `ChipGroup`, inside a `FilterRow`. |
576
+ | `options` | `readonly ChoiceChipOption<T>[]` | required | `{ value: T; label: ReactNode; disabled?: boolean }` for each chip. |
577
+ | `value` | `T` | required | The picked value. |
578
+ | `onChange` | `(value: T) => void` | required | Gets the picked value. |
579
+ | `name` | `string` | generated | The radios' name, for a form. |
580
+
461
581
  #### `RangeSlider` (client)
462
582
 
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.
583
+ 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
584
 
465
585
  | Prop | Type | Default | What it does |
466
586
  | --- | --- | --- | --- |
467
587
  | `label` | `string` | required | Names the group, and the ends as "Minimum *label*" and "Maximum *label*". |
468
588
  | `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. |
589
+ | `min`, `max` | `number` | required | The bounds. Given the wrong way round (`max` below `min`), they swap (since 0.4.0). |
590
+ | `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
591
  | `value` | `readonly [number, number \| null]` | required | The range. A `null` top means no upper limit. |
472
592
  | `onChange` | `(value: [number, number \| null]) => void` | required | Gets the new range. |
473
593
  | `openEnded` | `boolean` | `false` | The top end at `max` means "no upper limit": it shows `max+` (like `10+`) and reports `null`. |
@@ -494,13 +614,23 @@ A titled panel of `FilterRow`s with a live result count and a "Clear filters" bu
494
614
  | Prop | Type | Default | What it does |
495
615
  | --- | --- | --- | --- |
496
616
  | `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. |
617
+ | `headingLevel` | `2 \| 3 \| 4 \| 5 \| 6` | `2` | The heading's level (type `HeadingLevel`). |
498
618
  | `resultCount` | `ReactNode` | none | Shown in a polite live region, so each new count is announced. |
499
619
  | `active` | `boolean` | `false` | Whether any filter is set. |
500
620
  | `onClear` | `() => void` | none | The clear button's action. The button shows only when `active` is true and this is set. |
501
621
  | `clearLabel` | `ReactNode` | `"Clear filters"` | The clear button's text. |
502
622
  | `defaultOpen` | `boolean` | `false` | Whether the rows start open on phones. |
503
623
 
624
+ ### Meta
625
+
626
+ #### `JsonLd`
627
+
628
+ 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).
629
+
630
+ | Prop | Type | Default | What it does |
631
+ | --- | --- | --- | --- |
632
+ | `data` | `Record<string, unknown>` | required | The schema.org object. `@context` defaults to `https://schema.org`; set it in `data` to change it. |
633
+
504
634
  ### Shell
505
635
 
506
636
  Links in the header and footer are data (type `SiteLinkItem`):
@@ -509,11 +639,11 @@ Links in the header and footer are data (type `SiteLinkItem`):
509
639
  type SiteLinkItem = { label: string; href?: string; note?: string };
510
640
  ```
511
641
 
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.
642
+ 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
643
 
514
644
  #### `SiteHeader`
515
645
 
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`.
646
+ 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
647
 
518
648
  | Prop | Type | Default | What it does |
519
649
  | --- | --- | --- | --- |
@@ -533,7 +663,7 @@ Next bundles every client component a route imports, rendered or not. So a page
533
663
 
534
664
  #### `NavLinks`
535
665
 
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.
666
+ 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
667
 
538
668
  | Prop | Type | Default | What it does |
539
669
  | --- | --- | --- | --- |
@@ -542,7 +672,7 @@ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it insi
542
672
 
543
673
  #### `SiteFooter`
544
674
 
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[] }`).
675
+ 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
676
 
547
677
  | Prop | Type | Default | What it does |
548
678
  | --- | --- | --- | --- |
@@ -553,7 +683,30 @@ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub ico
553
683
  | `parentHref` | `string` | `"https://www.haruhime.moe"` | Where the wordmark links. |
554
684
  | `githubHref` | `string \| false` | `"https://github.com/haruhimemoe"` | Where the GitHub icon links. `false` leaves it out. |
555
685
  | `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. |
686
+ | `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. |
687
+ | `discordLabel` | `string` | `"Discord"` | The Discord link's accessible name. Since 0.4.0. |
688
+
689
+ #### `LinkTabs`
690
+
691
+ 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`.
692
+
693
+ | Prop | Type | Default | What it does |
694
+ | --- | --- | --- | --- |
695
+ | `label` | `string` | required | The nav landmark's accessible name. |
696
+ | `items` | `readonly LinkTabItem[]` | required | `{ href: string; label: ReactNode; current?: boolean }` for each tab, each with its own href. |
697
+
698
+ #### `HeaderMenu` (client)
699
+
700
+ 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`.
701
+
702
+ | Prop | Type | Default | What it does |
703
+ | --- | --- | --- | --- |
704
+ | `label` | `ReactNode` | required | The button's content. |
705
+ | `items` | `readonly HeaderMenuItem[]` | `[]` | `{ href: string; label: ReactNode }` links, top to bottom. |
706
+ | `children` | `ReactNode` | none | Shown after the links, such as a sign-out button. |
707
+ | `align` | `"start" \| "end"` | `"end"` | Which edge of the button the panel lines up with. |
708
+ | `buttonLabel` | `string` | none | The button's accessible name, when its content is only an image. |
709
+ | `buttonClassName` | `string` | none | Classes for the button, merged last. |
557
710
 
558
711
  #### `PageShell`
559
712
 
@@ -568,6 +721,78 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
568
721
  | `mainId` | `string` | `"main"` | `<main>`'s id, which the skip link targets. |
569
722
  | `mainClassName` | `string` | none | Extra classes for `<main>`, e.g. `"max-w-7xl"`. |
570
723
 
724
+ ### osu!
725
+
726
+ Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (no osu! API types) and are Server Components.
727
+
728
+ #### `StarRating`
729
+
730
+ 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.
731
+
732
+ | Prop | Type | Default | What it does |
733
+ | --- | --- | --- | --- |
734
+ | `value` | `number` | required | The rating, shown with two decimals. `NaN` shows as "–" on grey. |
735
+ | `label` | `ReactNode` | none | Read after the rating by screen readers, e.g. "with HR" (what a `title` tells mouse users). |
736
+ | `unit` | `string` | `"stars"` | The word read after the number. |
737
+
738
+ #### `BeatmapStats`
739
+
740
+ 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`.
741
+
742
+ | Prop | Type | Default | What it does |
743
+ | --- | --- | --- | --- |
744
+ | `cs`, `ar`, `od`, `hp` | `number \| null` | none | Shown with at most one decimal. |
745
+ | `bpm` | `number \| null` | none | Shown whole. |
746
+ | `lengthSeconds` | `number \| null` | none | Shown as `m:ss`, or `h:mm:ss` from an hour. |
747
+ | `labels` | `Partial<Record<BeatmapStatKey, ReactNode>>` | none | Replaces a label (`{ length: "Länge" }`). |
748
+
749
+ #### `ModBadge`
750
+
751
+ 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).
752
+
753
+ | Prop | Type | Default | What it does |
754
+ | --- | --- | --- | --- |
755
+ | `mod` | `string` | required | The mod or slot label. |
756
+
757
+ ### Tables
758
+
759
+ 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.
760
+
761
+ ```tsx
762
+ <Table caption="The pool's maps" hideCaption>
763
+ <THead>
764
+ <tr>
765
+ <Th>Slot</Th>
766
+ <Th numeric>Stars</Th>
767
+ </tr>
768
+ </THead>
769
+ <TBody>
770
+ {slots.map((slot) => (
771
+ <tr key={slot.label}>
772
+ <Th scope="row">{slot.label}</Th>
773
+ <Td numeric>{slot.stars}</Td>
774
+ </tr>
775
+ ))}
776
+ </TBody>
777
+ </Table>
778
+ ```
779
+
780
+ | Component | Extra props | What it renders |
781
+ | --- | --- | --- |
782
+ | `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. |
783
+ | `THead` | none | A `<thead>` in `c3`, `text-xs`, uppercase. |
784
+ | `TBody` | none | A `<tbody>` whose rows get a `b4` top border. Add `[&>tr]:align-top` for rows of mixed height. |
785
+ | `Th` | `numeric?: boolean` | A `<th>` with `scope="col"` by default. With `scope="row"` it is a row's heading, in bold `c1`. |
786
+ | `Td` | `numeric?: boolean` | A `<td>`. `numeric` lines up digits (`tabular-nums`). |
787
+
788
+ Cells get `py-2 pr-3`, and the last cell of a row no right padding.
789
+
790
+ ### Utilities
791
+
792
+ #### `cx`
793
+
794
+ 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.
795
+
571
796
  ## Accessibility
572
797
 
573
798
  - 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).
@@ -595,7 +820,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
595
820
 
596
821
  ## Changelog and contributing
597
822
 
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).
823
+ 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
824
 
600
825
  ## License
601
826
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file src/components/actions/AsyncButton.tsx
3
+ * @desc A button that runs an async action and says how it went in an <output> beside it (a
4
+ * polite live region), like the apps' refresh and retry buttons. While the action runs the
5
+ * button stays focusable but ignores presses (aria-disabled), and can show a pending label.
6
+ * A rejected action shows the failure message in rose.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import { type ReactNode } from "react";
12
+ import { type ButtonProps } from "../basics/Button.js";
13
+ /** Every Button prop except onClick, plus the action and what to say while and after it runs. */
14
+ export type AsyncButtonProps = Omit<ButtonProps, "onClick"> & {
15
+ /** Runs on press. What it returns (or resolves to) is shown as the result, e.g. "Done.". */
16
+ action: () => ReactNode | Promise<ReactNode>;
17
+ /** The button's text while the action runs. Defaults to the button's children. */
18
+ pendingLabel?: ReactNode;
19
+ /** Shown when the action throws or rejects: a message, or a function of the error. */
20
+ failedMessage?: ReactNode | ((error: unknown) => ReactNode);
21
+ /** Classes for the wrapper around the button and its status. */
22
+ wrapperClassName?: string | undefined;
23
+ };
24
+ /**
25
+ * @function AsyncButton
26
+ * @param props {AsyncButtonProps} the action, an optional pending label and failure message, plus
27
+ * Button props (`className` and `ref` go on the button)
28
+ * @returns {JSX.Element} the button and an `<output>` that announces the action's result
29
+ */
30
+ export declare function AsyncButton({ action, pendingLabel, failedMessage, wrapperClassName, className, children, ...props }: AsyncButtonProps): import("react").JSX.Element;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file src/components/actions/AsyncButton.tsx
3
+ * @desc A button that runs an async action and says how it went in an <output> beside it (a
4
+ * polite live region), like the apps' refresh and retry buttons. While the action runs the
5
+ * button stays focusable but ignores presses (aria-disabled), and can show a pending label.
6
+ * A rejected action shows the failure message in rose.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ "use client";
12
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
+ import { useRef, useState } from "react";
14
+ import { cx } from "../../utils/cx.js";
15
+ import { Button } from "../basics/Button.js";
16
+ import { StatusOutput } from "./StatusOutput.js";
17
+ import { useLatestStatus } from "./useLatestStatus.js";
18
+ /**
19
+ * @function AsyncButton
20
+ * @param props {AsyncButtonProps} the action, an optional pending label and failure message, plus
21
+ * Button props (`className` and `ref` go on the button)
22
+ * @returns {JSX.Element} the button and an `<output>` that announces the action's result
23
+ */
24
+ export function AsyncButton({ action, pendingLabel, failedMessage = "Something went wrong. Try again.", wrapperClassName, className, children, ...props }) {
25
+ const [pending, setPending] = useState(false);
26
+ // `pending` reaches the button only after a render; two presses in one frame must not both run.
27
+ const running = useRef(false);
28
+ const { status, start, settle } = useLatestStatus();
29
+ const run = async () => {
30
+ if (running.current)
31
+ return;
32
+ running.current = true;
33
+ setPending(true);
34
+ const id = start();
35
+ try {
36
+ settle(id, { message: await action(), failed: false });
37
+ }
38
+ catch (error) {
39
+ const message = typeof failedMessage === "function" ? failedMessage(error) : failedMessage;
40
+ settle(id, { message, failed: true });
41
+ }
42
+ finally {
43
+ running.current = false;
44
+ setPending(false);
45
+ }
46
+ };
47
+ return (_jsxs("div", { className: cx("flex flex-wrap items-center gap-3", wrapperClassName), children: [_jsx(Button, { "aria-disabled": pending || undefined, onClick: run, className: cx("aria-disabled:cursor-wait aria-disabled:opacity-70", className), ...props, children: pending && pendingLabel !== undefined ? pendingLabel : children }), _jsx(StatusOutput, { run: status?.run ?? null, children: status?.result.failed ? (_jsx("span", { className: "text-rose-300", children: status.result.message })) : (status?.result.message) })] }));
48
+ }
@@ -4,12 +4,12 @@
4
4
  * it (a polite live region), like the packs export and doc pages. If the clipboard is
5
5
  * missing or refuses, it says so and tells the reader to copy by hand. Each press empties
6
6
  * the status first and then writes the result as a new node, so a second copy is announced
7
- * too.
7
+ * too. Only the latest press reports: a slow earlier copy that settles later is ignored.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Wed Sep 23, 2026
10
- * @modified Wed Sep 23, 2026
10
+ * @modified Mon Sep 28, 2026
11
11
  */
12
- import { type ReactNode } from "react";
12
+ import type { ReactNode } from "react";
13
13
  import { type ButtonProps } from "../basics/Button.js";
14
14
  /** Every Button prop (native button props, variant, size) except children and onClick. */
15
15
  export type CopyButtonProps = Omit<ButtonProps, "children" | "onClick"> & {