@haruhimemoe/ui 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -1
- package/README.md +143 -33
- package/dist/components/basics/Disclosure.d.ts +1 -1
- package/dist/components/basics/Disclosure.js +2 -2
- package/dist/components/filters/RangeSlider.d.ts +1 -1
- package/dist/components/filters/RangeSlider.js +6 -6
- package/dist/components/filters/chipStyles.d.ts +2 -2
- package/dist/components/filters/chipStyles.js +3 -2
- package/dist/components/forms/CharCounter.d.ts +4 -2
- package/dist/components/forms/CharCounter.js +5 -3
- package/dist/components/forms/Checkbox.d.ts +1 -1
- package/dist/components/forms/Checkbox.js +3 -1
- package/dist/components/forms/FieldFrame.d.ts +1 -1
- package/dist/components/forms/FieldFrame.js +1 -1
- package/dist/components/forms/RadioGroup.d.ts +1 -1
- package/dist/components/forms/RadioGroup.js +4 -2
- package/dist/components/forms/ReportDisclosure.d.ts +1 -1
- package/dist/components/forms/ReportDisclosure.js +10 -6
- package/dist/components/forms/fieldStyles.d.ts +5 -6
- package/dist/components/forms/fieldStyles.js +6 -7
- package/dist/components/osu/BeatmapStats.d.ts +1 -1
- package/dist/components/osu/BeatmapStats.js +2 -1
- package/dist/components/osu/starColors.d.ts +14 -4
- package/dist/components/osu/starColors.js +39 -2
- package/dist/components/palette/CommandPalette.d.ts +21 -0
- package/dist/components/palette/CommandPalette.js +315 -0
- package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
- package/dist/components/palette/CommandPaletteButton.js +26 -0
- package/dist/components/palette/PaletteFooter.d.ts +21 -0
- package/dist/components/palette/PaletteFooter.js +10 -0
- package/dist/components/palette/PaletteInput.d.ts +30 -0
- package/dist/components/palette/PaletteInput.js +23 -0
- package/dist/components/palette/PaletteList.d.ts +29 -0
- package/dist/components/palette/PaletteList.js +44 -0
- package/dist/components/palette/PaletteRow.d.ts +30 -0
- package/dist/components/palette/PaletteRow.js +33 -0
- package/dist/components/palette/calc.d.ts +28 -0
- package/dist/components/palette/calc.js +226 -0
- package/dist/components/palette/fuzzy.d.ts +51 -0
- package/dist/components/palette/fuzzy.js +138 -0
- package/dist/components/palette/hotkeys.d.ts +57 -0
- package/dist/components/palette/hotkeys.js +111 -0
- package/dist/components/palette/paletteEvents.d.ts +22 -0
- package/dist/components/palette/paletteEvents.js +22 -0
- package/dist/components/palette/platform.d.ts +13 -0
- package/dist/components/palette/platform.js +21 -0
- package/dist/components/palette/recents.d.ts +48 -0
- package/dist/components/palette/recents.js +85 -0
- package/dist/components/palette/rows.d.ts +63 -0
- package/dist/components/palette/rows.js +136 -0
- package/dist/components/palette/siteCommands.d.ts +38 -0
- package/dist/components/palette/siteCommands.js +152 -0
- package/dist/components/palette/store.d.ts +94 -0
- package/dist/components/palette/store.js +135 -0
- package/dist/components/palette/types.d.ts +102 -0
- package/dist/components/palette/types.js +11 -0
- package/dist/components/palette/useProviderSearch.d.ts +28 -0
- package/dist/components/palette/useProviderSearch.js +68 -0
- package/dist/components/shell/HeaderMenu.d.ts +1 -1
- package/dist/components/shell/HeaderMenu.js +2 -2
- package/dist/components/shell/NavItem.d.ts +1 -1
- package/dist/components/shell/NavItem.js +2 -2
- package/dist/components/shell/SiteFooter.d.ts +9 -4
- package/dist/components/shell/SiteFooter.js +4 -2
- package/dist/components/tables/Table.d.ts +5 -3
- package/dist/components/tables/Table.js +15 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/theme.css +4 -4
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.8.0] - 2026-10-03
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `CommandPalette`: a mod+k command palette in a native `<dialog>`. Fuzzy search over commands with group headings and marked matches, nested pages (Backspace or Escape go back), async providers that search as you type (debounced, aborted when superseded), argument prompts (text, number, choice) before a command runs, a Recent group from localStorage, and a calculator row (`2*21` → `= 42`, Enter copies). Command shortcuts (`mod+shift+c`, chords like `g p`) work while the palette is closed. `openCommandPalette(page?)` opens it from anywhere; `CommandPaletteButton` is the header button with the platform hint. Built as a combobox over a listbox, with an `h1` edge on the active row, a focus cue on the input row, status errors and a polite result count.
|
|
14
|
+
- `siteCommands(options)`: the defaults every tool gets: Go to each nav page, Open each other haruhime tool, Copy page URL, Go back, Scroll to top, Reload, Open on GitHub, Sign in / My account / Sign out, Keyboard shortcuts, Report a bug.
|
|
15
|
+
- `fuzzyScore` and `evaluate` / `formatResult` are public, so an app can rank its provider rows the same way and reuse the calculator.
|
|
16
|
+
- `playground/`: a Next.js app in the repo that renders the components from `src/` (`bun run play`), and `bun run play:axe`, which builds it and runs axe-core in Chromium over the palette's states. Not published.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `Checkbox` and `RadioGroup` boxes are 24px (WCAG 2.2 target size; they were the browser's 13px), centered on the first line of their label. `Disclosure` and `HeaderMenu` buttons are at least 24px tall.
|
|
21
|
+
- The consumer check runs axe-core in headless Chromium over the fixture page with color contrast and target-size checks on, at 1280 and 390 wide, after `next build`. CI installs Chromium for it.
|
|
22
|
+
|
|
23
|
+
## [0.7.0] - 2026-10-03
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Accessibility pass (WCAG 2.2 AA). Fields keep the theme's 2px focus outline instead of hiding it behind a 1px border change. `RangeSlider` thumbs and chips are 24px, the minimum target size. The default `--h1-l` is `76%` (was `70%`), so `h1` text on `b5` clears 4.5:1 at every hue; pink gets a touch lighter at the default hue. `StarRating` picks its text color by contrast (white on the 6.5 to 7 star violet band, where gold was 3.9:1). `Table`'s scrolling wrapper is a focusable `<section>` named by the caption or `scrollLabel`. `SiteFooter` holds its columns in one `<nav>` (`navLabel`, default "Footer"), each column a `<section>` with its title as a heading (`headingLevel`, default 2), instead of a `<nav>` per column. `ReportDisclosure` keeps its status line mounted from the start and moves focus to it after a send. Field errors are `role="status"`, not `role="alert"`; radios no longer carry `aria-invalid` (the group describes the error). Text-only nav entries drop `aria-disabled`. `BeatmapStats` reads full stat names to screen readers.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `CharCounter`'s `live` prop: announces only the over-limit text.
|
|
32
|
+
- `Table`'s `scrollLabel`, `SiteFooter`'s `navLabel` and `headingLevel`.
|
|
33
|
+
- README: an "Accessibility" section with the house rules every component follows.
|
|
34
|
+
|
|
9
35
|
## [0.6.0] - 2026-09-28
|
|
10
36
|
|
|
11
37
|
### Added
|
|
@@ -98,7 +124,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
98
124
|
- `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`).
|
|
99
125
|
- 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).
|
|
100
126
|
|
|
101
|
-
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.
|
|
127
|
+
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.8.0...HEAD
|
|
128
|
+
[0.8.0]: https://github.com/haruhimemoe/ui/compare/v0.7.0...v0.8.0
|
|
129
|
+
[0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
|
|
102
130
|
[0.6.0]: https://github.com/haruhimemoe/ui/compare/v0.5.1...v0.6.0
|
|
103
131
|
[0.5.1]: https://github.com/haruhimemoe/ui/compare/v0.5.0...v0.5.1
|
|
104
132
|
[0.5.0]: https://github.com/haruhimemoe/ui/compare/v0.4.0...v0.5.0
|
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# @haruhimemoe/ui
|
|
4
4
|
|
|
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.
|
|
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, a command palette (mod+k, with the defaults every tool shares) 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.
|
|
6
6
|
|
|
7
7
|
See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
|
|
8
8
|
|
|
9
|
-
This README describes version 0.
|
|
9
|
+
This README describes version 0.8.0. Anything marked "since 0.8.0" is not in 0.7.0, anything marked "since 0.7.0" is not in 0.6.0, anything marked "since 0.6.0" is not in 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.
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
@@ -80,7 +80,7 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display:
|
|
|
80
80
|
At some hues the defaults drop below 4.5:1 contrast, so check yours. Two variables fix it:
|
|
81
81
|
|
|
82
82
|
- `--h2-l` sets the lightness of `h2` (default `45%`). White text on `h2` (primary buttons, the skip link) is under 4.5:1 for hues from about 23 to 205. Use `42%` at hue 200, `35%` at hue 150, or `31%` for any hue.
|
|
83
|
-
- `--h1-l` sets the lightness of `h1` (default `70%`). `h1` text on `
|
|
83
|
+
- `--h1-l` sets the lightness of `h1` (default `76%` since 0.7.0, `70%` before). At `76%`, `h1` text on `b5` (links in cards and prose) is at or above 4.5:1 at every hue. On `b4` ("Clear filters" in a filter panel) it dips under for hues from about 238 to 248. Use `77%` there.
|
|
84
84
|
|
|
85
85
|
The theme is dark only (`color-scheme: dark`).
|
|
86
86
|
|
|
@@ -153,7 +153,7 @@ export default function Home() {
|
|
|
153
153
|
|
|
154
154
|
Import every component from `@haruhimemoe/ui`, in Server and Client Components alike.
|
|
155
155
|
|
|
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.
|
|
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`, and since 0.8.0 `CommandPalette` and `CommandPaletteButton`. 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.
|
|
157
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.
|
|
158
158
|
- **Everything else is server-safe:** no state, no effects, no browser APIs.
|
|
159
159
|
|
|
@@ -234,7 +234,7 @@ An error notice (`role="alert"`) is announced either way.
|
|
|
234
234
|
|
|
235
235
|
#### `Prose`
|
|
236
236
|
|
|
237
|
-
Long-form typography for MDX, docs and legal pages. A `max-w-3xl` `<div>` that styles the `h2`, `h3`, `p`, `a`, `strong`, `ul`, `ol`, `li`, `code`, `pre`, `hr` and `table` elements inside it. Every native `<div>` prop.
|
|
237
|
+
Long-form typography for MDX, docs and legal pages. A `max-w-3xl` `<div>` that styles the `h2`, `h3`, `p`, `a`, `strong`, `ul`, `ol`, `li`, `code`, `pre`, `hr` and `table` elements inside it. Every native `<div>` prop. A `pre` scrolls sideways, so give it `tabIndex={0}` (through your Markdown renderer's `components` map) so keyboard users can reach the scroll; CSS can't add that.
|
|
238
238
|
|
|
239
239
|
The first element inside gets no top margin (`[&>:first-child]:mt-0`, since 0.2.0), so a heading that opens the block sits flush with what's above it instead of taking the `h2` or `h3` gap. The rule reaches direct children only. If you wrap the content in `<section>`s, the heading at the top of the first section keeps its margin. Reach one level deeper for that:
|
|
240
240
|
|
|
@@ -255,7 +255,7 @@ Later sections keep their heading margin, which spaces them apart.
|
|
|
255
255
|
|
|
256
256
|
#### `Disclosure` (client)
|
|
257
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.
|
|
258
|
+
Since 0.4.0. A button (at least 24px tall) 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
259
|
|
|
260
260
|
| Prop | Type | Default | What it does |
|
|
261
261
|
| --- | --- | --- | --- |
|
|
@@ -322,7 +322,7 @@ Shared props (type `FieldProps`), taken by `TextInput`, `Textarea`, `Select` and
|
|
|
322
322
|
| `id` | `string` | required | The control's id. The label points at it. The hint gets `<id>-hint` and the error `<id>-error`. |
|
|
323
323
|
| `label` | `ReactNode` | required | The visible label. |
|
|
324
324
|
| `hint` | `ReactNode` | none | Help text in a `<div>`, linked with `aria-describedby`. On `Checkbox` the hint sits inline inside the label, so keep it to text there. |
|
|
325
|
-
| `error` | `ReactNode` | none | Error text in a `role="
|
|
325
|
+
| `error` | `ReactNode` | none | Error text in a `role="status"` `<div>` (`text-rose-300`; `role="alert"` before 0.7.0), so a list of errors is fine. Sets `aria-invalid` and links the text with `aria-describedby`. |
|
|
326
326
|
| `wrapperClassName` | `string` | none | Classes for the wrapper around the label, control, hint and error, for layout (`min-w-48 flex-1`). |
|
|
327
327
|
|
|
328
328
|
`className` goes on the control itself. Your own `aria-describedby` is kept after the hint and error ids.
|
|
@@ -345,11 +345,11 @@ Every native `<select>` prop, plus the field props. Pass `<option>` elements as
|
|
|
345
345
|
|
|
346
346
|
#### `Checkbox`
|
|
347
347
|
|
|
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).
|
|
348
|
+
Every native `<input>` prop except `type` (`checked`, `defaultChecked`, `onChange`, `name`, `disabled`...), plus the field props. The box is 24px (since 0.8.0; it was the browser's 13px), 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
349
|
|
|
350
350
|
#### `RadioGroup` (client)
|
|
351
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`.
|
|
352
|
+
Since 0.4.0. A native radio group on the `Checkbox` look: a `<fieldset>` named by its `<legend>`, one 24px 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
353
|
|
|
354
354
|
| Prop | Type | Default | What it does |
|
|
355
355
|
| --- | --- | --- | --- |
|
|
@@ -358,7 +358,7 @@ Since 0.4.0. A native radio group on the `Checkbox` look: a `<fieldset>` named b
|
|
|
358
358
|
| `value` / `defaultValue` | `string` | none | The picked value, held by you (`value`, with `onChange`) or by the group (`defaultValue`). |
|
|
359
359
|
| `onChange` | `(value: string) => void` | none | Gets the picked option's value. |
|
|
360
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="
|
|
361
|
+
| `hint`, `error` | `ReactNode` | none | Under the options, linked to the group with `aria-describedby`. An error is a `role="status"` line (since 0.7.0; before, `role="alert"` and `aria-invalid` on each radio). |
|
|
362
362
|
| `required` | `boolean` | `false` | Every radio gets `required`. |
|
|
363
363
|
|
|
364
364
|
#### `TypeToConfirm` (client)
|
|
@@ -386,11 +386,14 @@ Since 0.4.0. A confirm for something that can't be undone: a `<form>` whose subm
|
|
|
386
386
|
|
|
387
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
388
|
|
|
389
|
+
With `live` (since 0.7.0) an `<output>` live region inside it announces "1,500 over the limit" when the count goes over and nothing while under, so typing isn't read out number by number.
|
|
390
|
+
|
|
389
391
|
| Prop | Type | Default | What it does |
|
|
390
392
|
| --- | --- | --- | --- |
|
|
391
393
|
| `count` | `number` | required | How many are used. |
|
|
392
394
|
| `limit` | `number` | required | The most allowed. |
|
|
393
395
|
| `unit` | `string` | `"characters"` | What is counted. |
|
|
396
|
+
| `live` | `boolean` | `false` | Announce the over-limit text to screen readers (since 0.7.0). |
|
|
394
397
|
|
|
395
398
|
#### `VisibilitySelect` (client)
|
|
396
399
|
|
|
@@ -420,7 +423,7 @@ Since 0.5.0. Who can see something: private, unlisted or public, each with a lin
|
|
|
420
423
|
|
|
421
424
|
#### `ReportDisclosure` (client)
|
|
422
425
|
|
|
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`.
|
|
426
|
+
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`), which takes focus, since the button that had it is gone (since 0.7.0; the line is mounted empty from the start, so it is announced); `{ 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
427
|
|
|
425
428
|
| Prop | Type | Default | What it does |
|
|
426
429
|
| --- | --- | --- | --- |
|
|
@@ -433,7 +436,7 @@ Since 0.5.0. "Report this": a `Disclosure` holding a reason `Textarea` (required
|
|
|
433
436
|
| `failedMessage` | `ReactNode` | `"Couldn't send the report. Try again."` | Said when `onSubmit` throws. |
|
|
434
437
|
| `minLength`, `maxLength` | `number` | `3`, none | The reason's length limits. |
|
|
435
438
|
| `rows` | `number` | `3` | The field's height. |
|
|
436
|
-
| `className` | `string` | none | Classes for the wrapper
|
|
439
|
+
| `className` | `string` | none | Classes for the wrapper around the disclosure and the status line. |
|
|
437
440
|
|
|
438
441
|
```tsx
|
|
439
442
|
<ReportDisclosure
|
|
@@ -448,7 +451,7 @@ Since 0.5.0. "Report this": a `Disclosure` holding a reason `Textarea` (required
|
|
|
448
451
|
|
|
449
452
|
#### `fieldClasses`
|
|
450
453
|
|
|
451
|
-
`fieldClasses(className?: string): string` returns the field look (`b6` background, `b3` border, `h1` border on focus, rose border when `aria-invalid`, and
|
|
454
|
+
`fieldClasses(className?: string): string` returns the field look (`b6` background, `b3` border, `h1` border plus the theme's 2px `h1` outline on focus, rose border when `aria-invalid`, and the `h1` border back when an invalid field has focus; before 0.7.0 the outline was hidden and only the border changed). Use it on a bare control that labels itself:
|
|
452
455
|
|
|
453
456
|
```tsx
|
|
454
457
|
<select aria-label="Move to" className={fieldClasses("w-auto")}>...</select>
|
|
@@ -750,7 +753,7 @@ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it insi
|
|
|
750
753
|
|
|
751
754
|
| Prop | Type | Default | What it does |
|
|
752
755
|
| --- | --- | --- | --- |
|
|
753
|
-
| `links` | `readonly SiteLinkItem[]` | required | The entries. |
|
|
756
|
+
| `links` | `readonly SiteLinkItem[]` | required | The entries. An entry without an `href` renders as dimmed text with its `note` (no `aria-disabled` since 0.7.0: it isn't a control). |
|
|
754
757
|
| `align` | `"start" \| "center"` | `"start"` | As `navAlign` on `SiteHeader`. |
|
|
755
758
|
|
|
756
759
|
#### `SiteFooter`
|
|
@@ -759,7 +762,9 @@ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub ico
|
|
|
759
762
|
|
|
760
763
|
| Prop | Type | Default | What it does |
|
|
761
764
|
| --- | --- | --- | --- |
|
|
762
|
-
| `columns` | `readonly SiteFooterColumn[]` | `[]` |
|
|
765
|
+
| `columns` | `readonly SiteFooterColumn[]` | `[]` | The columns sit in one `<nav>` (`navLabel`); each is a `<section>` headed by its title. Up to four columns side by side from `sm` up. Before 0.7.0 each column was its own `<nav>`. |
|
|
766
|
+
| `navLabel` | `string` | `"Footer"` | The nav landmark's name. Keep it different from `SiteHeader`'s `navLabel`, so the two landmarks tell apart (since 0.7.0). |
|
|
767
|
+
| `headingLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `2` | The column titles' heading level (since 0.7.0). |
|
|
763
768
|
| `tools` | `HaruhimeToolsOptions` | none | Adds a "haruhime tools" column: the other live haruhime.moe tools as "name: blurb" links, then "All tools" on www. Options: `current` (the tool this footer is on, left out), `title` (`"haruhime tools"`), `allLabel` (`"All tools"`, `false` drops it), `allHref` (`"https://www.haruhime.moe"`), `position` (where among `columns`, default `1`, clamped). Since 0.6.0. |
|
|
764
769
|
| `extra` | `ReactNode` | none | Shown above the fine print, e.g. a "clear local data" button. |
|
|
765
770
|
| `finePrint` | `ReactNode` | none | One line of small print, in a `<p>`. |
|
|
@@ -783,7 +788,7 @@ Since 0.4.0. A row of link tabs (Pools / Maps, All / Hidden): a named `<nav>` wi
|
|
|
783
788
|
|
|
784
789
|
#### `HeaderMenu` (client)
|
|
785
790
|
|
|
786
|
-
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`.
|
|
791
|
+
Since 0.4.0. The header's account menu: a button (an avatar and a name, say; at least 24px tall) 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`.
|
|
787
792
|
|
|
788
793
|
| Prop | Type | Default | What it does |
|
|
789
794
|
| --- | --- | --- | --- |
|
|
@@ -813,7 +818,7 @@ Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (
|
|
|
813
818
|
|
|
814
819
|
#### `StarRating`
|
|
815
820
|
|
|
816
|
-
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.
|
|
821
|
+
A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rating's color, dark text up to 6.5 and osu!'s pale yellow above, except where neither clears 4.5:1 on the pill (the violet band around 6.5 to 7 stars), which gets white (since 0.7.0). 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.
|
|
817
822
|
|
|
818
823
|
| Prop | Type | Default | What it does |
|
|
819
824
|
| --- | --- | --- | --- |
|
|
@@ -823,7 +828,7 @@ A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rati
|
|
|
823
828
|
|
|
824
829
|
#### `BeatmapStats`
|
|
825
830
|
|
|
826
|
-
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`.
|
|
831
|
+
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; screen readers get the full name instead of the letters (since 0.7.0). Every native `<dl>` prop except `children`. Type: `BeatmapStatKey`.
|
|
827
832
|
|
|
828
833
|
| Prop | Type | Default | What it does |
|
|
829
834
|
| --- | --- | --- | --- |
|
|
@@ -840,6 +845,108 @@ A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: N
|
|
|
840
845
|
| --- | --- | --- | --- |
|
|
841
846
|
| `mod` | `string` | required | The mod or slot label. |
|
|
842
847
|
|
|
848
|
+
### Palette (client)
|
|
849
|
+
|
|
850
|
+
Since 0.8.0. A command palette: press Ctrl K (⌘K on a Mac) anywhere on the page and a dialog opens with a search box over everything the app can do. It is one component for every haruhime tool: `CommandPalette` is the engine, `siteCommands` the defaults every site shares, and each app plugs in its own commands, pages and search providers through the same `Command` and `Provider` types. No new dependency.
|
|
851
|
+
|
|
852
|
+
Mount it once, from a client file, since commands carry functions. The button goes in `SiteHeader`'s `actions`:
|
|
853
|
+
|
|
854
|
+
```tsx
|
|
855
|
+
// src/components/Palette.tsx
|
|
856
|
+
"use client";
|
|
857
|
+
|
|
858
|
+
import { type Command, CommandPalette, siteCommands } from "@haruhimemoe/ui";
|
|
859
|
+
import { HEADER_LINKS } from "@/constants/nav";
|
|
860
|
+
|
|
861
|
+
const COMMANDS: Command[] = [
|
|
862
|
+
...siteCommands({ pages: HEADER_LINKS, tools: "packs", repo: "https://github.com/haruhimemoe/packs.haruhime.moe" }),
|
|
863
|
+
{ id: "pack.new", title: "New pack", group: "Packs", shortcut: "g n", run: (ctx) => ctx.navigate("/new") },
|
|
864
|
+
];
|
|
865
|
+
|
|
866
|
+
export function Palette() {
|
|
867
|
+
return <CommandPalette storageKey="packs" commands={COMMANDS} />;
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
```tsx
|
|
872
|
+
// app/layout.tsx
|
|
873
|
+
<SiteHeader brand={...} links={HEADER_LINKS} actions={<CommandPaletteButton>Search</CommandPaletteButton>} />
|
|
874
|
+
<Palette />
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
#### `CommandPalette`
|
|
878
|
+
|
|
879
|
+
The dialog. Renders nothing until opened, then a native `<dialog>` (modal, backdrop, scroll locked) with a combobox over a listbox. Mount one per app.
|
|
880
|
+
|
|
881
|
+
| Prop | Type | Default | What it does |
|
|
882
|
+
| --- | --- | --- | --- |
|
|
883
|
+
| `commands` | `readonly Command[]` | required | The root rows. Read on every open, so a command's `when` and titles can change. |
|
|
884
|
+
| `providers` | `readonly Provider[]` | none | Searched at the root as you type, once the query reaches each one's `minLength`. |
|
|
885
|
+
| `storageKey` | `string` | `"default"` | Namespaces the recents in `localStorage` (`haruhime:palette:<key>`). |
|
|
886
|
+
| `hotkey` | `string` | `"mod+k"` | The toggle, in shortcut syntax. `mod` is ⌘ on a Mac and Ctrl elsewhere. |
|
|
887
|
+
| `placeholder` | `string` | `"Search commands…"` | The root input's placeholder. |
|
|
888
|
+
| `label` | `string` | `"Command palette"` | The dialog's and the input's accessible name. |
|
|
889
|
+
| `calculator` | `boolean` | `true` | A `= 42` row for a query that computes (`2*21`), first in the list; Enter copies the result. |
|
|
890
|
+
| `recents` | `boolean` | `true` | A Recent group of the last five commands run, and a ranking boost by how often each ran. |
|
|
891
|
+
| `className` | `string` | none | Classes for the panel. |
|
|
892
|
+
|
|
893
|
+
Keys: ↑ ↓ move (wrapping), Home and End jump, Enter runs the active row, Escape goes back a level and then closes, Backspace on an empty input goes back a level, Tab stays put (focus never leaves the input), and the hotkey (a combo, not a chord) toggles the palette from anywhere, a field included, without typing into it. A click on the backdrop closes it, and so does the browser (a back gesture on Android). Focus returns to whatever had it. The footer reads the result count, or the copy outcome until the query changes.
|
|
894
|
+
|
|
895
|
+
Typing filters the rows with a fuzzy match: a letter or digit must start a word or follow the previous match ("cpu" finds "Copy page URL", "go" doesn't find "Sign out"), except in scripts without case or spaces (kanji, kana), which match anywhere; the title weighs most, then `keywords`, `subtitle` and `group`. Matched letters are marked. With an empty query every command is listed under its group, in order, after the Recent group.
|
|
896
|
+
|
|
897
|
+
#### `Command`
|
|
898
|
+
|
|
899
|
+
| Field | Type | What it does |
|
|
900
|
+
| --- | --- | --- |
|
|
901
|
+
| `id` | `string` | Unique in the app. Recents are stored by it, so keep it stable when a title changes. |
|
|
902
|
+
| `title` | `string` | The row. |
|
|
903
|
+
| `subtitle?` | `string` | A smaller second line. |
|
|
904
|
+
| `icon?` | `ReactNode` | A 20px slot before the title. |
|
|
905
|
+
| `keywords?` | `string[]` | Matched at a lower weight than the title. |
|
|
906
|
+
| `group?` | `string` | The heading the row sits under. Default `"Commands"`. |
|
|
907
|
+
| `shortcut?` | `string` | Shown as keys on the row, and active while the palette is mounted and closed: a combo (`"mod+shift+c"`, `"?"`) or a chord of two bare keys (`"g p"`, within 800 ms). Never fires from a field. |
|
|
908
|
+
| `when?` | `(ctx) => boolean` | Hidden when false. Read on every render of the list. |
|
|
909
|
+
| `run?` | `(ctx, args) => void \| Promise<void>` | What it does. A rejection is logged; the palette stays usable. |
|
|
910
|
+
| `page?` | `Page` | Instead of `run`: Enter pushes this page (its `commands` and `providers`), with the page's title as a crumb. |
|
|
911
|
+
| `args?` | `ArgSpec[]` | Prompts collected before `run`, one at a time: `{ name, label, type: "text" \| "number" \| "choice", choices?, validate? }`. A `choice` lists its `choices` as rows (fuzzy-filtered); `text` and `number` take the input on Enter; `validate` returns a message to block it. `run` gets them as `args[name]`. |
|
|
912
|
+
| `closeOnRun?` | `boolean` | Default `true`. |
|
|
913
|
+
|
|
914
|
+
`PaletteContext` (the `ctx` a command runs with): `navigate(href)` (`router.push`), `close()`, `push(page)` (opens the palette first when closed), `copy(text)` (clipboard, announcing "Copied" or the failure), `pathname`, and `commands` (every root command, so a page can list them).
|
|
915
|
+
|
|
916
|
+
#### `Provider`
|
|
917
|
+
|
|
918
|
+
`{ id, group?, minLength? = 2, debounceMs? = 200, search(query, signal) }`. `search` returns `Command[]` for the query and must honor the `AbortSignal`: a newer query aborts the older search, and a late result is dropped. Its rows sit under `group` (default "Results") after the static matches; while it runs the list is `aria-busy`, says "Searching…" and keeps the last rows in place, so nothing flashes per keystroke; an error says "Couldn't search, try again". `fuzzyScore(query, text)` is exported so a provider can rank its rows the way the palette does.
|
|
919
|
+
|
|
920
|
+
#### `openCommandPalette(page?)`
|
|
921
|
+
|
|
922
|
+
Opens the mounted palette from anywhere (a button, a tour), onto `page` when given. It dispatches a `window` event, so it works from a Server Component's client child without a ref.
|
|
923
|
+
|
|
924
|
+
#### `CommandPaletteButton` (client)
|
|
925
|
+
|
|
926
|
+
A ghost `Button` with a magnifier, your `children` beside it and the hotkey hint (`Ctrl K`, or `⌘K` once a Mac is detected after mount; decorative, so it isn't part of the name). With `children`, that text is the button's name; without, `label` is (default "Open command palette"). Every `Button` prop except `onClick`.
|
|
927
|
+
|
|
928
|
+
#### `siteCommands(options)`
|
|
929
|
+
|
|
930
|
+
The defaults every tool gets, in order: Navigate, Page, Account, Help. Pass only what the app has.
|
|
931
|
+
|
|
932
|
+
| Option | Type | What it builds |
|
|
933
|
+
| --- | --- | --- |
|
|
934
|
+
| `pages` | `SiteLinkItem[]` | "Go to <label>" per linked nav item (`site.go.<slug>`). |
|
|
935
|
+
| `tools` | `HaruhimeToolId \| false` | "Open <tool>" for every other tool in `HARUHIME_TOOLS` plus "Open haruhime.moe" (`site.tool.<id>`, `site.tool.home`). `false` leaves them out. |
|
|
936
|
+
| `repo` | `string` | "Open on GitHub" (`site.github`) and "Report a bug" (`site.report`, the repo's new-issue page). |
|
|
937
|
+
| `account` | `{ signedIn, signInHref, accountHref?, signOutHref? }` | "Sign in" (`site.sign-in`) while signed out; "My account" (`site.account`) and "Sign out" (`site.sign-out`, navigated, as next-kit's route expects) while signed in. |
|
|
938
|
+
| `include` | `("navigate" \| "page" \| "account" \| "help")[]` | Which groups. Default all. |
|
|
939
|
+
|
|
940
|
+
Always there: "Copy page URL" (`site.copy-url`, mod+shift+c), "Go back" (`site.back`), "Scroll to top" (`site.top`, instant under reduced motion), "Reload page" (`site.reload`) and "Keyboard shortcuts" (`site.shortcuts`, `?`), a page listing every command that has a shortcut, the app's included.
|
|
941
|
+
|
|
942
|
+
#### Calculator
|
|
943
|
+
|
|
944
|
+
`evaluate(expression)` and `formatResult(value)` are exported. The grammar: `+ - * / % ^`, unary minus, parentheses, `k` and `m` suffixes (`1.5k`), `pi` and `e`, and `sqrt`, `abs`, `round`, `floor`, `ceil`, `min` and `max`. Anything else, and a result that isn't finite, is `null`. In the palette a bare number or word is a search, not a sum.
|
|
945
|
+
|
|
946
|
+
#### Accessibility
|
|
947
|
+
|
|
948
|
+
The input is a `combobox` over a `listbox`; the active row is named by `aria-activedescendant`, so focus never leaves the input. The active row shows an `h1` left edge as well as a tint, the input row's bottom border turns `h1` while it has focus, hint rows ("Searching…", "No matching commands") are disabled options, an argument's error is an always-mounted `role="status"`, group headings aren't uppercase, rows are 36px, and the footer's `<output>` reads the result count and the copy outcome. `bun run play:axe` runs axe-core in Chromium over the open palette's states with contrast on.
|
|
949
|
+
|
|
843
950
|
### Tables
|
|
844
951
|
|
|
845
952
|
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.
|
|
@@ -865,7 +972,7 @@ Since 0.4.0. `Table`, `THead`, `TBody`, `Th` and `Td` give a data table the apps
|
|
|
865
972
|
|
|
866
973
|
| Component | Extra props | What it renders |
|
|
867
974
|
| --- | --- | --- |
|
|
868
|
-
| `Table` | `caption?: ReactNode`, `hideCaption?: boolean` (default `false`), `wrapperClassName?: string` | A `<
|
|
975
|
+
| `Table` | `caption?: ReactNode`, `hideCaption?: boolean` (default `false`), `wrapperClassName?: string`, `scrollLabel?: string` (default `"Table"`) | A `<section>` that scrolls sideways on phones, around the `<table>`. It takes keyboard focus (`tabindex="0"`) so the scroll is reachable without a mouse, and is named by the caption, or by `scrollLabel` without one (since 0.7.0; a plain `<div>` before). `className` and `ref` go on the `<table>`. The caption names the table; `hideCaption` keeps it for screen readers only, when a heading already shows. |
|
|
869
976
|
| `THead` | none | A `<thead>` in `c3`, `text-xs`, uppercase. |
|
|
870
977
|
| `TBody` | none | A `<tbody>` whose rows get a `b4` top border. Add `[&>tr]:align-top` for rows of mixed height. |
|
|
871
978
|
| `Th` | `numeric?: boolean` | A `<th>` with `scope="col"` by default. With `scope="row"` it is a row's heading, in bold `c1`. |
|
|
@@ -881,22 +988,25 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
|
|
|
881
988
|
|
|
882
989
|
## Accessibility
|
|
883
990
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
-
|
|
887
|
-
-
|
|
888
|
-
- `
|
|
889
|
-
-
|
|
890
|
-
- `CopyButton`
|
|
891
|
-
-
|
|
892
|
-
-
|
|
893
|
-
- `
|
|
894
|
-
-
|
|
895
|
-
-
|
|
991
|
+
The target is WCAG 2.2 AA. House rules, which every component follows and your own code around them should too:
|
|
992
|
+
|
|
993
|
+
- **Focus is always visible, and always the same.** The theme draws a 2px `h1` outline, offset 2px, on `:focus-visible`. Fields keep it (since 0.7.0; before, they swapped it for a 1px border change) and add an `h1` border. `RangeSlider` thumbs show a solid `h1` ring instead and keep a transparent outline, so Windows high contrast mode (forced colors) still paints one. Never `outline: none`.
|
|
994
|
+
- **Targets are 24px or more** (WCAG 2.2 2.5.8): buttons are `h-9`, chips and tabs 24px tall, slider thumbs 24px (since 0.7.0), checkboxes and radios 24px and the `Disclosure` and `HeaderMenu` buttons at least 24px tall (since 0.8.0).
|
|
995
|
+
- **Contrast is computed, not eyeballed.** The test suite checks `c1` on `h2`, `h1` on `b4` and `b5`, and the hue overrides under Setup; `StarRating` picks its text color by contrast. Text is never dimmed with `opacity` (a `text-c4` line at 70% opacity drops under 4.5:1): use a lighter palette step instead.
|
|
996
|
+
- **Color never carries meaning alone.** Accent links are underlined, a pressed `Chip` is `aria-pressed`, the current nav link is `aria-current`, `CharCounter` says "over the limit" in words, errors are text.
|
|
997
|
+
- **Live regions exist before they speak.** `CopyButton`, `AsyncButton`, `CharCounter live`, `FilterPanel`'s count and `ReportDisclosure`'s outcome render their `<output>` or `role="status"` node up front, empty, and swap the text in. Field errors are `role="status"` (polite); `Notice live tone="error"` is the one `role="alert"`.
|
|
998
|
+
- **Focus never falls to the body.** When the control you pressed goes away, focus moves somewhere sensible: `FilterPanel` to its heading, `Pagination` to "Page X of Y", `InlineConfirm` back to its trigger, `ReportDisclosure` to its outcome line. Pending buttons use `aria-disabled`, not `disabled`, so focus stays.
|
|
999
|
+
- **The palette is a combobox.** `CommandPalette` keeps focus on its input and names the active row with `aria-activedescendant`; the row shows its state with an `h1` edge, the input row shows focus, and `bun run play:axe` checks its open states in a real browser (since 0.8.0).
|
|
1000
|
+
- **Landmarks are few and named.** `PageShell` gives a skip link and `<main>`; `SiteHeader` one `<nav>` (`navLabel`, default "Main"); `SiteFooter` one `<nav>` (`navLabel`, default "Footer") with a headed `<section>` per column; `Pagination` and `LinkTabs` are labelled `<nav>`s. Give two of a kind different labels.
|
|
1001
|
+
- **Scrollable regions take focus.** `Table`'s wrapper is a focusable named section. Do the same for a `pre` inside `Prose`.
|
|
1002
|
+
- **Native first.** Fields are native inputs, selects and textareas; radios and checkboxes are native with a visible label; `RangeSlider`'s thumbs are native range inputs with `aria-valuetext` ("10+" reads as it shows); `Disclosure` and `HeaderMenu` are buttons with `aria-expanded` and `aria-controls`; `Tabs` follows the ARIA tabs pattern (one tab in the Tab order, arrows, Home and End, `aria-controls`). ARIA only where HTML has no element.
|
|
1003
|
+
- **Groups are named.** `ChipGroup`, `RangeSlider`, `RadioGroup` and `FilterRow` are fieldsets named by their label. Inside a `FilterRow`, `hideLabel` leaves the naming to the row, so each row is announced once.
|
|
1004
|
+
- **Icons are decorative; links and buttons have names.** `DiscordIcon` and `GitHubIcon` are hidden from screen readers; give the link around each one an `aria-label`, as `SiteFooter` does. Give icon-only buttons an `aria-label`, and keep `label` props meaningful. `BeatmapStats` reads "Circle size" where it shows "CS".
|
|
1005
|
+
- **Tested.** Every component is checked with axe-core's WCAG 2.0, 2.1 and 2.2 A and AA rules in the test suite (color contrast excepted: that needs a real browser). The consumer check then renders every component in a real Next.js app and runs axe in headless Chromium with contrast and target-size checks on, at a desktop and a phone width; the sites run the same pass over their pages. Interactive ones also have keyboard tests.
|
|
896
1006
|
|
|
897
1007
|
## Compatibility
|
|
898
1008
|
|
|
899
|
-
| | Supported |
|
|
1009
|
+
| Requirement | Supported |
|
|
900
1010
|
| --- | --- |
|
|
901
1011
|
| Next.js | 16 (app router). Components use `next/link` and `next/navigation`. |
|
|
902
1012
|
| React | 19 |
|
|
@@ -907,7 +1017,7 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
|
|
|
907
1017
|
|
|
908
1018
|
## Changelog and contributing
|
|
909
1019
|
|
|
910
|
-
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).
|
|
1020
|
+
See [CHANGELOG.md](./CHANGELOG.md) for what changed in each version and [CONTRIBUTING.md](./CONTRIBUTING.md) to work on the package. `bun run play` serves `playground/`, a small Next.js app in the repo that renders the components straight from `src/`, for trying a change by hand. Report security issues as described in [SECURITY.md](./SECURITY.md). Bring questions and feedback to the haruhime.moe [Discord server](https://discord.gg/bKy9kjMV4y).
|
|
911
1021
|
|
|
912
1022
|
## License
|
|
913
1023
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* so its form fields keep their values. Uncontrolled by default, or controlled with `open`.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Mon Sep 28, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
9
|
*/
|
|
10
10
|
import { type ComponentProps, type ReactNode } from "react";
|
|
11
11
|
/** Every native `<div>` prop for the wrapper, plus the button's text and the panel. */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* so its form fields keep their values. Uncontrolled by default, or controlled with `open`.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Mon Sep 28, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
9
|
*/
|
|
10
10
|
"use client";
|
|
11
11
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
@@ -26,5 +26,5 @@ export function Disclosure({ summary, children, defaultOpen = false, open, onOpe
|
|
|
26
26
|
setInner(!isOpen);
|
|
27
27
|
onOpenChange?.(!isOpen);
|
|
28
28
|
};
|
|
29
|
-
return (_jsxs("div", { className: cx("flex flex-col gap-2", className), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isOpen, "aria-controls": panelId, onClick: toggle, className: cx("inline-flex items-center gap-1 self-start font-bold text-c2 text-sm transition-colors hover:text-c1", buttonClassName), children: [summary, _jsx("span", { "aria-hidden": "true", children: isOpen ? "▴" : "▾" })] }), _jsx("div", { id: panelId, hidden: !isOpen, className: panelClassName, children: children })] }));
|
|
29
|
+
return (_jsxs("div", { className: cx("flex flex-col gap-2", className), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isOpen, "aria-controls": panelId, onClick: toggle, className: cx("inline-flex min-h-6 items-center gap-1 self-start font-bold text-c2 text-sm transition-colors hover:text-c1", buttonClassName), children: [summary, _jsx("span", { "aria-hidden": "true", children: isOpen ? "▴" : "▾" })] }), _jsx("div", { id: panelId, hidden: !isOpen, className: panelClassName, children: children })] }));
|
|
30
30
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* lives in rangeMath.ts and the boxes in RangeBox.tsx.
|
|
8
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
9
|
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
11
|
*/
|
|
12
12
|
import type { HTMLAttributes } from "react";
|
|
13
13
|
import { type GroupFrameProps } from "./GroupFrame.js";
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* lives in rangeMath.ts and the boxes in RangeBox.tsx.
|
|
8
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
9
|
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
11
|
*/
|
|
12
12
|
"use client";
|
|
13
13
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
@@ -17,12 +17,12 @@ import { GroupFrame } from "./GroupFrame.js";
|
|
|
17
17
|
import { RangeBox } from "./RangeBox.js";
|
|
18
18
|
import { defaultParse, dragEnd, nextRange, normalizeRange, percent, rangeBounds, readDraft, thumbTarget, } from "./rangeMath.js";
|
|
19
19
|
// Native range inputs, stacked on one track. Only the thumbs take the pointer, so either thumb
|
|
20
|
-
// can be dragged wherever they sit. The focus ring goes on the thumb, not the full-width input:
|
|
20
|
+
// can be dragged wherever they sit. Thumbs are 24px (size-6): WCAG 2.2's minimum target size. The focus ring goes on the thumb, not the full-width input:
|
|
21
21
|
// solid h1, so it clears 3:1 on the panel. Forced-colors mode drops box-shadow rings, so the
|
|
22
22
|
// input keeps a transparent outline (`outline-hidden`) that mode paints instead.
|
|
23
|
-
const RANGE = "pointer-events-none absolute inset-x-0 top-1/2 h-
|
|
24
|
-
"[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-
|
|
25
|
-
"[&::-moz-range-track]:bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-
|
|
23
|
+
const RANGE = "pointer-events-none absolute inset-x-0 top-1/2 h-6 w-full -translate-y-1/2 appearance-none bg-transparent focus-visible:outline-hidden disabled:cursor-not-allowed " +
|
|
24
|
+
"[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:box-border [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-h1 [&::-webkit-slider-thumb]:bg-c1 " +
|
|
25
|
+
"[&::-moz-range-track]:bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:box-border [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-h1 [&::-moz-range-thumb]:bg-c1 " +
|
|
26
26
|
"focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-h1 focus-visible:[&::-moz-range-thumb]:ring-4 focus-visible:[&::-moz-range-thumb]:ring-h1";
|
|
27
27
|
/**
|
|
28
28
|
* @function RangeSlider
|
|
@@ -94,5 +94,5 @@ export function RangeSlider({ label, min: rawMin, max: rawMax, step: rawStep = 1
|
|
|
94
94
|
const box = (end) => (_jsx(RangeBox, { label: end === "low" ? minLabel : maxLabel, text: end === "low" ? format(low) : highOpen ? openText : format(high), inputMode: inputMode, disabled: disabled, onCommit: commitDraft(end) }));
|
|
95
95
|
const lowPercent = percent(low, bounds);
|
|
96
96
|
const highPercent = highOpen ? 100 : percent(high, bounds);
|
|
97
|
-
return (_jsx(GroupFrame, { label: label, disabled: disabled, className: cx("disabled:opacity-50", className), ...props, children: _jsxs("div", { className: "flex items-center gap-3", children: [box("low"), _jsxs("div", { className: "relative h-
|
|
97
|
+
return (_jsx(GroupFrame, { label: label, disabled: disabled, className: cx("disabled:opacity-50", className), ...props, children: _jsxs("div", { className: "flex items-center gap-3", children: [box("low"), _jsxs("div", { className: "relative h-6 min-w-0 flex-1", children: [_jsx("div", { "aria-hidden": "true", className: "absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-b3" }), _jsx("div", { "aria-hidden": "true", className: "absolute inset-x-3 top-1/2 h-1.5 -translate-y-1/2", children: _jsx("div", { "data-range-fill": "", className: "absolute inset-y-0 rounded-full bg-h1 forced-colors:bg-[Highlight]", style: { left: `${lowPercent}%`, right: `${100 - highPercent}%` } }) }), _jsx("input", { ...thumb("low"), className: cx(RANGE, lowPercent > 50 && "z-10") }), _jsx("input", { ...thumb("high"), className: RANGE })] }), box("high")] }) }));
|
|
98
98
|
}
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* colors, so on and off still look different.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Mon Sep 28, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
9
|
*/
|
|
10
10
|
/** The pill: rounded, px-2.5 py-0.5, bold text-xs. */
|
|
11
|
-
export declare const CHIP = "rounded-full px-2.5 py-
|
|
11
|
+
export declare const CHIP = "rounded-full px-2.5 py-1 font-bold text-xs transition-colors";
|
|
12
12
|
/** A chip that is on (pressed or checked). */
|
|
13
13
|
export declare const CHIP_ON = "bg-h1 text-b6 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
|
|
14
14
|
/** A chip that is off. */
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* colors, so on and off still look different.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Mon Sep 28, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
9
|
*/
|
|
10
10
|
/** The pill: rounded, px-2.5 py-0.5, bold text-xs. */
|
|
11
|
-
|
|
11
|
+
// py-1 with the text-xs line height makes a 24px pill: WCAG 2.2's minimum target size.
|
|
12
|
+
export const CHIP = "rounded-full px-2.5 py-1 font-bold text-xs transition-colors";
|
|
12
13
|
/** A chip that is on (pressed or checked). */
|
|
13
14
|
export const CHIP_ON = "bg-h1 text-b6 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
|
|
14
15
|
/** A chip that is off. */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* length), so it fits any rule. Server-safe. Moved from bb.haruhime.moe.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Mon Sep 28, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
9
|
*/
|
|
10
10
|
import type { ComponentProps } from "react";
|
|
11
11
|
/** Every native `<p>` prop except children, plus the count, the limit and the unit. */
|
|
@@ -16,10 +16,12 @@ export type CharCounterProps = Omit<ComponentProps<"p">, "children"> & {
|
|
|
16
16
|
limit: number;
|
|
17
17
|
/** What is counted (default "characters"). */
|
|
18
18
|
unit?: string | undefined;
|
|
19
|
+
/** Announce "N over the limit" to screen readers when the count goes over (and silence when it comes back). */
|
|
20
|
+
live?: boolean | undefined;
|
|
19
21
|
};
|
|
20
22
|
/**
|
|
21
23
|
* @function CharCounter
|
|
22
24
|
* @param props {CharCounterProps} the count, the limit, the unit, and native `<p>` props
|
|
23
25
|
* @returns {JSX.Element} "count / limit unit" in c3, or in bold rose with ": N over the limit"
|
|
24
26
|
*/
|
|
25
|
-
export declare function CharCounter({ count, limit, unit, className, ...props }: CharCounterProps): import("react").JSX.Element;
|
|
27
|
+
export declare function CharCounter({ count, limit, unit, live, className, ...props }: CharCounterProps): import("react").JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
3
|
const format = (value) => value.toLocaleString("en-US");
|
|
4
4
|
/**
|
|
@@ -6,7 +6,9 @@ const format = (value) => value.toLocaleString("en-US");
|
|
|
6
6
|
* @param props {CharCounterProps} the count, the limit, the unit, and native `<p>` props
|
|
7
7
|
* @returns {JSX.Element} "count / limit unit" in c3, or in bold rose with ": N over the limit"
|
|
8
8
|
*/
|
|
9
|
-
export function CharCounter({ count, limit, unit = "characters", className, ...props }) {
|
|
9
|
+
export function CharCounter({ count, limit, unit = "characters", live = false, className, ...props }) {
|
|
10
10
|
const over = count - limit;
|
|
11
|
-
|
|
11
|
+
const overText = over > 0 ? `${format(over)} over the limit` : "";
|
|
12
|
+
const shown = `${format(count)} / ${format(limit)} ${unit}${over > 0 ? `: ${overText}` : ""}`;
|
|
13
|
+
return (_jsx("p", { className: cx("text-sm tabular-nums", over > 0 ? "font-bold text-rose-300" : "text-c3", className), ...props, children: live ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: shown }), _jsx("output", { "aria-live": "polite", className: "sr-only", children: overText })] })) : (shown) }));
|
|
12
14
|
}
|
|
@@ -9,5 +9,7 @@ import { FieldError, fieldControlProps, hintId } from "./FieldFrame.js";
|
|
|
9
9
|
*/
|
|
10
10
|
export function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, "aria-labelledby": labelledBy, ...props }) {
|
|
11
11
|
const labelId = `${id}-label`;
|
|
12
|
-
return (_jsxs("div", { className: cx("flex flex-col gap-1", wrapperClassName), children: [_jsxs("label", { htmlFor: id, className: "flex items-start gap-2 text-sm", children: [_jsx("input", { ...props, type: "checkbox", ...fieldControlProps({ id, hint, error, describedBy, invalid }), "aria-labelledby": labelledBy ? `${labelId} ${labelledBy}` : labelId,
|
|
12
|
+
return (_jsxs("div", { className: cx("flex flex-col gap-1", wrapperClassName), children: [_jsxs("label", { htmlFor: id, className: "flex items-start gap-2 text-sm", children: [_jsx("input", { ...props, type: "checkbox", ...fieldControlProps({ id, hint, error, describedBy, invalid }), "aria-labelledby": labelledBy ? `${labelId} ${labelledBy}` : labelId,
|
|
13
|
+
// 24px, the WCAG 2.2 target size; -mt-0.5 centers it on the first text-sm line.
|
|
14
|
+
className: cx("-mt-0.5 size-6 shrink-0 accent-h1", className) }), _jsxs("span", { children: [_jsx("span", { id: labelId, className: "font-bold text-c1", children: label }), hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(id), children: hint })] })) : null] })] }), _jsx(FieldError, { id: id, error: error })] }));
|
|
13
15
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* aria-invalid.
|
|
8
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
9
|
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
11
|
*/
|
|
12
12
|
import type { ComponentProps, ReactNode } from "react";
|
|
13
13
|
/** The label, hint and error every form field takes, keyed on the control's required id. */
|
|
@@ -44,7 +44,7 @@ export const fieldControlProps = ({ id, hint, error, describedBy, invalid, }) =>
|
|
|
44
44
|
* nothing without an error
|
|
45
45
|
*/
|
|
46
46
|
export function FieldError({ id, error }) {
|
|
47
|
-
return error ? (_jsx("div", { id: errorId(id), role: "
|
|
47
|
+
return error ? (_jsx("div", { id: errorId(id), role: "status", className: "text-rose-300 text-sm", children: error })) : null;
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
50
|
* @function FieldFrame
|