@haruhimemoe/ui 0.1.0 → 0.3.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 +59 -18
- package/dist/components/basics/Card.d.ts +9 -6
- package/dist/components/basics/Card.js +8 -6
- package/dist/components/basics/Prose.d.ts +5 -3
- package/dist/components/basics/Prose.js +3 -2
- package/dist/components/icons/DiscordIcon.d.ts +21 -0
- package/dist/components/icons/DiscordIcon.js +10 -0
- package/dist/components/shell/NavItem.d.ts +30 -0
- package/dist/components/shell/NavItem.js +28 -0
- package/dist/components/shell/NavLinks.d.ts +8 -4
- package/dist/components/shell/NavLinks.js +13 -22
- package/dist/components/shell/NavListClient.d.ts +26 -0
- package/dist/components/shell/NavListClient.js +27 -0
- package/dist/components/shell/SiteFooter.d.ts +8 -5
- package/dist/components/shell/SiteFooter.js +14 -5
- package/dist/components/shell/SiteHeader.d.ts +4 -3
- package/dist/components/shell/links.d.ts +12 -3
- package/dist/components/shell/links.js +22 -7
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- 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.3.0] - 2026-09-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `DiscordIcon`: the Discord logo as an inline SVG in the current text color, sized and hidden from screen readers like `GitHubIcon`. The path is Simple Icons' `discord.svg` (CC0 1.0). Discord's brand guidelines ask for the logo in color, black or white, so set one of those as the text color.
|
|
14
|
+
- `SiteFooter` takes `discordHref`. When set, a Discord icon link named "Discord" sits before the GitHub icon in the last row, at the same size. The logo stays white and dims on hover instead of changing color, as Discord's guidelines ask. Without `discordHref` the footer is unchanged.
|
|
15
|
+
|
|
16
|
+
## [0.2.0] - 2026-09-24
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `Card` takes `headingLevel` (`2`, `3` or `4`, default `2`), like `FilterPanel`, for a card that sits under another heading.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- `SiteHeader` and `NavLinks` no longer send tailwind-merge to the browser on every page. `NavLinks` is now a Server Component: it merges its classes on the server and hands finished class strings to a small client list that only reads the path for `aria-current`. In a Next.js 16 build, the nav's client chunk drops from about 12 KB to 4 KB gzipped. When no link can be the current page (all external or text-only), the list renders on the server alone and nothing in the nav hydrates.
|
|
25
|
+
- `Prose` no longer puts the `h2` or `h3` top margin above a heading that opens the block: its first child gets `mt-0`. The README shows the one-class pattern for content wrapped in `<section>`s.
|
|
26
|
+
|
|
9
27
|
## [0.1.0] - 2026-09-23
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -19,5 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
19
37
|
- `className` on every component, and the extras passed to `buttonClasses` and `fieldClasses`, merge with tailwind-merge: a caller's class replaces a built-in one that sets the same property (`fieldClasses("w-auto")` drops `w-full`).
|
|
20
38
|
- Shell: `SiteHeader` (brand slot, nav links as data with `aria-current`, actions slot), `NavLinks`, `SiteFooter` (link columns as data, fine print, the haruhime.moe wordmark and a GitHub link) and `PageShell` (skip link, header, main, footer).
|
|
21
39
|
|
|
22
|
-
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.
|
|
40
|
+
[unreleased]: https://github.com/haruhimemoe/ui/compare/v0.3.0...HEAD
|
|
41
|
+
[0.3.0]: https://github.com/haruhimemoe/ui/compare/v0.2.0...v0.3.0
|
|
42
|
+
[0.2.0]: https://github.com/haruhimemoe/ui/compare/v0.1.0...v0.2.0
|
|
23
43
|
[0.1.0]: https://github.com/haruhimemoe/ui/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, cards, form fields, filter controls (toggle chips, a two-thumb range slider, a filter panel) and the site header, footer and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
|
|
4
4
|
|
|
5
|
+
See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
|
|
6
|
+
|
|
7
|
+
This README describes version 0.3.0. Anything marked "since 0.3.0" is not in 0.2.0, and anything marked "since 0.2.0" is not in 0.1.0. [CHANGELOG.md](./CHANGELOG.md) lists what changed in each version.
|
|
8
|
+
|
|
5
9
|
## Requirements
|
|
6
10
|
|
|
7
11
|
- Next.js 16 (app router)
|
|
8
12
|
- React 19
|
|
9
|
-
- Tailwind CSS 4.1 or later
|
|
13
|
+
- Tailwind CSS 4.1 or later, below 5
|
|
10
14
|
|
|
11
|
-
These are peer dependencies. The package is ESM only.
|
|
15
|
+
These are peer dependencies. The package is ESM only, and its `engines` field asks for Node.js 22.12 or later.
|
|
12
16
|
|
|
13
17
|
## Install
|
|
14
18
|
|
|
@@ -147,16 +151,17 @@ export default function Home() {
|
|
|
147
151
|
|
|
148
152
|
Import every component from `@haruhimemoe/ui`, in Server and Client Components alike.
|
|
149
153
|
|
|
150
|
-
- **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider
|
|
154
|
+
- **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider` and `FilterPanel`. Each file starts with `"use client"`.
|
|
155
|
+
- **`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.
|
|
151
156
|
- **Everything else is server-safe:** no state, no effects, no browser APIs.
|
|
152
157
|
|
|
153
158
|
A Server Component can't pass a function to a Client Component. So callback props (`onChange`, `onPressedChange`, `onClear`) have to come from your own `"use client"` file, like the filters example below. Props that are plain data (`CopyButton`'s `text`, `Chip`'s `pressed`) work from a Server Component. `Pagination` takes a function (`hrefFor`), but it is a Server Component itself, so that is fine anywhere.
|
|
154
159
|
|
|
155
160
|
## Props, classes and refs
|
|
156
161
|
|
|
157
|
-
- Every component takes its element's native props and passes them through (`id`, `aria-*`, `data-*`, event handlers). The tables
|
|
158
|
-
- `ref` is a normal prop (React 19).
|
|
159
|
-
- `className` is added after the built-in classes and wins on conflict: a class that sets the same property as a built-in one replaces it (merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge)). `<Select className="w-auto">` drops the built-in `w-full`.
|
|
162
|
+
- Every component takes its element's native props and passes them through (`id`, `aria-*`, `data-*`, event handlers). Each section below names that element. The tables list only the extra props.
|
|
163
|
+
- `ref` is a normal prop (React 19). It goes where the native props go: the outer element for most components, the control (`<input>`, `<select>`, `<textarea>`) for the form fields, and the `<button>` for `CopyButton`. On `PageShell` that is the wrapper `<div>`, not `<main>`.
|
|
164
|
+
- `className` is added after the built-in classes and wins on conflict: a class that sets the same property as a built-in one replaces it (merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge)). `<Select className="w-auto">` drops the built-in `w-full`. On `DiscordIcon`, `GitHubIcon` and `HaruhimeWordmark`, `className` replaces the default size instead.
|
|
160
165
|
|
|
161
166
|
## Components
|
|
162
167
|
|
|
@@ -176,8 +181,8 @@ A pill button. Every native `<button>` prop.
|
|
|
176
181
|
|
|
177
182
|
A link that looks like `Button`. Every `next/link` prop (`href`, `prefetch`, `replace`, `scroll`, `target`, `rel`...), plus `variant` and `size` as on `Button`.
|
|
178
183
|
|
|
179
|
-
-
|
|
180
|
-
-
|
|
184
|
+
- A string `href` with a scheme (`https:`, `mailto:`) or starting with `//` renders a plain `<a>`, and `next/link`'s own props are dropped.
|
|
185
|
+
- That plain `<a>` with `target="_blank"` and no `rel` gets `rel="noreferrer"`. A `rel` you pass always wins. Internal links get only the `rel` you pass.
|
|
181
186
|
|
|
182
187
|
#### `buttonClasses`
|
|
183
188
|
|
|
@@ -193,7 +198,8 @@ The osu!-web panel: rounded, `b4` background, `p-5`. Every native `<section>` pr
|
|
|
193
198
|
|
|
194
199
|
| Prop | Type | Default | What it does |
|
|
195
200
|
| --- | --- | --- | --- |
|
|
196
|
-
| `title` | `ReactNode` | none | Rendered as
|
|
201
|
+
| `title` | `ReactNode` | none | Rendered as a heading at the top (`<h2>` by default). It also names the section (`aria-labelledby`), which makes the card a region landmark. |
|
|
202
|
+
| `headingLevel` | `2 \| 3 \| 4` | `2` | The title's heading level. Use `3` or `4` for a card that sits under another heading, such as a card inside a titled card. Since 0.2.0. |
|
|
197
203
|
|
|
198
204
|
#### `PageHeader`
|
|
199
205
|
|
|
@@ -228,6 +234,23 @@ An error notice (`role="alert"`) is announced either way.
|
|
|
228
234
|
|
|
229
235
|
Long-form typography for MDX, docs and legal pages. A `max-w-3xl` `<div>` that styles the `h2`, `h3`, `p`, `a`, `strong`, `ul`, `ol`, `li`, `code`, `pre`, `hr` and `table` elements inside it. Every native `<div>` prop.
|
|
230
236
|
|
|
237
|
+
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:
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
<Prose className="[&>:first-child>:first-child]:mt-0">
|
|
241
|
+
<section>
|
|
242
|
+
<h2>What we store</h2>
|
|
243
|
+
<p>Your osu! id and your packs.</p>
|
|
244
|
+
</section>
|
|
245
|
+
<section>
|
|
246
|
+
<h2>How long we keep it</h2>
|
|
247
|
+
<p>Until you delete your account.</p>
|
|
248
|
+
</section>
|
|
249
|
+
</Prose>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Later sections keep their heading margin, which spaces them apart.
|
|
253
|
+
|
|
231
254
|
### Forms
|
|
232
255
|
|
|
233
256
|
The fields render a label, the control, an optional hint and an optional error, wired together for screen readers. They are Server Components: you pass the `id`, so they need no generated ids.
|
|
@@ -313,9 +336,19 @@ schema.org structured data in a `<script type="application/ld+json">`. Every `<`
|
|
|
313
336
|
|
|
314
337
|
### Icons
|
|
315
338
|
|
|
339
|
+
#### `DiscordIcon`
|
|
340
|
+
|
|
341
|
+
Since 0.3.0. The Discord logo as an inline SVG in the current text color. Hidden from screen readers by default (`aria-hidden="true"`), so put a label on the link around it. Every native `<svg>` prop except `children` and `viewBox`.
|
|
342
|
+
|
|
343
|
+
The path is Simple Icons' `discord.svg` at tag 16.32.0 ([simple-icons/simple-icons](https://github.com/simple-icons/simple-icons), CC0 1.0). Discord is a trademark of Discord Inc. Its [brand guidelines](https://discord.com/branding) ask for the logo in color, black or white, and not recolored. The icon takes the current text color, so give the element around it one of those (`SiteFooter` uses white).
|
|
344
|
+
|
|
345
|
+
| Prop | Type | Default | What it does |
|
|
346
|
+
| --- | --- | --- | --- |
|
|
347
|
+
| `className` | `string` | `"size-5"` | Replaces the default size. |
|
|
348
|
+
|
|
316
349
|
#### `GitHubIcon`
|
|
317
350
|
|
|
318
|
-
The GitHub mark as an inline SVG in the current text color.
|
|
351
|
+
The GitHub mark as an inline SVG in the current text color. Hidden from screen readers by default (`aria-hidden="true"`), so put a label on the link around it. Every native `<svg>` prop except `children` and `viewBox`.
|
|
319
352
|
|
|
320
353
|
| Prop | Type | Default | What it does |
|
|
321
354
|
| --- | --- | --- | --- |
|
|
@@ -323,7 +356,7 @@ The GitHub mark as an inline SVG in the current text color. Always hidden from s
|
|
|
323
356
|
|
|
324
357
|
#### `HaruhimeWordmark`
|
|
325
358
|
|
|
326
|
-
The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and pink whatever `--hue` is. Every native `<svg>` prop
|
|
359
|
+
The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and pink whatever `--hue` is. Every native `<svg>` prop except `children` and `viewBox`.
|
|
327
360
|
|
|
328
361
|
| Prop | Type | Default | What it does |
|
|
329
362
|
| --- | --- | --- | --- |
|
|
@@ -476,7 +509,7 @@ Links in the header and footer are data (type `SiteLinkItem`):
|
|
|
476
509
|
type SiteLinkItem = { label: string; href?: string; note?: string };
|
|
477
510
|
```
|
|
478
511
|
|
|
479
|
-
Paths use `next/link`; anything with a scheme (`https:`, `mailto:`) or starting with `//` is a plain `<a>`. An item without `href` shows as
|
|
512
|
+
Paths use `next/link`; anything with a scheme (`https:`, `mailto:`) or starting with `//` is a plain `<a>`. An item without `href` shows as plain text. `note` adds a word beside it in small uppercase letters (`{ label: "Pools", note: "soon" }`). The header dims text-only items and shows the note only on them. The footer shows a note beside a link too.
|
|
480
513
|
|
|
481
514
|
#### `SiteHeader`
|
|
482
515
|
|
|
@@ -492,9 +525,15 @@ The dark top bar: brand on the left, the nav, and an actions slot on the right.
|
|
|
492
525
|
|
|
493
526
|
The link for the current page gets `aria-current="page"` and lights up. A section link gets `aria-current="true"` on pages under it (`/packs` while on `/packs/123`). `/` only matches itself.
|
|
494
527
|
|
|
495
|
-
|
|
528
|
+
Only a path inside the app can be the current page. External URLs, relative hrefs (`#main`, `?page=2`) and text-only entries never are. Since 0.2.0, when no link can be, the nav skips the client list. With only external and text-only entries, it renders on the server alone and nothing in it hydrates. A relative href still renders `next/link`, which hydrates. When a link can be the current page, a small client list marks it. In 0.1.0 the whole nav is a client component, so it always hydrates.
|
|
529
|
+
|
|
530
|
+
Rendered from a Server Component, `SiteHeader` and `NavLinks` merge the nav's classes on the server, so tailwind-merge stays out of the browser (since 0.2.0; in 0.1.0 the nav always brings tailwind-merge to the browser). Rendered inside a Client Component, they merge them in the browser and bring tailwind-merge with them.
|
|
531
|
+
|
|
532
|
+
Next bundles every client component a route imports, rendered or not. So a page with `SiteHeader` still downloads the client list's small chunk (mostly `next/link`), even when the nav rendered on the server alone.
|
|
533
|
+
|
|
534
|
+
#### `NavLinks`
|
|
496
535
|
|
|
497
|
-
The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop. Type: `SiteNavAlign`.
|
|
536
|
+
The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop. Type: `SiteNavAlign`. It works in Server and Client Components. Since 0.2.0 it is a Server Component with a small client part (in 0.1.0 it is a client component). From a Server Component it behaves like `SiteHeader`'s nav. Inside a Client Component (a header with a menu toggle, say), it renders in the browser with the rest of that component: it merges its classes there, so tailwind-merge ships in that page's bundle.
|
|
498
537
|
|
|
499
538
|
| Prop | Type | Default | What it does |
|
|
500
539
|
| --- | --- | --- | --- |
|
|
@@ -503,17 +542,18 @@ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it insi
|
|
|
503
542
|
|
|
504
543
|
#### `SiteFooter`
|
|
505
544
|
|
|
506
|
-
Link columns, an extra slot, fine print, the haruhime.moe wordmark
|
|
545
|
+
Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub icon link and an optional Discord icon link. Every native `<footer>` prop. Type: `SiteFooterColumn` (`{ title: string; items: readonly SiteLinkItem[] }`).
|
|
507
546
|
|
|
508
547
|
| Prop | Type | Default | What it does |
|
|
509
548
|
| --- | --- | --- | --- |
|
|
510
549
|
| `columns` | `readonly SiteFooterColumn[]` | `[]` | Each column is a `<nav>` named by its title, which shows above the list. Up to four columns side by side from `sm` up. |
|
|
511
550
|
| `extra` | `ReactNode` | none | Shown above the fine print, e.g. a "clear local data" button. |
|
|
512
551
|
| `finePrint` | `ReactNode` | none | One line of small print, in a `<p>`. |
|
|
513
|
-
| `parentLink` | `boolean` | `true` | Show the haruhime.moe wordmark linking the parent site. With it, the last row holds the wordmark and the
|
|
552
|
+
| `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. |
|
|
514
553
|
| `parentHref` | `string` | `"https://www.haruhime.moe"` | Where the wordmark links. |
|
|
515
554
|
| `githubHref` | `string \| false` | `"https://github.com/haruhimemoe"` | Where the GitHub icon links. `false` leaves it out. |
|
|
516
555
|
| `githubLabel` | `string` | `"haruhimemoe on GitHub"` | The GitHub link's accessible name. |
|
|
556
|
+
| `discordHref` | `string` | none | Where the Discord icon links, such as your server's invite (`https://discord.gg/...`). Without it there is no Discord icon. The icon sits before the GitHub icon at the same size. It stays white (`text-c1`) and dims on hover instead of changing color, since Discord's brand guidelines ask that the logo not be recolored. The link's accessible name is "Discord". Since 0.3.0. |
|
|
517
557
|
|
|
518
558
|
#### `PageShell`
|
|
519
559
|
|
|
@@ -530,7 +570,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
|
|
|
530
570
|
|
|
531
571
|
## Accessibility
|
|
532
572
|
|
|
533
|
-
- Every component is checked with axe
|
|
573
|
+
- Every component is checked in the test suite with axe-core's WCAG 2.0, 2.1 and 2.2 A and AA rules (all but color contrast, which needs a real browser). Interactive ones also have keyboard tests. The tests also calculate the contrast figures under Setup (`c1` on `h2`, `h1` on `b4`, and the `--h1-l` and `--h2-l` values).
|
|
534
574
|
- Focus is always visible: the theme draws an `h1` outline on `:focus-visible`. Fields show focus with an `h1` border instead (plus an `h1` ring when invalid), and `RangeSlider` thumbs with a solid `h1` ring. Those keep a transparent outline, so Windows high contrast mode (forced colors) still shows focus.
|
|
535
575
|
- In forced colors mode, a pressed `Chip` takes the system highlight colors, so on and off still look different.
|
|
536
576
|
- Form fields link their label, hint and error. An error sets `aria-invalid` and is announced.
|
|
@@ -539,7 +579,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
|
|
|
539
579
|
- `CopyButton` announces "Copied." (or the failure) through an `<output>`, on every press.
|
|
540
580
|
- `Pagination` moves focus to its "Page X of Y" text when the link you pressed goes away on the first or last page.
|
|
541
581
|
- `SiteHeader` marks the current page with `aria-current`. `PageShell` starts with a skip link to `<main>`.
|
|
542
|
-
- `GitHubIcon`
|
|
582
|
+
- `DiscordIcon` and `GitHubIcon` are hidden from screen readers by default: give the link around each one an `aria-label`, as `SiteFooter` does.
|
|
543
583
|
- You supply the text, so you also supply labels: give icon-only buttons an `aria-label`, and keep `label` props meaningful.
|
|
544
584
|
|
|
545
585
|
## Compatibility
|
|
@@ -549,6 +589,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
|
|
|
549
589
|
| Next.js | 16 (app router). Components use `next/link` and `next/navigation`. |
|
|
550
590
|
| React | 19 |
|
|
551
591
|
| Tailwind CSS | 4.1 or later (4.x), through `@tailwindcss/postcss` |
|
|
592
|
+
| Node.js | 22.12 or later (`engines`) |
|
|
552
593
|
| Module format | ESM only. Plain Node and Vitest can import it (for component tests in your app). |
|
|
553
594
|
| Theme | Dark only |
|
|
554
595
|
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/basics/Card.tsx
|
|
3
|
-
* @desc osu!-web panel: rounded b4 surface, optional h2
|
|
4
|
-
* (useId works in Server Components).
|
|
3
|
+
* @desc osu!-web panel: rounded b4 surface, optional title (an h2 by default) that labels the
|
|
4
|
+
* region. Server-safe (useId works in Server Components).
|
|
5
5
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
6
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
7
|
+
* @modified Thu Sep 24, 2026
|
|
8
8
|
*/
|
|
9
9
|
import { type ComponentProps, type ReactNode } from "react";
|
|
10
|
-
/** Every native `<section>` prop (including `ref`), with `title` rendered as the card's
|
|
10
|
+
/** Every native `<section>` prop (including `ref`), with `title` rendered as the card's heading. */
|
|
11
11
|
export type CardProps = Omit<ComponentProps<"section">, "title"> & {
|
|
12
12
|
title?: ReactNode | undefined;
|
|
13
|
+
/** Heading level for the title (default 2). Use 3 or 4 for a card under another heading. */
|
|
14
|
+
headingLevel?: 2 | 3 | 4 | undefined;
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
15
17
|
* @function Card
|
|
16
|
-
* @param props {CardProps} an optional title that labels the region,
|
|
18
|
+
* @param props {CardProps} an optional title that labels the region, its heading level (default
|
|
19
|
+
* 2), plus native section props
|
|
17
20
|
* @returns {JSX.Element} a rounded panel, labelled by its title when one is given
|
|
18
21
|
*/
|
|
19
|
-
export declare function Card({ title, className, children, ...props }: CardProps): import("react").JSX.Element;
|
|
22
|
+
export declare function Card({ title, headingLevel, className, children, ...props }: CardProps): import("react").JSX.Element;
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* @file src/components/basics/Card.tsx
|
|
4
|
-
* @desc osu!-web panel: rounded b4 surface, optional h2
|
|
5
|
-
* (useId works in Server Components).
|
|
4
|
+
* @desc osu!-web panel: rounded b4 surface, optional title (an h2 by default) that labels the
|
|
5
|
+
* region. Server-safe (useId works in Server Components).
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
9
9
|
*/
|
|
10
10
|
import { useId } from "react";
|
|
11
11
|
import { cx } from "../../utils/cx.js";
|
|
12
12
|
/**
|
|
13
13
|
* @function Card
|
|
14
|
-
* @param props {CardProps} an optional title that labels the region,
|
|
14
|
+
* @param props {CardProps} an optional title that labels the region, its heading level (default
|
|
15
|
+
* 2), plus native section props
|
|
15
16
|
* @returns {JSX.Element} a rounded panel, labelled by its title when one is given
|
|
16
17
|
*/
|
|
17
|
-
export function Card({ title, className, children, ...props }) {
|
|
18
|
+
export function Card({ title, headingLevel = 2, className, children, ...props }) {
|
|
18
19
|
const headingId = useId();
|
|
19
|
-
|
|
20
|
+
const Heading = `h${headingLevel}`;
|
|
21
|
+
return (_jsxs("section", { "aria-labelledby": title ? headingId : undefined, className: cx("rounded-[10px] bg-b4 p-5 text-c2", className), ...props, children: [title ? (_jsx(Heading, { id: headingId, className: "mb-2 font-bold text-c1 text-lg", children: title })) : null, children] }));
|
|
20
22
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/basics/Prose.tsx
|
|
3
3
|
* @desc Long-form typography for MDX, docs and legal text, styled with the osu!-web tokens.
|
|
4
|
-
* Styles the plain elements inside it (headings, links, lists, code, tables).
|
|
4
|
+
* Styles the plain elements inside it (headings, links, lists, code, tables). The element
|
|
5
|
+
* that opens the block gets no top margin, so a leading heading sits flush.
|
|
5
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
7
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
8
9
|
*/
|
|
9
10
|
import type { ComponentProps } from "react";
|
|
10
11
|
/** Every native `<div>` prop (including `ref`). */
|
|
@@ -13,6 +14,7 @@ export type ProseProps = ComponentProps<"div">;
|
|
|
13
14
|
* @function Prose
|
|
14
15
|
* @param props {ProseProps} native div props; children are the rendered Markdown or MDX
|
|
15
16
|
* @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
|
|
16
|
-
* tables inside it
|
|
17
|
+
* tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
|
|
18
|
+
* which outranks the h2 and h3 margins by specificity.
|
|
17
19
|
*/
|
|
18
20
|
export declare function Prose({ className, ...props }: ProseProps): import("react").JSX.Element;
|
|
@@ -4,8 +4,9 @@ import { cx } from "../../utils/cx.js";
|
|
|
4
4
|
* @function Prose
|
|
5
5
|
* @param props {ProseProps} native div props; children are the rendered Markdown or MDX
|
|
6
6
|
* @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
|
|
7
|
-
* tables inside it
|
|
7
|
+
* tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
|
|
8
|
+
* which outranks the h2 and h3 margins by specificity.
|
|
8
9
|
*/
|
|
9
10
|
export function Prose({ className, ...props }) {
|
|
10
|
-
return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre]:mt-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-b6 [&_pre]:p-3 [&_pre]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
|
|
11
|
+
return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&>:first-child]:mt-0 [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre]:mt-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-b6 [&_pre]:p-3 [&_pre]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
|
|
11
12
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/icons/DiscordIcon.tsx
|
|
3
|
+
* @desc The Discord logo as a static inline SVG (no icon library, no external request), in the
|
|
4
|
+
* current text color. Always decorative: label the link around it. The path is Simple
|
|
5
|
+
* Icons' discord.svg at tag 16.32.0 (https://github.com/simple-icons/simple-icons, CC0
|
|
6
|
+
* 1.0). Discord is a trademark of Discord Inc. Its brand guidelines ask for the logo in
|
|
7
|
+
* color, black or white and not recolored, so set one of those as the text color.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Fri Sep 25, 2026
|
|
10
|
+
* @modified Fri Sep 25, 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { ComponentProps } from "react";
|
|
13
|
+
/** Every native `<svg>` prop except children and viewBox. */
|
|
14
|
+
export type DiscordIconProps = Omit<ComponentProps<"svg">, "children" | "viewBox">;
|
|
15
|
+
/**
|
|
16
|
+
* @function DiscordIcon
|
|
17
|
+
* @param props {DiscordIconProps} native svg props; `className` replaces the default size
|
|
18
|
+
* ("size-5")
|
|
19
|
+
* @returns {JSX.Element} the Discord logo in the current text color, hidden from assistive tech
|
|
20
|
+
*/
|
|
21
|
+
export declare function DiscordIcon({ className, ...props }: DiscordIconProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @function DiscordIcon
|
|
4
|
+
* @param props {DiscordIconProps} native svg props; `className` replaces the default size
|
|
5
|
+
* ("size-5")
|
|
6
|
+
* @returns {JSX.Element} the Discord logo in the current text color, hidden from assistive tech
|
|
7
|
+
*/
|
|
8
|
+
export function DiscordIcon({ className = "size-5", ...props }) {
|
|
9
|
+
return (_jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", className: className, fill: "currentColor", ...props, children: _jsx("path", { d: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" }) }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/NavItem.tsx
|
|
3
|
+
* @desc One entry of the header's nav list (internal): a link, or dimmed text with its note when
|
|
4
|
+
* the entry has no href. NavLinks renders it on the server, and NavListClient in the
|
|
5
|
+
* browser, so both lists print the same markup.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Thu Sep 24, 2026
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { SiteLinkItem } from "./links.js";
|
|
11
|
+
/** One nav entry, its aria-current and the finished classes for its link. */
|
|
12
|
+
export type NavItemProps = {
|
|
13
|
+
item: SiteLinkItem;
|
|
14
|
+
/** The link's aria-current, from `ariaCurrentFor`. */
|
|
15
|
+
current?: "page" | "true" | undefined;
|
|
16
|
+
/** Classes for the link. Ignored for an entry without `href`. */
|
|
17
|
+
linkClassName: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* @function navItemKey
|
|
21
|
+
* @param item {SiteLinkItem} a nav entry
|
|
22
|
+
* @returns {string} its React key: the href, or the label for a text-only entry
|
|
23
|
+
*/
|
|
24
|
+
export declare const navItemKey: (item: SiteLinkItem) => string;
|
|
25
|
+
/**
|
|
26
|
+
* @function NavItem
|
|
27
|
+
* @param props {NavItemProps} the entry, its aria-current and its link classes
|
|
28
|
+
* @returns {JSX.Element} an `<li>` with the link, or with dimmed text and the note beside it
|
|
29
|
+
*/
|
|
30
|
+
export declare function NavItem({ item, current, linkClassName }: NavItemProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/shell/NavItem.tsx
|
|
4
|
+
* @desc One entry of the header's nav list (internal): a link, or dimmed text with its note when
|
|
5
|
+
* the entry has no href. NavLinks renders it on the server, and NavListClient in the
|
|
6
|
+
* browser, so both lists print the same markup.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Thu Sep 24, 2026
|
|
9
|
+
* @modified Thu Sep 24, 2026
|
|
10
|
+
*/
|
|
11
|
+
import { AutoLink } from "./AutoLink.js";
|
|
12
|
+
/**
|
|
13
|
+
* @function navItemKey
|
|
14
|
+
* @param item {SiteLinkItem} a nav entry
|
|
15
|
+
* @returns {string} its React key: the href, or the label for a text-only entry
|
|
16
|
+
*/
|
|
17
|
+
export const navItemKey = (item) => item.href || item.label;
|
|
18
|
+
/**
|
|
19
|
+
* @function NavItem
|
|
20
|
+
* @param props {NavItemProps} the entry, its aria-current and its link classes
|
|
21
|
+
* @returns {JSX.Element} an `<li>` with the link, or with dimmed text and the note beside it
|
|
22
|
+
*/
|
|
23
|
+
export function NavItem({ item, current, linkClassName }) {
|
|
24
|
+
if (!item.href) {
|
|
25
|
+
return (_jsx("li", { children: _jsxs("span", { "aria-disabled": "true", className: "text-c4", children: [item.label, item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-xs uppercase tracking-wide", children: item.note })] })) : null] }) }));
|
|
26
|
+
}
|
|
27
|
+
return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: linkClassName, children: item.label }) }));
|
|
28
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/shell/NavLinks.tsx
|
|
3
|
-
* @desc The header's nav list.
|
|
4
|
-
*
|
|
3
|
+
* @desc The header's nav list. Server-safe: it merges the classes here, then renders the list
|
|
4
|
+
* itself when no link can be the current page (external, relative or text-only), or hands
|
|
5
|
+
* it to the small NavListClient, which reads the path to set aria-current.
|
|
5
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
7
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
8
9
|
*/
|
|
9
10
|
import type { ComponentProps } from "react";
|
|
10
11
|
import { type SiteLinkItem } from "./links.js";
|
|
@@ -19,6 +20,9 @@ export type NavLinksProps = Omit<ComponentProps<"ul">, "children"> & {
|
|
|
19
20
|
* @function NavLinks
|
|
20
21
|
* @param props {NavLinksProps} the links (items without `href` show as dimmed text with their
|
|
21
22
|
* `note`), the alignment (default "start") and native list props
|
|
22
|
-
* @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
|
|
23
|
+
* @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current.
|
|
24
|
+
* When no link can be current, it skips the client list and nothing reads the path.
|
|
25
|
+
* From a Server Component with only external and text-only links, nothing hydrates; a
|
|
26
|
+
* relative href still renders next/link, which does.
|
|
23
27
|
*/
|
|
24
28
|
export declare function NavLinks({ links, align, className, ...props }: NavLinksProps): import("react").JSX.Element;
|
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
* @file src/components/shell/NavLinks.tsx
|
|
3
|
-
* @desc The header's nav list. A client component only so it can read the current path and set
|
|
4
|
-
* aria-current on the matching link; SiteHeader around it stays a server component.
|
|
5
|
-
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
-
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified Wed Sep 23, 2026
|
|
8
|
-
*/
|
|
9
|
-
"use client";
|
|
10
|
-
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
11
|
-
import { usePathname } from "next/navigation.js";
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
12
2
|
import { cx } from "../../utils/cx.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
3
|
+
import { canBeCurrent } from "./links.js";
|
|
4
|
+
import { NavItem, navItemKey } from "./NavItem.js";
|
|
5
|
+
import { NavListClient } from "./NavListClient.js";
|
|
15
6
|
const LISTS = {
|
|
16
7
|
start: "flex flex-wrap gap-x-5 gap-y-1 font-bold text-sm",
|
|
17
8
|
center: "flex flex-wrap items-center justify-center gap-x-6 gap-y-1 font-bold",
|
|
@@ -26,15 +17,15 @@ const CURRENT = "text-c1 transition-colors";
|
|
|
26
17
|
* @function NavLinks
|
|
27
18
|
* @param props {NavLinksProps} the links (items without `href` show as dimmed text with their
|
|
28
19
|
* `note`), the alignment (default "start") and native list props
|
|
29
|
-
* @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
|
|
20
|
+
* @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current.
|
|
21
|
+
* When no link can be current, it skips the client list and nothing reads the path.
|
|
22
|
+
* From a Server Component with only external and text-only links, nothing hydrates; a
|
|
23
|
+
* relative href still renders next/link, which does.
|
|
30
24
|
*/
|
|
31
25
|
export function NavLinks({ links, align = "start", className, ...props }) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const current = ariaCurrentFor(pathname, item.href);
|
|
38
|
-
return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: current ? CURRENT : LINKS[align], children: item.label }) }, item.href));
|
|
39
|
-
}) }));
|
|
26
|
+
const listClassName = cx(LISTS[align], className);
|
|
27
|
+
if (!links.some((item) => item.href && canBeCurrent(item.href))) {
|
|
28
|
+
return (_jsx("ul", { className: listClassName, ...props, children: links.map((item) => (_jsx(NavItem, { item: item, linkClassName: LINKS[align] }, navItemKey(item)))) }));
|
|
29
|
+
}
|
|
30
|
+
return (_jsx(NavListClient, { links: links, linkClassName: LINKS[align], currentClassName: CURRENT, className: listClassName, ...props }));
|
|
40
31
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/NavListClient.tsx
|
|
3
|
+
* @desc The nav list that marks the current page (internal). The only client part of the header:
|
|
4
|
+
* it reads the pathname to set aria-current. Its classes arrive finished from NavLinks on
|
|
5
|
+
* the server, so it imports no class merging and ships no tailwind-merge to the browser.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Thu Sep 24, 2026
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps } from "react";
|
|
11
|
+
import { type SiteLinkItem } from "./links.js";
|
|
12
|
+
/** Every native `<ul>` prop (including `ref`), plus the links and their finished classes. */
|
|
13
|
+
export type NavListClientProps = Omit<ComponentProps<"ul">, "children"> & {
|
|
14
|
+
links: readonly SiteLinkItem[];
|
|
15
|
+
/** Classes for a link that is not the current page. */
|
|
16
|
+
linkClassName: string;
|
|
17
|
+
/** Classes for the current page's link. */
|
|
18
|
+
currentClassName: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* @function NavListClient
|
|
22
|
+
* @param props {NavListClientProps} the links, the classes for current and other links, and
|
|
23
|
+
* native list props (`className` already merged)
|
|
24
|
+
* @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
|
|
25
|
+
*/
|
|
26
|
+
export declare function NavListClient({ links, linkClassName, currentClassName, ...props }: NavListClientProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/NavListClient.tsx
|
|
3
|
+
* @desc The nav list that marks the current page (internal). The only client part of the header:
|
|
4
|
+
* it reads the pathname to set aria-current. Its classes arrive finished from NavLinks on
|
|
5
|
+
* the server, so it imports no class merging and ships no tailwind-merge to the browser.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Thu Sep 24, 2026
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
9
|
+
*/
|
|
10
|
+
"use client";
|
|
11
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
12
|
+
import { usePathname } from "next/navigation.js";
|
|
13
|
+
import { ariaCurrentFor } from "./links.js";
|
|
14
|
+
import { NavItem, navItemKey } from "./NavItem.js";
|
|
15
|
+
/**
|
|
16
|
+
* @function NavListClient
|
|
17
|
+
* @param props {NavListClientProps} the links, the classes for current and other links, and
|
|
18
|
+
* native list props (`className` already merged)
|
|
19
|
+
* @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
|
|
20
|
+
*/
|
|
21
|
+
export function NavListClient({ links, linkClassName, currentClassName, ...props }) {
|
|
22
|
+
const pathname = usePathname();
|
|
23
|
+
return (_jsx("ul", { ...props, children: links.map((item) => {
|
|
24
|
+
const current = item.href ? ariaCurrentFor(pathname, item.href) : undefined;
|
|
25
|
+
return (_jsx(NavItem, { item: item, current: current, linkClassName: current ? currentClassName : linkClassName }, navItemKey(item)));
|
|
26
|
+
}) }));
|
|
27
|
+
}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* @file src/components/shell/SiteFooter.tsx
|
|
3
3
|
* @desc Site footer: labelled link columns from data (entries without an href show as text with
|
|
4
4
|
* a small note, like "soon"), an optional extra slot, one line of fine print, the
|
|
5
|
-
* haruhime.moe wordmark linking the parent site,
|
|
5
|
+
* haruhime.moe wordmark linking the parent site, a GitHub icon link, and an optional
|
|
6
|
+
* Discord icon link beside it (white, as Discord's brand guidelines ask).
|
|
6
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
8
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
9
|
+
* @modified Fri Sep 25, 2026
|
|
9
10
|
*/
|
|
10
11
|
import type { ComponentProps, ReactNode } from "react";
|
|
11
12
|
import type { SiteLinkItem } from "./links.js";
|
|
@@ -30,11 +31,13 @@ export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
|
|
|
30
31
|
githubHref?: string | false | undefined;
|
|
31
32
|
/** The GitHub link's accessible name. Default "haruhimemoe on GitHub". */
|
|
32
33
|
githubLabel?: string | undefined;
|
|
34
|
+
/** Where the Discord icon links, e.g. a server invite. No Discord icon without it. */
|
|
35
|
+
discordHref?: string | undefined;
|
|
33
36
|
};
|
|
34
37
|
/**
|
|
35
38
|
* @function SiteFooter
|
|
36
|
-
* @param props {SiteFooterProps} columns, extra slot, fine print, parent
|
|
37
|
-
* native footer props
|
|
39
|
+
* @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
|
|
40
|
+
* links, and native footer props
|
|
38
41
|
* @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
|
|
39
42
|
*/
|
|
40
|
-
export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
|
|
43
|
+
export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, className, ...props }: SiteFooterProps): import("react").JSX.Element;
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
+
import { DiscordIcon } from "../icons/DiscordIcon.js";
|
|
3
4
|
import { GitHubIcon } from "../icons/GitHubIcon.js";
|
|
4
5
|
import { HaruhimeWordmarkLink } from "../icons/HaruhimeWordmarkLink.js";
|
|
5
6
|
import { AutoLink } from "./AutoLink.js";
|
|
7
|
+
// The GitHub icon link in the bottom row.
|
|
8
|
+
const ICON_LINK = "shrink-0 text-c3 transition-colors hover:text-c1";
|
|
9
|
+
// Discord's brand guidelines ask for the logo in color, black or white, never recolored. c1 is
|
|
10
|
+
// white at every hue, so the Discord link stays white and dims on hover instead of changing hue.
|
|
11
|
+
const DISCORD_LINK = "shrink-0 text-c1 transition-opacity hover:opacity-80";
|
|
6
12
|
// Static strings so Tailwind sees every class. Four or more columns share the four-column grid.
|
|
7
13
|
const GRID_COLUMNS = ["", "", "sm:grid-cols-2", "sm:grid-cols-3", "sm:grid-cols-4"];
|
|
8
14
|
/**
|
|
9
15
|
* @function SiteFooter
|
|
10
|
-
* @param props {SiteFooterProps} columns, extra slot, fine print, parent
|
|
11
|
-
* native footer props
|
|
16
|
+
* @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
|
|
17
|
+
* links, and native footer props
|
|
12
18
|
* @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
|
|
13
19
|
*/
|
|
14
|
-
export function SiteFooter({ columns = [], extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", className, ...props }) {
|
|
20
|
+
export function SiteFooter({ columns = [], extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", discordHref, className, ...props }) {
|
|
15
21
|
const fine = finePrint ? _jsx("p", { className: "text-c4 text-xs", children: finePrint }) : null;
|
|
16
|
-
const github = githubHref ? (_jsx("a", { href: githubHref, "aria-label": githubLabel, className:
|
|
22
|
+
const github = githubHref ? (_jsx("a", { href: githubHref, "aria-label": githubLabel, className: ICON_LINK, children: _jsx(GitHubIcon, {}) })) : null;
|
|
23
|
+
const discord = discordHref ? (_jsx("a", { href: discordHref, "aria-label": "Discord", className: DISCORD_LINK, children: _jsx(DiscordIcon, {}) })) : null;
|
|
24
|
+
// Both icons sit together at the row's end, Discord first. One icon stays a direct child.
|
|
25
|
+
const icons = discord && github ? (_jsxs("div", { className: "flex shrink-0 items-center gap-4", children: [discord, github] })) : ((discord ?? github));
|
|
17
26
|
// With the wordmark, the fine print gets its own line and the row holds wordmark + icon.
|
|
18
27
|
// Without it, the fine print shares the row with the icon.
|
|
19
28
|
const rowStart = parentLink ? _jsx(HaruhimeWordmarkLink, { href: parentHref }) : fine;
|
|
20
|
-
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("div", { className: cx("grid gap-8", GRID_COLUMNS[Math.min(columns.length, 4)]), children: columns.map((column) => (_jsxs("nav", { "aria-label": column.title, children: [_jsx("p", { 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 })), item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-c4 text-xs uppercase tracking-wide", children: item.note })] })) : null] }, `${item.label} ${item.href ?? ""}`))) })] }, column.title))) })) : null, extra || fine || rowStart ||
|
|
29
|
+
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("div", { className: cx("grid gap-8", GRID_COLUMNS[Math.min(columns.length, 4)]), children: columns.map((column) => (_jsxs("nav", { "aria-label": column.title, children: [_jsx("p", { 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 })), item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-c4 text-xs uppercase tracking-wide", children: item.note })] })) : null] }, `${item.label} ${item.href ?? ""}`))) })] }, 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] }) }));
|
|
21
30
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/shell/SiteHeader.tsx
|
|
3
3
|
* @desc Site header, the osu!-web dark bar: a brand slot on the left, nav links from data, and an
|
|
4
|
-
* actions slot on the right (an account menu, say). A server component
|
|
5
|
-
*
|
|
4
|
+
* actions slot on the right (an account menu, say). A server component. The nav list is a
|
|
5
|
+
* small client component only when a link can be the current page; with only external or
|
|
6
|
+
* text-only links the whole header renders on the server.
|
|
6
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
8
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
9
|
+
* @modified Thu Sep 24, 2026
|
|
9
10
|
*/
|
|
10
11
|
import type { ComponentProps, ReactNode } from "react";
|
|
11
12
|
import type { SiteLinkItem } from "./links.js";
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/shell/links.ts
|
|
3
|
-
* @desc Link data shared by SiteHeader and SiteFooter, plus the
|
|
4
|
-
* hrefs leave the app (plain `<a>`)
|
|
3
|
+
* @desc Link data shared by SiteHeader and SiteFooter, plus the rules they apply to it: which
|
|
4
|
+
* hrefs leave the app (plain `<a>`), which link marks the current page, and which hrefs can
|
|
5
|
+
* ever be the current page.
|
|
5
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
7
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
8
9
|
*/
|
|
9
10
|
import { isExternalHref } from "../../utils/href.js";
|
|
10
11
|
export { isExternalHref };
|
|
@@ -14,6 +15,14 @@ export type SiteLinkItem = {
|
|
|
14
15
|
href?: string | undefined;
|
|
15
16
|
note?: string | undefined;
|
|
16
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* @function canBeCurrent
|
|
20
|
+
* @param href {string} a nav link target
|
|
21
|
+
* @returns {boolean} true when some pathname makes `ariaCurrentFor` mark the link, which is only
|
|
22
|
+
* for a path in the app ("/packs"). False for external, relative, fragment-only and
|
|
23
|
+
* query-only hrefs.
|
|
24
|
+
*/
|
|
25
|
+
export declare const canBeCurrent: (href: string) => boolean;
|
|
17
26
|
/**
|
|
18
27
|
* @function ariaCurrentFor
|
|
19
28
|
* @param pathname {string | null} the current pathname (`usePathname()`), null outside the router
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/shell/links.ts
|
|
3
|
-
* @desc Link data shared by SiteHeader and SiteFooter, plus the
|
|
4
|
-
* hrefs leave the app (plain `<a>`)
|
|
3
|
+
* @desc Link data shared by SiteHeader and SiteFooter, plus the rules they apply to it: which
|
|
4
|
+
* hrefs leave the app (plain `<a>`), which link marks the current page, and which hrefs can
|
|
5
|
+
* ever be the current page.
|
|
5
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
7
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
8
|
+
* @modified Thu Sep 24, 2026
|
|
8
9
|
*/
|
|
9
10
|
import { isExternalHref } from "../../utils/href.js";
|
|
10
11
|
export { isExternalHref };
|
|
11
12
|
const trimSlash = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
13
|
+
// The app path an href points at, without query, fragment or trailing slash. Undefined for an
|
|
14
|
+
// href that leaves the app or is relative ("packs", "#main", "?page=2").
|
|
15
|
+
const appPath = (href) => {
|
|
16
|
+
if (isExternalHref(href))
|
|
17
|
+
return undefined;
|
|
18
|
+
const target = trimSlash(href.replace(/[?#].*$/s, ""));
|
|
19
|
+
return target.startsWith("/") ? target : undefined;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* @function canBeCurrent
|
|
23
|
+
* @param href {string} a nav link target
|
|
24
|
+
* @returns {boolean} true when some pathname makes `ariaCurrentFor` mark the link, which is only
|
|
25
|
+
* for a path in the app ("/packs"). False for external, relative, fragment-only and
|
|
26
|
+
* query-only hrefs.
|
|
27
|
+
*/
|
|
28
|
+
export const canBeCurrent = (href) => appPath(href) !== undefined;
|
|
12
29
|
/**
|
|
13
30
|
* @function ariaCurrentFor
|
|
14
31
|
* @param pathname {string | null} the current pathname (`usePathname()`), null outside the router
|
|
@@ -18,10 +35,8 @@ const trimSlash = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0
|
|
|
18
35
|
* External and relative hrefs never match, and "/" only matches itself.
|
|
19
36
|
*/
|
|
20
37
|
export function ariaCurrentFor(pathname, href) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const target = trimSlash(href.replace(/[?#].*$/s, ""));
|
|
24
|
-
if (!target.startsWith("/"))
|
|
38
|
+
const target = appPath(href);
|
|
39
|
+
if (!pathname || target === undefined)
|
|
25
40
|
return undefined;
|
|
26
41
|
const here = trimSlash(pathname);
|
|
27
42
|
if (here === target)
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* their own "use client" directive, so this barrel is safe to import from Server Components.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Fri Sep 25, 2026
|
|
9
9
|
*/
|
|
10
10
|
export { CopyButton, type CopyButtonProps } from "./components/actions/CopyButton.js";
|
|
11
11
|
export { JsonLd, type JsonLdProps } from "./components/actions/JsonLd.js";
|
|
@@ -28,6 +28,7 @@ export { fieldClasses } from "./components/forms/fieldStyles.js";
|
|
|
28
28
|
export { Select, type SelectProps } from "./components/forms/Select.js";
|
|
29
29
|
export { Textarea, type TextareaProps } from "./components/forms/Textarea.js";
|
|
30
30
|
export { TextInput, type TextInputProps } from "./components/forms/TextInput.js";
|
|
31
|
+
export { DiscordIcon, type DiscordIconProps } from "./components/icons/DiscordIcon.js";
|
|
31
32
|
export { GitHubIcon, type GitHubIconProps } from "./components/icons/GitHubIcon.js";
|
|
32
33
|
export { HaruhimeWordmark, type HaruhimeWordmarkProps, } from "./components/icons/HaruhimeWordmark.js";
|
|
33
34
|
export { HaruhimeWordmarkLink, type HaruhimeWordmarkLinkProps, } from "./components/icons/HaruhimeWordmarkLink.js";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* their own "use client" directive, so this barrel is safe to import from Server Components.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Fri Sep 25, 2026
|
|
9
9
|
*/
|
|
10
10
|
// Actions
|
|
11
11
|
export { CopyButton } from "./components/actions/CopyButton.js";
|
|
@@ -32,6 +32,7 @@ export { Select } from "./components/forms/Select.js";
|
|
|
32
32
|
export { Textarea } from "./components/forms/Textarea.js";
|
|
33
33
|
export { TextInput } from "./components/forms/TextInput.js";
|
|
34
34
|
// Icons
|
|
35
|
+
export { DiscordIcon } from "./components/icons/DiscordIcon.js";
|
|
35
36
|
export { GitHubIcon } from "./components/icons/GitHubIcon.js";
|
|
36
37
|
export { HaruhimeWordmark, } from "./components/icons/HaruhimeWordmark.js";
|
|
37
38
|
export { HaruhimeWordmarkLink, } from "./components/icons/HaruhimeWordmarkLink.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haruhimemoe/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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, filter controls and the site header and footer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"osu",
|