@haruhimemoe/ui 0.5.1 → 0.7.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 +21 -1
- package/README.md +35 -25
- 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/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 +2 -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/shell/NavItem.d.ts +1 -1
- package/dist/components/shell/NavItem.js +2 -2
- package/dist/components/shell/SiteFooter.d.ts +18 -6
- package/dist/components/shell/SiteFooter.js +11 -3
- package/dist/components/shell/haruhimeTools.d.ts +46 -0
- package/dist/components/shell/haruhimeTools.js +30 -0
- package/dist/components/tables/Table.d.ts +5 -3
- package/dist/components/tables/Table.js +15 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/theme.css +4 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.0] - 2026-10-03
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `CharCounter`'s `live` prop: announces only the over-limit text.
|
|
18
|
+
- `Table`'s `scrollLabel`, `SiteFooter`'s `navLabel` and `headingLevel`.
|
|
19
|
+
- README: an "Accessibility" section with the house rules every component follows.
|
|
20
|
+
|
|
21
|
+
## [0.6.0] - 2026-09-28
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `SiteFooter`'s `tools` prop: a "haruhime tools" column linking the other live haruhime.moe tools (packs, pools, bb) and "All tools" on www, with the current tool left out. `HARUHIME_TOOLS` and `haruhimeToolsColumn` export the same data for a custom footer.
|
|
26
|
+
|
|
9
27
|
## [0.5.1] - 2026-09-28
|
|
10
28
|
|
|
11
29
|
### Security
|
|
@@ -92,7 +110,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
92
110
|
- `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`).
|
|
93
111
|
- 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).
|
|
94
112
|
|
|
95
|
-
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.
|
|
113
|
+
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.7.0...HEAD
|
|
114
|
+
[0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
|
|
115
|
+
[0.6.0]: https://github.com/haruhimemoe/ui/compare/v0.5.1...v0.6.0
|
|
96
116
|
[0.5.1]: https://github.com/haruhimemoe/ui/compare/v0.5.0...v0.5.1
|
|
97
117
|
[0.5.0]: https://github.com/haruhimemoe/ui/compare/v0.4.0...v0.5.0
|
|
98
118
|
[0.4.0]: https://github.com/haruhimemoe/ui/compare/v0.3.0...v0.4.0
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-w
|
|
|
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.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
|
|
|
@@ -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
|
|
|
@@ -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.
|
|
@@ -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,10 @@ 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). |
|
|
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. |
|
|
763
769
|
| `extra` | `ReactNode` | none | Shown above the fine print, e.g. a "clear local data" button. |
|
|
764
770
|
| `finePrint` | `ReactNode` | none | One line of small print, in a `<p>`. |
|
|
765
771
|
| `parentLink` | `boolean` | `true` | Show the haruhime.moe wordmark linking the parent site. With it, the last row holds the wordmark and the icons, and the fine print sits above. Without it, the fine print shares the row with the icons. |
|
|
@@ -769,6 +775,8 @@ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub ico
|
|
|
769
775
|
| `discordHref` | `string` | none | Where the Discord icon links, such as your server's invite (`https://discord.gg/...`). Without it there is no Discord icon. The icon sits before the GitHub icon at the same size. It stays white (`text-c1`) and dims on hover instead of changing color, since Discord's brand guidelines ask that the logo not be recolored. Since 0.3.0. |
|
|
770
776
|
| `discordLabel` | `string` | `"Discord"` | The Discord link's accessible name. Since 0.4.0. |
|
|
771
777
|
|
|
778
|
+
`HARUHIME_TOOLS` (each `{ id, name, href, blurb }`, type `HaruhimeTool`, ids `HaruhimeToolId`: `"packs" | "pools" | "bb"`) and `haruhimeToolsColumn(options)` (the same column as plain data, for a footer you lay out yourself) are exported too. Since 0.6.0. They are the one place outside the wordmark that names the haruhime.moe tools: a tool joins the list when it goes live.
|
|
779
|
+
|
|
772
780
|
#### `LinkTabs`
|
|
773
781
|
|
|
774
782
|
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`.
|
|
@@ -810,7 +818,7 @@ Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (
|
|
|
810
818
|
|
|
811
819
|
#### `StarRating`
|
|
812
820
|
|
|
813
|
-
A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rating's color, dark text up to 6.5 and pale yellow above. The spectrum is osu!'s own, the same at every `--hue`, so the pill sets its colors inline. Screen readers hear "5.23 stars" and the `label` after it. Every native `<span>` prop except `children`; a `style` you pass merges over the colors.
|
|
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.
|
|
814
822
|
|
|
815
823
|
| Prop | Type | Default | What it does |
|
|
816
824
|
| --- | --- | --- | --- |
|
|
@@ -820,7 +828,7 @@ A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rati
|
|
|
820
828
|
|
|
821
829
|
#### `BeatmapStats`
|
|
822
830
|
|
|
823
|
-
A beatmap's CS, AR, OD, HP, BPM and length as a compact `<dl>`, in that order. Stats you leave out (or that aren't finite) don't show. CS, AR, OD, HP and BPM are `<abbr>`s titled with their full names. Every native `<dl>` prop except `children`. Type: `BeatmapStatKey`.
|
|
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`.
|
|
824
832
|
|
|
825
833
|
| Prop | Type | Default | What it does |
|
|
826
834
|
| --- | --- | --- | --- |
|
|
@@ -862,7 +870,7 @@ Since 0.4.0. `Table`, `THead`, `TBody`, `Th` and `Td` give a data table the apps
|
|
|
862
870
|
|
|
863
871
|
| Component | Extra props | What it renders |
|
|
864
872
|
| --- | --- | --- |
|
|
865
|
-
| `Table` | `caption?: ReactNode`, `hideCaption?: boolean` (default `false`), `wrapperClassName?: string` | A `<
|
|
873
|
+
| `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. |
|
|
866
874
|
| `THead` | none | A `<thead>` in `c3`, `text-xs`, uppercase. |
|
|
867
875
|
| `TBody` | none | A `<tbody>` whose rows get a `b4` top border. Add `[&>tr]:align-top` for rows of mixed height. |
|
|
868
876
|
| `Th` | `numeric?: boolean` | A `<th>` with `scope="col"` by default. With `scope="row"` it is a row's heading, in bold `c1`. |
|
|
@@ -878,18 +886,20 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
|
|
|
878
886
|
|
|
879
887
|
## Accessibility
|
|
880
888
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
-
|
|
884
|
-
-
|
|
885
|
-
- `
|
|
886
|
-
-
|
|
887
|
-
- `CopyButton`
|
|
888
|
-
-
|
|
889
|
-
-
|
|
890
|
-
- `
|
|
891
|
-
- `
|
|
892
|
-
-
|
|
889
|
+
The target is WCAG 2.2 AA. House rules, which every component follows and your own code around them should too:
|
|
890
|
+
|
|
891
|
+
- **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`.
|
|
892
|
+
- **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).
|
|
893
|
+
- **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.
|
|
894
|
+
- **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.
|
|
895
|
+
- **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"`.
|
|
896
|
+
- **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.
|
|
897
|
+
- **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.
|
|
898
|
+
- **Scrollable regions take focus.** `Table`'s wrapper is a focusable named section. Do the same for a `pre` inside `Prose`.
|
|
899
|
+
- **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.
|
|
900
|
+
- **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.
|
|
901
|
+
- **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".
|
|
902
|
+
- **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, and the sites run a Playwright axe pass with it on). Interactive ones also have keyboard tests.
|
|
893
903
|
|
|
894
904
|
## Compatibility
|
|
895
905
|
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
|
|
8
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
9
|
* @created Mon Sep 28, 2026
|
|
10
|
-
* @modified
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
11
11
|
*/
|
|
12
12
|
import { type ComponentProps, type ReactNode } from "react";
|
|
13
13
|
/** One radio: its value, its label, an optional inline hint, and whether it can be picked. */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
|
|
8
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
9
|
* @created Mon Sep 28, 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";
|
|
@@ -27,6 +27,6 @@ export function RadioGroup({ label, options, name, value, defaultValue, onChange
|
|
|
27
27
|
const optionId = `${id}-${i}`;
|
|
28
28
|
return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "radio", id: optionId, name: name ?? id, value: option.value, ...(value === undefined
|
|
29
29
|
? { defaultChecked: option.value === defaultValue }
|
|
30
|
-
: { checked: option.value === value }), disabled: option.disabled, required: required, "aria-
|
|
30
|
+
: { checked: option.value === value }), disabled: option.disabled, required: required, "aria-labelledby": `${optionId}-label`, "aria-describedby": option.hint ? hintId(optionId) : undefined, onChange: () => onChange?.(option.value), className: "mt-1 accent-h1" }), _jsxs("span", { children: [_jsx("span", { id: `${optionId}-label`, className: "font-bold text-c1", children: option.label }), option.hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(optionId), children: option.hint })] })) : null] })] }, option.value));
|
|
31
31
|
}), hint ? (_jsx("div", { id: hintId(id), className: "text-c4 text-xs", children: hint })) : null, _jsx(FieldError, { id: id, error: error })] }));
|
|
32
32
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
|
|
7
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
8
|
* @created Mon Sep 28, 2026
|
|
9
|
-
* @modified
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
10
|
*/
|
|
11
11
|
import { type ReactNode } from "react";
|
|
12
12
|
/** What `onSubmit` says: done (with what to say) or not (with the error). */
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
|
|
7
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
8
|
* @created Mon Sep 28, 2026
|
|
9
|
-
* @modified
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
10
|
*/
|
|
11
11
|
"use client";
|
|
12
12
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
-
import { useId, useRef, useState } from "react";
|
|
13
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
14
14
|
import { cx } from "../../utils/cx.js";
|
|
15
15
|
import { Button } from "../basics/Button.js";
|
|
16
16
|
import { Disclosure } from "../basics/Disclosure.js";
|
|
@@ -27,9 +27,13 @@ export function ReportDisclosure({ onSubmit, summary = "Report", label = "What's
|
|
|
27
27
|
const [error, setError] = useState(null);
|
|
28
28
|
const [done, setDone] = useState(null);
|
|
29
29
|
const running = useRef(false);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const status = useRef(null);
|
|
31
|
+
// The form goes away with the button that had focus, so focus lands on the outcome instead
|
|
32
|
+
// of falling to the body.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (done !== null)
|
|
35
|
+
status.current?.focus();
|
|
36
|
+
}, [done]);
|
|
33
37
|
const send = async (event) => {
|
|
34
38
|
event.preventDefault();
|
|
35
39
|
if (running.current)
|
|
@@ -50,5 +54,5 @@ export function ReportDisclosure({ onSubmit, summary = "Report", label = "What's
|
|
|
50
54
|
running.current = false;
|
|
51
55
|
setPending(false);
|
|
52
56
|
};
|
|
53
|
-
return (
|
|
57
|
+
return (_jsxs("div", { className: cx("flex flex-col gap-2", className), children: [done === null ? (_jsx(Disclosure, { summary: summary, children: _jsxs("form", { className: "flex flex-col gap-2", onSubmit: send, children: [_jsx(Textarea, { id: `${id}-reason`, label: label, hint: hint, error: error ?? undefined, value: reason, rows: rows, minLength: minLength, maxLength: maxLength, required: true, onChange: (event) => setReason(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: "secondary", disabled: pending, className: "self-start", children: pending ? pendingLabel : submitLabel })] }) })) : null, _jsx("p", { ref: status, role: "status", tabIndex: -1, className: done === null ? "sr-only" : "text-c2 text-sm", children: done })] }));
|
|
54
58
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/forms/fieldStyles.ts
|
|
3
|
-
* @desc Shared classes for
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* groups and filter rows share.
|
|
3
|
+
* * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
|
|
4
|
+
* the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
|
|
5
|
+
* every control gets, plus an h1 border; an invalid field keeps its rose border until it
|
|
6
|
+
* takes focus, when the h1 border and ring take over so focus stays visible.
|
|
8
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
8
|
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
11
10
|
*/
|
|
12
11
|
/** A field or group label: bold c3 at text-sm (internal). */
|
|
13
12
|
export declare const FIELD_LABEL = "font-bold text-c3 text-sm";
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/forms/fieldStyles.ts
|
|
3
|
-
* @desc Shared classes for
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* groups and filter rows share.
|
|
3
|
+
* * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
|
|
4
|
+
* the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
|
|
5
|
+
* every control gets, plus an h1 border; an invalid field keeps its rose border until it
|
|
6
|
+
* takes focus, when the h1 border and ring take over so focus stays visible.
|
|
8
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
8
|
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
11
10
|
*/
|
|
12
11
|
import { cx } from "../../utils/cx.js";
|
|
13
|
-
const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1
|
|
12
|
+
const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1";
|
|
14
13
|
/** A field or group label: bold c3 at text-sm (internal). */
|
|
15
14
|
export const FIELD_LABEL = "font-bold text-c3 text-sm";
|
|
16
15
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* not finite doesn't show. The short names are `<abbr>`s with the full name as a title.
|
|
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, ReactNode } from "react";
|
|
11
11
|
/** The stats BeatmapStats shows, in order. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
-
|
|
3
|
+
// The short form shows (and titles a tooltip); screen readers get the full name instead.
|
|
4
|
+
const abbr = (short, full) => (_jsxs("abbr", { title: full, children: [_jsx("span", { "aria-hidden": "true", children: short }), _jsx("span", { className: "sr-only", children: full })] }));
|
|
4
5
|
const LABELS = {
|
|
5
6
|
cs: abbr("CS", "Circle size"),
|
|
6
7
|
ar: abbr("AR", "Approach rate"),
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* They are the same at every --hue, like the wordmark's brand colors.
|
|
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
|
+
type Rgb = readonly [number, number, number];
|
|
10
11
|
/**
|
|
11
12
|
* @function starRatingColor
|
|
12
13
|
* @param stars {number} a star rating
|
|
@@ -14,10 +15,19 @@
|
|
|
14
15
|
* straight blend between the two stops around it otherwise
|
|
15
16
|
*/
|
|
16
17
|
export declare const starRatingColor: (stars: number) => string;
|
|
18
|
+
/**
|
|
19
|
+
* @function contrastRatio
|
|
20
|
+
* @param a {Rgb} one color
|
|
21
|
+
* @param b {Rgb} the other
|
|
22
|
+
* @returns {number} the WCAG contrast ratio between them, 1 to 21
|
|
23
|
+
*/
|
|
24
|
+
export declare const contrastRatio: (a: Rgb, b: Rgb) => number;
|
|
17
25
|
/**
|
|
18
26
|
* @function starRatingTextColor
|
|
19
|
-
* @param stars {number}
|
|
20
|
-
* @returns {string} text
|
|
21
|
-
*
|
|
27
|
+
* @param stars {number} the rating
|
|
28
|
+
* @returns {string} the text color on {@link starRatingColor}'s pill: osu!'s black up to the
|
|
29
|
+
* dark end, its pale gold from there, white only where neither clears 4.5:1 (the violet
|
|
30
|
+
* band around 6.5 to 7 stars)
|
|
22
31
|
*/
|
|
23
32
|
export declare const starRatingTextColor: (stars: number) => string;
|
|
33
|
+
export {};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* They are the same at every --hue, like the wordmark's brand colors.
|
|
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
|
const STOPS = [
|
|
11
11
|
[0.1, [66, 144, 251]],
|
|
@@ -45,4 +45,41 @@ export const starRatingColor = (stars) => {
|
|
|
45
45
|
* @returns {string} text that reads on `starRatingColor(stars)`: black up to 6.5, then osu!'s pale
|
|
46
46
|
* yellow on the dark end of the spectrum
|
|
47
47
|
*/
|
|
48
|
-
|
|
48
|
+
const BLACK = [0, 0, 0];
|
|
49
|
+
const GOLD = [255, 217, 102];
|
|
50
|
+
const WHITE = [255, 255, 255];
|
|
51
|
+
const luminance = ([r, g, b]) => {
|
|
52
|
+
const channel = (v) => {
|
|
53
|
+
const c = v / 255;
|
|
54
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
55
|
+
};
|
|
56
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* @function contrastRatio
|
|
60
|
+
* @param a {Rgb} one color
|
|
61
|
+
* @param b {Rgb} the other
|
|
62
|
+
* @returns {number} the WCAG contrast ratio between them, 1 to 21
|
|
63
|
+
*/
|
|
64
|
+
export const contrastRatio = (a, b) => {
|
|
65
|
+
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
66
|
+
return (light + 0.05) / (dark + 0.05);
|
|
67
|
+
};
|
|
68
|
+
const parse = (color) => (color.match(/\d+/g) ?? ["0", "0", "0"]).slice(0, 3).map(Number);
|
|
69
|
+
/**
|
|
70
|
+
* @function starRatingTextColor
|
|
71
|
+
* @param stars {number} the rating
|
|
72
|
+
* @returns {string} the text color on {@link starRatingColor}'s pill: osu!'s black up to the
|
|
73
|
+
* dark end, its pale gold from there, white only where neither clears 4.5:1 (the violet
|
|
74
|
+
* band around 6.5 to 7 stars)
|
|
75
|
+
*/
|
|
76
|
+
export const starRatingTextColor = (stars) => {
|
|
77
|
+
const pill = parse(starRatingColor(stars));
|
|
78
|
+
if (stars < 6.5 && contrastRatio(BLACK, pill) >= 4.5)
|
|
79
|
+
return css(BLACK);
|
|
80
|
+
if (contrastRatio(GOLD, pill) >= 4.5)
|
|
81
|
+
return css(GOLD);
|
|
82
|
+
if (contrastRatio(BLACK, pill) >= 4.5)
|
|
83
|
+
return css(BLACK);
|
|
84
|
+
return css(WHITE);
|
|
85
|
+
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* browser, so both lists print the same markup.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Thu Sep 24, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Sat Oct 3, 2026
|
|
9
9
|
*/
|
|
10
10
|
import type { SiteLinkItem } from "./links.js";
|
|
11
11
|
/** One nav entry, its aria-current and the finished classes for its link. */
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
* browser, so both lists print the same markup.
|
|
7
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
8
|
* @created Thu Sep 24, 2026
|
|
9
|
-
* @modified
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
10
|
*/
|
|
11
11
|
import { AutoLink } from "../basics/AutoLink.js";
|
|
12
12
|
import { LinkNote } from "./LinkNote.js";
|
|
@@ -17,7 +17,7 @@ import { LinkNote } from "./LinkNote.js";
|
|
|
17
17
|
*/
|
|
18
18
|
export function NavItem({ item, current, linkClassName }) {
|
|
19
19
|
if (!item.href) {
|
|
20
|
-
return (_jsx("li", { children: _jsxs("span", {
|
|
20
|
+
return (_jsx("li", { children: _jsxs("span", { className: "text-c4", children: [item.label, _jsx(LinkNote, { note: item.note })] }) }));
|
|
21
21
|
}
|
|
22
22
|
return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: linkClassName, children: item.label }) }));
|
|
23
23
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/shell/SiteFooter.tsx
|
|
3
|
-
* @desc Site footer:
|
|
4
|
-
* a small note, like "soon"), an optional
|
|
3
|
+
* @desc Site footer: link columns from data under one nav landmark, each a section headed by its title (entries without an href show as text with
|
|
4
|
+
* a small note, like "soon"), an optional "haruhime tools" column linking the other tools,
|
|
5
|
+
* an optional extra slot, one line of fine print, the
|
|
5
6
|
* haruhime.moe wordmark linking the parent site, a GitHub icon link, and an optional
|
|
6
7
|
* Discord icon link beside it (white, as Discord's brand guidelines ask).
|
|
7
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
9
|
* @created Wed Sep 23, 2026
|
|
9
|
-
* @modified
|
|
10
|
+
* @modified Sat Oct 3, 2026
|
|
10
11
|
*/
|
|
11
12
|
import type { ComponentProps, ReactNode } from "react";
|
|
13
|
+
import type { HeadingLevel } from "../basics/cardStyles.js";
|
|
14
|
+
import { type HaruhimeToolsOptions } from "./haruhimeTools.js";
|
|
12
15
|
import { type SiteLinkItem } from "./links.js";
|
|
13
|
-
/** One footer column: a title (
|
|
16
|
+
/** One footer column: a title (its heading, which names the column's section) and its entries. */
|
|
14
17
|
export type SiteFooterColumn = {
|
|
15
18
|
title: string;
|
|
16
19
|
items: readonly SiteLinkItem[];
|
|
@@ -19,6 +22,15 @@ export type SiteFooterColumn = {
|
|
|
19
22
|
export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
|
|
20
23
|
/** Link columns, left to right. Internal hrefs use `next/link`, external ones a plain `<a>`. */
|
|
21
24
|
columns?: readonly SiteFooterColumn[] | undefined;
|
|
25
|
+
/** The one nav landmark around the columns. Default "Footer". Keep it unlike the header's. */
|
|
26
|
+
navLabel?: string | undefined;
|
|
27
|
+
/** The column titles' heading level. Default 2. */
|
|
28
|
+
headingLevel?: HeadingLevel | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Adds the "haruhime tools" column (haruhimeToolsColumn): the other haruhime.moe tools and
|
|
31
|
+
* "All tools". `current` leaves this tool out; `position` places it (default 1).
|
|
32
|
+
*/
|
|
33
|
+
tools?: HaruhimeToolsOptions | undefined;
|
|
22
34
|
/** Rendered above the fine print, e.g. a "clear local data" control. */
|
|
23
35
|
extra?: ReactNode;
|
|
24
36
|
/** One line of small print, e.g. a trademark notice. */
|
|
@@ -38,8 +50,8 @@ export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
|
|
|
38
50
|
};
|
|
39
51
|
/**
|
|
40
52
|
* @function SiteFooter
|
|
41
|
-
* @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
|
|
53
|
+
* @param props {SiteFooterProps} columns, the tools column, extra slot, fine print, parent, GitHub and Discord
|
|
42
54
|
* links and their names, and native footer props
|
|
43
55
|
* @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
|
|
44
56
|
*/
|
|
45
|
-
export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, discordLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
|
|
57
|
+
export declare function SiteFooter({ columns: ownColumns, navLabel, headingLevel, tools, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, discordLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
|
|
@@ -4,6 +4,7 @@ import { AutoLink } from "../basics/AutoLink.js";
|
|
|
4
4
|
import { DiscordIcon } from "../icons/DiscordIcon.js";
|
|
5
5
|
import { GitHubIcon } from "../icons/GitHubIcon.js";
|
|
6
6
|
import { HaruhimeWordmarkLink } from "../icons/HaruhimeWordmarkLink.js";
|
|
7
|
+
import { haruhimeToolsColumn } from "./haruhimeTools.js";
|
|
7
8
|
import { LinkNote } from "./LinkNote.js";
|
|
8
9
|
import { linkItemKey } from "./links.js";
|
|
9
10
|
// The GitHub icon link in the bottom row.
|
|
@@ -15,11 +16,16 @@ const DISCORD_LINK = "shrink-0 text-c1 transition-opacity hover:opacity-80";
|
|
|
15
16
|
const GRID_COLUMNS = ["", "", "sm:grid-cols-2", "sm:grid-cols-3", "sm:grid-cols-4"];
|
|
16
17
|
/**
|
|
17
18
|
* @function SiteFooter
|
|
18
|
-
* @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
|
|
19
|
+
* @param props {SiteFooterProps} columns, the tools column, extra slot, fine print, parent, GitHub and Discord
|
|
19
20
|
* links and their names, and native footer props
|
|
20
21
|
* @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
|
|
21
22
|
*/
|
|
22
|
-
export function SiteFooter({ columns = [], extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", discordHref, discordLabel = "Discord", className, ...props }) {
|
|
23
|
+
export function SiteFooter({ columns: ownColumns = [], navLabel = "Footer", headingLevel = 2, tools, extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", discordHref, discordLabel = "Discord", className, ...props }) {
|
|
24
|
+
const columns = [...ownColumns];
|
|
25
|
+
if (tools) {
|
|
26
|
+
const at = Math.max(0, Math.min(tools.position ?? 1, columns.length));
|
|
27
|
+
columns.splice(at, 0, haruhimeToolsColumn(tools));
|
|
28
|
+
}
|
|
23
29
|
const fine = finePrint ? _jsx("p", { className: "text-c4 text-xs", children: finePrint }) : null;
|
|
24
30
|
const github = githubHref ? (_jsx("a", { href: githubHref, "aria-label": githubLabel, className: ICON_LINK, children: _jsx(GitHubIcon, {}) })) : null;
|
|
25
31
|
const discord = discordHref ? (_jsx("a", { href: discordHref, "aria-label": discordLabel, className: DISCORD_LINK, children: _jsx(DiscordIcon, {}) })) : null;
|
|
@@ -28,5 +34,7 @@ export function SiteFooter({ columns = [], extra, finePrint, parentLink = true,
|
|
|
28
34
|
// With the wordmark, the fine print gets its own line and the row holds wordmark + icon.
|
|
29
35
|
// Without it, the fine print shares the row with the icon.
|
|
30
36
|
const rowStart = parentLink ? _jsx(HaruhimeWordmarkLink, { href: parentHref }) : fine;
|
|
31
|
-
|
|
37
|
+
const Heading = `h${headingLevel}`;
|
|
38
|
+
const columnId = (title) => `footer-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
39
|
+
return (_jsx("footer", { className: cx("border-b4 border-t bg-b6 text-c3 text-sm", className), ...props, children: _jsxs("div", { className: "mx-auto flex max-w-5xl flex-col gap-8 px-4 py-10", children: [columns.length > 0 ? (_jsx("nav", { "aria-label": navLabel, className: cx("grid gap-8", GRID_COLUMNS[Math.min(columns.length, 4)]), children: columns.map((column) => (_jsxs("section", { "aria-labelledby": columnId(column.title), children: [_jsx(Heading, { id: columnId(column.title), className: "mb-3 font-bold text-c4 text-xs uppercase tracking-wide", children: column.title }), _jsx("ul", { className: "flex flex-col gap-2", children: column.items.map((item) => (_jsxs("li", { children: [item.href ? (_jsx(AutoLink, { href: item.href, className: "wrap-anywhere transition-colors hover:text-c1", children: item.label })) : (_jsx("span", { children: item.label })), _jsx(LinkNote, { note: item.note })] }, linkItemKey(item)))) })] }, column.title))) })) : null, extra || fine || rowStart || icons ? (_jsxs("div", { className: "flex flex-col gap-3 border-b4 border-t pt-6", children: [extra, parentLink ? fine : null, rowStart || icons ? (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [rowStart, icons] })) : null] })) : null] }) }));
|
|
32
40
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/haruhimeTools.ts
|
|
3
|
+
* @desc The haruhime.moe tools as data (HARUHIME_TOOLS) and the footer column that links them
|
|
4
|
+
* from each tool (haruhimeToolsColumn): every other tool plus "All tools" on the parent
|
|
5
|
+
* site. Sibling subdomains are separate sites to search engines, so each tool links the
|
|
6
|
+
* others. Server-safe: no directive, no hooks.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { SiteLinkItem } from "./links.js";
|
|
12
|
+
/** A live haruhime.moe tool's id: its subdomain. */
|
|
13
|
+
export type HaruhimeToolId = "packs" | "pools" | "bb";
|
|
14
|
+
/** One live haruhime.moe tool: its name, home page and what it is in a few words. */
|
|
15
|
+
export type HaruhimeTool = {
|
|
16
|
+
id: HaruhimeToolId;
|
|
17
|
+
name: string;
|
|
18
|
+
href: string;
|
|
19
|
+
/** A few words after the name in the footer, like "mappool downloads". */
|
|
20
|
+
blurb: string;
|
|
21
|
+
};
|
|
22
|
+
/** The live haruhime.moe tools in launch order. A tool joins this list once it's live. */
|
|
23
|
+
export declare const HARUHIME_TOOLS: readonly HaruhimeTool[];
|
|
24
|
+
/** haruhimeToolsColumn's (and SiteFooter's `tools` prop's) options. */
|
|
25
|
+
export type HaruhimeToolsOptions = {
|
|
26
|
+
/** The tool the footer sits on. It's left out of the list (its own column links it). */
|
|
27
|
+
current?: HaruhimeToolId | undefined;
|
|
28
|
+
/** The column's title (and nav landmark name). Default "haruhime tools". */
|
|
29
|
+
title?: string | undefined;
|
|
30
|
+
/** The last entry, linking the parent site. Default "All tools"; false leaves it out. */
|
|
31
|
+
allLabel?: string | false | undefined;
|
|
32
|
+
/** Where "All tools" links. Default "https://www.haruhime.moe". */
|
|
33
|
+
allHref?: string | undefined;
|
|
34
|
+
/** Where SiteFooter puts the column among `columns` (0 is first). Default 1, after the first. */
|
|
35
|
+
position?: number | undefined;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* @function haruhimeToolsColumn
|
|
39
|
+
* @param options {HaruhimeToolsOptions} the current tool, title and "All tools" link
|
|
40
|
+
* @returns {{ title: string; items: SiteLinkItem[] }} a footer column: each other tool as
|
|
41
|
+
* "name: blurb", then "All tools" on the parent site
|
|
42
|
+
*/
|
|
43
|
+
export declare function haruhimeToolsColumn(options?: HaruhimeToolsOptions): {
|
|
44
|
+
title: string;
|
|
45
|
+
items: SiteLinkItem[];
|
|
46
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/haruhimeTools.ts
|
|
3
|
+
* @desc The haruhime.moe tools as data (HARUHIME_TOOLS) and the footer column that links them
|
|
4
|
+
* from each tool (haruhimeToolsColumn): every other tool plus "All tools" on the parent
|
|
5
|
+
* site. Sibling subdomains are separate sites to search engines, so each tool links the
|
|
6
|
+
* others. Server-safe: no directive, no hooks.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
/** The live haruhime.moe tools in launch order. A tool joins this list once it's live. */
|
|
12
|
+
export const HARUHIME_TOOLS = [
|
|
13
|
+
{ id: "packs", name: "packs", href: "https://packs.haruhime.moe", blurb: "mappool downloads" },
|
|
14
|
+
{ id: "pools", name: "pools", href: "https://pools.haruhime.moe", blurb: "mappool builder" },
|
|
15
|
+
{ id: "bb", name: "bb", href: "https://bb.haruhime.moe", blurb: "osu! BBCode editor" },
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* @function haruhimeToolsColumn
|
|
19
|
+
* @param options {HaruhimeToolsOptions} the current tool, title and "All tools" link
|
|
20
|
+
* @returns {{ title: string; items: SiteLinkItem[] }} a footer column: each other tool as
|
|
21
|
+
* "name: blurb", then "All tools" on the parent site
|
|
22
|
+
*/
|
|
23
|
+
export function haruhimeToolsColumn(options = {}) {
|
|
24
|
+
const { current, title = "haruhime tools", allLabel = "All tools" } = options;
|
|
25
|
+
const items = HARUHIME_TOOLS.filter((tool) => tool.id !== current).map((tool) => ({ href: tool.href, label: `${tool.name}: ${tool.blurb}` }));
|
|
26
|
+
if (allLabel !== false) {
|
|
27
|
+
items.push({ href: options.allHref ?? "https://www.haruhime.moe", label: allLabel });
|
|
28
|
+
}
|
|
29
|
+
return { title, items };
|
|
30
|
+
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* Server-safe. Fill it with THead, TBody, Th and Td.
|
|
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
|
-
import type
|
|
10
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
11
11
|
/** Every native `<table>` prop (including `ref`), plus a caption and the wrapper's classes. */
|
|
12
12
|
export type TableProps = ComponentProps<"table"> & {
|
|
13
13
|
/** Names the table for screen readers. Shown above it unless `hideCaption`. */
|
|
@@ -16,6 +16,8 @@ export type TableProps = ComponentProps<"table"> & {
|
|
|
16
16
|
hideCaption?: boolean | undefined;
|
|
17
17
|
/** Classes for the wrapper `<div>` that scrolls sideways. */
|
|
18
18
|
wrapperClassName?: string | undefined;
|
|
19
|
+
/** The scroll wrapper's accessible name when there is no caption. Default "Table". */
|
|
20
|
+
scrollLabel?: string | undefined;
|
|
19
21
|
};
|
|
20
22
|
/**
|
|
21
23
|
* @function Table
|
|
@@ -23,4 +25,4 @@ export type TableProps = ComponentProps<"table"> & {
|
|
|
23
25
|
* wrapperClassName; `className` and `ref` go on the `<table>`
|
|
24
26
|
* @returns {JSX.Element} an `overflow-x-auto` `<div>` around the `<table>` and its caption
|
|
25
27
|
*/
|
|
26
|
-
export declare function Table({ caption, hideCaption, wrapperClassName, className, children, ...props }: TableProps): import("react").JSX.Element;
|
|
28
|
+
export declare function Table({ caption, hideCaption, wrapperClassName, scrollLabel, className, children, ...props }: TableProps): import("react").JSX.Element;
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/tables/Table.tsx
|
|
4
|
+
* @desc A data table on the apps' look: full width, small left-aligned text, in a wrapper that
|
|
5
|
+
* scrolls sideways on phones, with an optional caption (visible, or for screen readers only).
|
|
6
|
+
* Server-safe. Fill it with THead, TBody, Th and Td.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
|
+
*/
|
|
11
|
+
import { useId } from "react";
|
|
2
12
|
import { cx } from "../../utils/cx.js";
|
|
3
13
|
import { FIELD_LABEL } from "../forms/fieldStyles.js";
|
|
4
14
|
/**
|
|
@@ -7,6 +17,9 @@ import { FIELD_LABEL } from "../forms/fieldStyles.js";
|
|
|
7
17
|
* wrapperClassName; `className` and `ref` go on the `<table>`
|
|
8
18
|
* @returns {JSX.Element} an `overflow-x-auto` `<div>` around the `<table>` and its caption
|
|
9
19
|
*/
|
|
10
|
-
export function Table({ caption, hideCaption = false, wrapperClassName, className, children, ...props }) {
|
|
11
|
-
|
|
20
|
+
export function Table({ caption, hideCaption = false, wrapperClassName, scrollLabel = "Table", className, children, ...props }) {
|
|
21
|
+
const captionId = useId();
|
|
22
|
+
return (_jsx("section", {
|
|
23
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region needs keyboard focus
|
|
24
|
+
tabIndex: 0, "aria-labelledby": caption ? captionId : undefined, "aria-label": caption ? undefined : scrollLabel, className: cx("overflow-x-auto", wrapperClassName), children: _jsxs("table", { className: cx("w-full text-left text-sm", className), ...props, children: [caption ? (_jsx("caption", { id: captionId, className: hideCaption ? "sr-only" : `mb-2 text-left ${FIELD_LABEL}`, children: caption })) : null, children] }) }));
|
|
12
25
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export { type BeatmapStatKey, BeatmapStats, type BeatmapStatsProps, } from "./co
|
|
|
51
51
|
export { ModBadge, type ModBadgeProps } from "./components/osu/ModBadge.js";
|
|
52
52
|
export { StarRating, type StarRatingProps } from "./components/osu/StarRating.js";
|
|
53
53
|
export { HeaderMenu, type HeaderMenuItem, type HeaderMenuProps, } from "./components/shell/HeaderMenu.js";
|
|
54
|
+
export { HARUHIME_TOOLS, type HaruhimeTool, type HaruhimeToolId, type HaruhimeToolsOptions, haruhimeToolsColumn, } from "./components/shell/haruhimeTools.js";
|
|
54
55
|
export { type LinkTabItem, LinkTabs, type LinkTabsProps } from "./components/shell/LinkTabs.js";
|
|
55
56
|
export type { SiteLinkItem } from "./components/shell/links.js";
|
|
56
57
|
export { NavLinks, type NavLinksProps, type SiteNavAlign } from "./components/shell/NavLinks.js";
|
package/dist/index.js
CHANGED
|
@@ -57,6 +57,7 @@ export { ModBadge } from "./components/osu/ModBadge.js";
|
|
|
57
57
|
export { StarRating } from "./components/osu/StarRating.js";
|
|
58
58
|
// Shell
|
|
59
59
|
export { HeaderMenu, } from "./components/shell/HeaderMenu.js";
|
|
60
|
+
export { HARUHIME_TOOLS, haruhimeToolsColumn, } from "./components/shell/haruhimeTools.js";
|
|
60
61
|
export { LinkTabs } from "./components/shell/LinkTabs.js";
|
|
61
62
|
export { NavLinks } from "./components/shell/NavLinks.js";
|
|
62
63
|
export { PageShell } from "./components/shell/PageShell.js";
|
package/dist/theme.css
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
* @import "@haruhimemoe/ui/theme.css";
|
|
11
11
|
*
|
|
12
12
|
* Set --hue on :root to recolor everything (default 333, pink). Some hues need --h2-l
|
|
13
|
-
* (h2 lightness, default 45%) or --h1-l (h1 lightness, default
|
|
14
|
-
* 4.5:1; the README lists values. Load Nunito with next/font as the --font-nunito variable;
|
|
13
|
+
* (h2 lightness, default 45%) or --h1-l (h1 lightness, default 76%, which clears 4.5:1 on
|
|
14
|
+
* b5 at every hue) moved to keep text at 4.5:1; the README lists values. Load Nunito with next/font as the --font-nunito variable;
|
|
15
15
|
* without it the sans stack falls back to the system font.
|
|
16
16
|
* @author David @dvhsh (https://dvh.sh)
|
|
17
17
|
* @created Wed Sep 23, 2026
|
|
18
|
-
* @modified
|
|
18
|
+
* @modified Sat Oct 3, 2026
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
/* This file ships as dist/theme.css, so './' is the package's compiled components. */
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
--color-c3: hsl(var(--hue) 40% 80%);
|
|
40
40
|
--color-c4: hsl(var(--hue) 40% 70%);
|
|
41
41
|
|
|
42
|
-
--color-h1: hsl(var(--hue) 100% var(--h1-l,
|
|
42
|
+
--color-h1: hsl(var(--hue) 100% var(--h1-l, 76%));
|
|
43
43
|
--color-h2: hsl(var(--hue) 50% var(--h2-l, 45%));
|
|
44
44
|
|
|
45
45
|
--font-sans: var(--font-nunito), ui-sans-serif, system-ui, sans-serif;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haruhimemoe/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "React components for the haruhime.moe osu! tools on Next.js: the osu!-web-style palette as a Tailwind theme, buttons, cards, form fields and confirms, filter controls, tables, osu! beatmap display pieces and the site header and footer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"osu",
|