@haruhimemoe/ui 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -1
- package/README.md +265 -29
- package/dist/components/actions/AsyncButton.d.ts +30 -0
- package/dist/components/actions/AsyncButton.js +48 -0
- package/dist/components/actions/CopyButton.d.ts +3 -3
- package/dist/components/actions/CopyButton.js +9 -12
- package/dist/components/actions/InlineConfirm.d.ts +45 -0
- package/dist/components/actions/InlineConfirm.js +80 -0
- package/dist/components/actions/Pagination.d.ts +40 -17
- package/dist/components/actions/Pagination.js +25 -18
- package/dist/components/actions/PaginationButton.d.ts +25 -0
- package/dist/components/actions/PaginationButton.js +23 -0
- package/dist/components/actions/PaginationStatus.d.ts +7 -4
- package/dist/components/actions/PaginationStatus.js +7 -5
- package/dist/components/actions/StatusOutput.d.ts +20 -0
- package/dist/components/actions/StatusOutput.js +9 -0
- package/dist/components/actions/pages.d.ts +21 -0
- package/dist/components/actions/pages.js +21 -0
- package/dist/components/actions/useLatestStatus.d.ts +26 -0
- package/dist/components/actions/useLatestStatus.js +32 -0
- package/dist/components/basics/AutoLink.d.ts +21 -0
- package/dist/components/basics/AutoLink.js +26 -0
- package/dist/components/basics/Badge.d.ts +22 -0
- package/dist/components/basics/Badge.js +18 -0
- package/dist/components/basics/ButtonLink.d.ts +5 -6
- package/dist/components/basics/ButtonLink.js +5 -11
- package/dist/components/basics/Card.d.ts +4 -3
- package/dist/components/basics/Card.js +3 -2
- package/dist/components/basics/Disclosure.d.ts +34 -0
- package/dist/components/basics/Disclosure.js +30 -0
- package/dist/components/basics/Notice.d.ts +2 -1
- package/dist/components/basics/TextLink.d.ts +21 -0
- package/dist/components/basics/TextLink.js +19 -0
- package/dist/components/basics/buttonStyles.d.ts +3 -1
- package/dist/components/basics/buttonStyles.js +1 -1
- package/dist/components/basics/cardStyles.d.ts +14 -0
- package/dist/components/basics/cardStyles.js +12 -0
- package/dist/components/basics/linkStyles.d.ts +23 -0
- package/dist/components/basics/linkStyles.js +23 -0
- package/dist/components/filters/Chip.d.ts +13 -5
- package/dist/components/filters/Chip.js +20 -11
- package/dist/components/filters/ChipGroup.d.ts +11 -12
- package/dist/components/filters/ChipGroup.js +5 -10
- package/dist/components/filters/ChoiceChips.d.ts +38 -0
- package/dist/components/filters/ChoiceChips.js +31 -0
- package/dist/components/filters/FilterPanel.d.ts +5 -4
- package/dist/components/filters/FilterPanel.js +5 -4
- package/dist/components/filters/FilterRow.d.ts +1 -1
- package/dist/components/filters/FilterRow.js +3 -2
- package/dist/components/filters/GroupFrame.d.ts +27 -0
- package/dist/components/filters/GroupFrame.js +27 -0
- package/dist/components/filters/RangeBox.d.ts +28 -0
- package/dist/components/filters/RangeBox.js +41 -0
- package/dist/components/filters/RangeSlider.d.ts +13 -14
- package/dist/components/filters/RangeSlider.js +40 -103
- package/dist/components/filters/chipStyles.d.ts +17 -0
- package/dist/components/filters/chipStyles.js +17 -0
- package/dist/components/filters/rangeMath.d.ts +106 -0
- package/dist/components/filters/rangeMath.js +146 -0
- package/dist/components/forms/Checkbox.d.ts +5 -4
- package/dist/components/forms/Checkbox.js +3 -3
- package/dist/components/forms/FieldFrame.d.ts +28 -2
- package/dist/components/forms/FieldFrame.js +14 -1
- package/dist/components/forms/RadioGroup.d.ts +46 -0
- package/dist/components/forms/RadioGroup.js +32 -0
- package/dist/components/forms/Select.d.ts +1 -1
- package/dist/components/forms/Select.js +2 -2
- package/dist/components/forms/TextInput.d.ts +1 -1
- package/dist/components/forms/TextInput.js +2 -2
- package/dist/components/forms/Textarea.d.ts +1 -1
- package/dist/components/forms/Textarea.js +2 -2
- package/dist/components/forms/TypeToConfirm.d.ts +40 -0
- package/dist/components/forms/TypeToConfirm.js +46 -0
- package/dist/components/forms/fieldStyles.d.ts +5 -2
- package/dist/components/forms/fieldStyles.js +5 -2
- package/dist/components/icons/DiscordIcon.d.ts +21 -0
- package/dist/components/icons/DiscordIcon.js +10 -0
- package/dist/components/{actions → meta}/JsonLd.d.ts +2 -2
- package/dist/components/osu/BeatmapStats.d.ts +36 -0
- package/dist/components/osu/BeatmapStats.js +44 -0
- package/dist/components/osu/ModBadge.d.ts +23 -0
- package/dist/components/osu/ModBadge.js +22 -0
- package/dist/components/osu/StarRating.d.ts +26 -0
- package/dist/components/osu/StarRating.js +17 -0
- package/dist/components/osu/starColors.d.ts +23 -0
- package/dist/components/osu/starColors.js +48 -0
- package/dist/components/shell/HeaderMenu.d.ts +39 -0
- package/dist/components/shell/HeaderMenu.js +58 -0
- package/dist/components/shell/LinkNote.d.ts +16 -0
- package/dist/components/shell/LinkNote.js +17 -0
- package/dist/components/shell/LinkTabs.d.ts +29 -0
- package/dist/components/shell/LinkTabs.js +15 -0
- package/dist/components/shell/NavItem.d.ts +1 -7
- package/dist/components/shell/NavItem.js +5 -10
- package/dist/components/shell/NavLinks.d.ts +1 -1
- package/dist/components/shell/NavLinks.js +3 -3
- package/dist/components/shell/NavListClient.d.ts +1 -1
- package/dist/components/shell/NavListClient.js +4 -4
- package/dist/components/shell/SiteFooter.d.ts +11 -6
- package/dist/components/shell/SiteFooter.js +18 -7
- package/dist/components/shell/links.d.ts +8 -1
- package/dist/components/shell/links.js +8 -1
- package/dist/components/tables/TBody.d.ts +16 -0
- package/dist/components/tables/TBody.js +10 -0
- package/dist/components/tables/THead.d.ts +16 -0
- package/dist/components/tables/THead.js +10 -0
- package/dist/components/tables/Table.d.ts +26 -0
- package/dist/components/tables/Table.js +12 -0
- package/dist/components/tables/Td.d.ts +19 -0
- package/dist/components/tables/Td.js +11 -0
- package/dist/components/tables/Th.d.ts +20 -0
- package/dist/components/tables/Th.js +11 -0
- package/dist/components/tables/tableStyles.d.ts +12 -0
- package/dist/components/tables/tableStyles.js +12 -0
- package/dist/index.d.ts +24 -2
- package/dist/index.js +28 -2
- package/dist/utils/href.d.ts +7 -4
- package/dist/utils/href.js +23 -7
- package/package.json +3 -3
- package/dist/components/shell/AutoLink.d.ts +0 -19
- package/dist/components/shell/AutoLink.js +0 -23
- /package/dist/components/{actions → meta}/JsonLd.js +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/BeatmapStats.tsx
|
|
3
|
+
* @desc A beatmap's CS / AR / OD / HP / BPM / length as a compact description list (the packs
|
|
4
|
+
* and pools map rows). Takes plain numbers, so it needs no osu! types; a stat left out or
|
|
5
|
+
* not finite doesn't show. The short names are `<abbr>`s with the full name as a title.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
11
|
+
/** The stats BeatmapStats shows, in order. */
|
|
12
|
+
export type BeatmapStatKey = "cs" | "ar" | "od" | "hp" | "bpm" | "length";
|
|
13
|
+
/** Every native `<dl>` prop except children, plus the numbers and optional labels. */
|
|
14
|
+
export type BeatmapStatsProps = Omit<ComponentProps<"dl">, "children"> & {
|
|
15
|
+
/** Circle size. */
|
|
16
|
+
cs?: number | null | undefined;
|
|
17
|
+
/** Approach rate. */
|
|
18
|
+
ar?: number | null | undefined;
|
|
19
|
+
/** Overall difficulty. */
|
|
20
|
+
od?: number | null | undefined;
|
|
21
|
+
/** HP drain. */
|
|
22
|
+
hp?: number | null | undefined;
|
|
23
|
+
/** Beats per minute, shown whole. */
|
|
24
|
+
bpm?: number | null | undefined;
|
|
25
|
+
/** Drain length in seconds, shown as m:ss (h:mm:ss from an hour). */
|
|
26
|
+
lengthSeconds?: number | null | undefined;
|
|
27
|
+
/** Replaces a stat's label (to translate it, say). */
|
|
28
|
+
labels?: Partial<Record<BeatmapStatKey, ReactNode>> | undefined;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* @function BeatmapStats
|
|
32
|
+
* @param props {BeatmapStatsProps} the stats as numbers, optional labels, and native dl props
|
|
33
|
+
* @returns {JSX.Element} a wrapping `<dl>` of label and value pairs, in CS, AR, OD, HP, BPM,
|
|
34
|
+
* length order
|
|
35
|
+
*/
|
|
36
|
+
export declare function BeatmapStats({ cs, ar, od, hp, bpm, lengthSeconds, labels, className, ...props }: BeatmapStatsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
const abbr = (short, full) => _jsx("abbr", { title: full, children: short });
|
|
4
|
+
const LABELS = {
|
|
5
|
+
cs: abbr("CS", "Circle size"),
|
|
6
|
+
ar: abbr("AR", "Approach rate"),
|
|
7
|
+
od: abbr("OD", "Overall difficulty"),
|
|
8
|
+
hp: abbr("HP", "HP drain"),
|
|
9
|
+
bpm: abbr("BPM", "Beats per minute"),
|
|
10
|
+
length: "Length",
|
|
11
|
+
};
|
|
12
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
13
|
+
/**
|
|
14
|
+
* @function formatLength
|
|
15
|
+
* @param seconds {number} a length in seconds
|
|
16
|
+
* @returns {string} "m:ss", or "h:mm:ss" from an hour up
|
|
17
|
+
*/
|
|
18
|
+
const formatLength = (seconds) => {
|
|
19
|
+
const total = Math.round(seconds);
|
|
20
|
+
const hours = Math.floor(total / 3600);
|
|
21
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
22
|
+
return hours > 0
|
|
23
|
+
? `${hours}:${pad(minutes)}:${pad(total % 60)}`
|
|
24
|
+
: `${minutes}:${pad(total % 60)}`;
|
|
25
|
+
};
|
|
26
|
+
// One decimal at most, with float noise gone (9.300000001 is 9.3).
|
|
27
|
+
const stat = (n) => String(Math.round(n * 10) / 10);
|
|
28
|
+
/**
|
|
29
|
+
* @function BeatmapStats
|
|
30
|
+
* @param props {BeatmapStatsProps} the stats as numbers, optional labels, and native dl props
|
|
31
|
+
* @returns {JSX.Element} a wrapping `<dl>` of label and value pairs, in CS, AR, OD, HP, BPM,
|
|
32
|
+
* length order
|
|
33
|
+
*/
|
|
34
|
+
export function BeatmapStats({ cs, ar, od, hp, bpm, lengthSeconds, labels, className, ...props }) {
|
|
35
|
+
const values = [
|
|
36
|
+
["cs", cs, stat],
|
|
37
|
+
["ar", ar, stat],
|
|
38
|
+
["od", od, stat],
|
|
39
|
+
["hp", hp, stat],
|
|
40
|
+
["bpm", bpm, (n) => String(Math.round(n))],
|
|
41
|
+
["length", lengthSeconds, formatLength],
|
|
42
|
+
];
|
|
43
|
+
return (_jsx("dl", { className: cx("flex flex-wrap gap-x-3 gap-y-1 text-xs", className), ...props, children: values.map(([key, value, format]) => typeof value === "number" && Number.isFinite(value) ? (_jsxs("div", { className: "flex gap-1", children: [_jsx("dt", { className: "text-c4", children: labels?.[key] ?? LABELS[key] }), _jsx("dd", { className: "font-bold text-c1 tabular-nums", children: format(value) })] }, key)) : null) }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/ModBadge.tsx
|
|
3
|
+
* @desc A mod pool slot's pill (NM1, HD2, TB), colored by its mod bucket the way tournament
|
|
4
|
+
* sheets and packs.haruhime.moe color them: NM sky, HD amber, HR rose, DT and NC violet,
|
|
5
|
+
* FM emerald, TB orange. Anything else is a quiet b3 pill; `className` recolors it (a
|
|
6
|
+
* custom bucket). Server-safe.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { ComponentProps } from "react";
|
|
12
|
+
/** Every native `<span>` prop (including `ref`), plus the slot or mod it shows. */
|
|
13
|
+
export type ModBadgeProps = ComponentProps<"span"> & {
|
|
14
|
+
/** A mod or slot label ("HD", "NM1", "TB"). Its first two letters pick the color. */
|
|
15
|
+
mod: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @function ModBadge
|
|
19
|
+
* @param props {ModBadgeProps} the mod or slot label, and native span props (children replace
|
|
20
|
+
* the shown text; a `title` can name the bucket)
|
|
21
|
+
* @returns {JSX.Element} a rounded pill in the bucket's color with dark text, or b3 with c2 text
|
|
22
|
+
*/
|
|
23
|
+
export declare function ModBadge({ mod, className, children, ...props }: ModBadgeProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
const BUCKETS = {
|
|
4
|
+
NM: "bg-sky-400",
|
|
5
|
+
HD: "bg-amber-300",
|
|
6
|
+
HR: "bg-rose-400",
|
|
7
|
+
DT: "bg-violet-400",
|
|
8
|
+
NC: "bg-violet-400",
|
|
9
|
+
FM: "bg-emerald-400",
|
|
10
|
+
TB: "bg-orange-400",
|
|
11
|
+
};
|
|
12
|
+
const BASE = "inline-flex min-w-11 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 font-extrabold text-xs";
|
|
13
|
+
/**
|
|
14
|
+
* @function ModBadge
|
|
15
|
+
* @param props {ModBadgeProps} the mod or slot label, and native span props (children replace
|
|
16
|
+
* the shown text; a `title` can name the bucket)
|
|
17
|
+
* @returns {JSX.Element} a rounded pill in the bucket's color with dark text, or b3 with c2 text
|
|
18
|
+
*/
|
|
19
|
+
export function ModBadge({ mod, className, children, ...props }) {
|
|
20
|
+
const color = BUCKETS[mod.slice(0, 2).toUpperCase()];
|
|
21
|
+
return (_jsx("span", { className: cx(BASE, color ? `${color} text-b6` : "bg-b3 text-c2", className), ...props, children: children ?? mod }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/StarRating.tsx
|
|
3
|
+
* @desc A star-rating pill colored on osu!'s difficulty spectrum (the packs beatmap cards):
|
|
4
|
+
* "★ 5.23" on the rating's color. Screen readers hear "5.23 stars" and an optional label
|
|
5
|
+
* after it (what a `title` tells mouse users). Server-safe.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
11
|
+
/** Every native `<span>` prop except children, plus the rating and what screen readers hear. */
|
|
12
|
+
export type StarRatingProps = Omit<ComponentProps<"span">, "children"> & {
|
|
13
|
+
/** The star rating. Shown with two decimals; NaN shows as "–". */
|
|
14
|
+
value: number;
|
|
15
|
+
/** Read after the rating by screen readers, e.g. "with HR". */
|
|
16
|
+
label?: ReactNode;
|
|
17
|
+
/** The word read after the number (default "stars"). */
|
|
18
|
+
unit?: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* @function StarRating
|
|
22
|
+
* @param props {StarRatingProps} the rating, an optional label and unit, and native span props
|
|
23
|
+
* (`style` merges over the spectrum colors)
|
|
24
|
+
* @returns {JSX.Element} a rounded pill with a star and the rating, colored by the rating
|
|
25
|
+
*/
|
|
26
|
+
export declare function StarRating({ value, label, unit, className, style, ...props }: StarRatingProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
import { starRatingColor, starRatingTextColor } from "./starColors.js";
|
|
4
|
+
/**
|
|
5
|
+
* @function StarRating
|
|
6
|
+
* @param props {StarRatingProps} the rating, an optional label and unit, and native span props
|
|
7
|
+
* (`style` merges over the spectrum colors)
|
|
8
|
+
* @returns {JSX.Element} a rounded pill with a star and the rating, colored by the rating
|
|
9
|
+
*/
|
|
10
|
+
export function StarRating({ value, label, unit = "stars", className, style, ...props }) {
|
|
11
|
+
const text = Number.isFinite(value) ? value.toFixed(2) : "–";
|
|
12
|
+
return (_jsxs("span", { className: cx("inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-bold text-xs tabular-nums", className), style: {
|
|
13
|
+
backgroundColor: starRatingColor(value),
|
|
14
|
+
color: starRatingTextColor(value),
|
|
15
|
+
...style,
|
|
16
|
+
}, ...props, children: [_jsx("span", { "aria-hidden": "true", children: "\u2605" }), _jsx("span", { "aria-hidden": "true", children: text }), _jsxs("span", { className: "sr-only", children: [text, " ", unit, label ? _jsxs(_Fragment, { children: [", ", label] }) : null] })] }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/starColors.ts
|
|
3
|
+
* @desc osu!'s star-rating color spectrum (internal), for StarRating's pill. The stops are osu!
|
|
4
|
+
* (lazer) difficulty colors, as data (no osu! source copied), blended in RGB between stops.
|
|
5
|
+
* They are the same at every --hue, like the wordmark's brand colors.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @function starRatingColor
|
|
12
|
+
* @param stars {number} a star rating
|
|
13
|
+
* @returns {string} a CSS `rgb()` color: grey below 0.1 (and for NaN), black from 9 up, and a
|
|
14
|
+
* straight blend between the two stops around it otherwise
|
|
15
|
+
*/
|
|
16
|
+
export declare const starRatingColor: (stars: number) => string;
|
|
17
|
+
/**
|
|
18
|
+
* @function starRatingTextColor
|
|
19
|
+
* @param stars {number} a star rating
|
|
20
|
+
* @returns {string} text that reads on `starRatingColor(stars)`: black up to 6.5, then osu!'s pale
|
|
21
|
+
* yellow on the dark end of the spectrum
|
|
22
|
+
*/
|
|
23
|
+
export declare const starRatingTextColor: (stars: number) => string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/starColors.ts
|
|
3
|
+
* @desc osu!'s star-rating color spectrum (internal), for StarRating's pill. The stops are osu!
|
|
4
|
+
* (lazer) difficulty colors, as data (no osu! source copied), blended in RGB between stops.
|
|
5
|
+
* They are the same at every --hue, like the wordmark's brand colors.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
const STOPS = [
|
|
11
|
+
[0.1, [66, 144, 251]],
|
|
12
|
+
[1.25, [79, 192, 255]],
|
|
13
|
+
[2, [79, 255, 213]],
|
|
14
|
+
[2.5, [124, 255, 79]],
|
|
15
|
+
[3.3, [246, 240, 92]],
|
|
16
|
+
[4.2, [255, 128, 104]],
|
|
17
|
+
[4.9, [255, 78, 111]],
|
|
18
|
+
[5.8, [198, 69, 184]],
|
|
19
|
+
[6.7, [101, 99, 222]],
|
|
20
|
+
[7.7, [24, 21, 142]],
|
|
21
|
+
[9, [0, 0, 0]],
|
|
22
|
+
];
|
|
23
|
+
const css = ([r, g, b]) => `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)})`;
|
|
24
|
+
/**
|
|
25
|
+
* @function starRatingColor
|
|
26
|
+
* @param stars {number} a star rating
|
|
27
|
+
* @returns {string} a CSS `rgb()` color: grey below 0.1 (and for NaN), black from 9 up, and a
|
|
28
|
+
* straight blend between the two stops around it otherwise
|
|
29
|
+
*/
|
|
30
|
+
export const starRatingColor = (stars) => {
|
|
31
|
+
if (!(stars >= 0.1))
|
|
32
|
+
return css([170, 170, 170]);
|
|
33
|
+
const upper = STOPS.findIndex(([at]) => stars < at);
|
|
34
|
+
const low = STOPS[upper - 1];
|
|
35
|
+
const high = STOPS[upper];
|
|
36
|
+
if (!low || !high)
|
|
37
|
+
return css([0, 0, 0]);
|
|
38
|
+
const t = (stars - low[0]) / (high[0] - low[0]);
|
|
39
|
+
const mix = (i) => low[1][i] + (high[1][i] - low[1][i]) * t;
|
|
40
|
+
return css([mix(0), mix(1), mix(2)]);
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* @function starRatingTextColor
|
|
44
|
+
* @param stars {number} a star rating
|
|
45
|
+
* @returns {string} text that reads on `starRatingColor(stars)`: black up to 6.5, then osu!'s pale
|
|
46
|
+
* yellow on the dark end of the spectrum
|
|
47
|
+
*/
|
|
48
|
+
export const starRatingTextColor = (stars) => stars >= 6.5 ? css([255, 217, 102]) : css([0, 0, 0]);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/HeaderMenu.tsx
|
|
3
|
+
* @desc The header's account menu (the pools avatar menu): a button that discloses a small panel
|
|
4
|
+
* of links and extra controls (a sign-out button). A disclosure, not an ARIA menu: Tab moves
|
|
5
|
+
* through the links. Escape closes it and puts focus back on the button; a click outside,
|
|
6
|
+
* a click on a link, or focus leaving it closes it too.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
12
|
+
/** One link in the panel. */
|
|
13
|
+
export type HeaderMenuItem = {
|
|
14
|
+
href: string;
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
/** Every native `<div>` prop except children, plus the button, the links and extra controls. */
|
|
18
|
+
export type HeaderMenuProps = Omit<ComponentProps<"div">, "children"> & {
|
|
19
|
+
/** The button's content, e.g. an avatar and a name. Give an image-only button an aria-label. */
|
|
20
|
+
label: ReactNode;
|
|
21
|
+
/** Links in the panel, top to bottom. */
|
|
22
|
+
items?: readonly HeaderMenuItem[] | undefined;
|
|
23
|
+
/** Shown after the links, e.g. a sign-out button. */
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
/** Which edge of the button the panel lines up with (default "end", for a header's right side). */
|
|
26
|
+
align?: "start" | "end" | undefined;
|
|
27
|
+
/** Accessible name for the button, when its content isn't text. */
|
|
28
|
+
buttonLabel?: string | undefined;
|
|
29
|
+
/** Classes for the button, merged last. */
|
|
30
|
+
buttonClassName?: string | undefined;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* @function HeaderMenu
|
|
34
|
+
* @param props {HeaderMenuProps} the button's content, the links, extra controls as children, the
|
|
35
|
+
* panel's alignment, and native div props for the wrapper
|
|
36
|
+
* @returns {JSX.Element} the toggle button (aria-expanded, aria-controls) and, while open, the
|
|
37
|
+
* panel below it
|
|
38
|
+
*/
|
|
39
|
+
export declare function HeaderMenu({ label, items, children, align, buttonLabel, buttonClassName, className, ...props }: HeaderMenuProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/HeaderMenu.tsx
|
|
3
|
+
* @desc The header's account menu (the pools avatar menu): a button that discloses a small panel
|
|
4
|
+
* of links and extra controls (a sign-out button). A disclosure, not an ARIA menu: Tab moves
|
|
5
|
+
* through the links. Escape closes it and puts focus back on the button; a click outside,
|
|
6
|
+
* a click on a link, or focus leaving it closes it too.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
"use client";
|
|
12
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
14
|
+
import { cx } from "../../utils/cx.js";
|
|
15
|
+
import { AutoLink } from "../basics/AutoLink.js";
|
|
16
|
+
const ITEM = "block rounded px-3 py-2 font-bold text-c2 text-sm hover:bg-b4 hover:text-c1";
|
|
17
|
+
/**
|
|
18
|
+
* @function HeaderMenu
|
|
19
|
+
* @param props {HeaderMenuProps} the button's content, the links, extra controls as children, the
|
|
20
|
+
* panel's alignment, and native div props for the wrapper
|
|
21
|
+
* @returns {JSX.Element} the toggle button (aria-expanded, aria-controls) and, while open, the
|
|
22
|
+
* panel below it
|
|
23
|
+
*/
|
|
24
|
+
export function HeaderMenu({ label, items = [], children, align = "end", buttonLabel, buttonClassName, className, ...props }) {
|
|
25
|
+
const panelId = useId();
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
const wrapper = useRef(null);
|
|
28
|
+
const button = useRef(null);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open)
|
|
31
|
+
return;
|
|
32
|
+
const onPointer = (event) => {
|
|
33
|
+
if (!wrapper.current?.contains(event.target))
|
|
34
|
+
setOpen(false);
|
|
35
|
+
};
|
|
36
|
+
const onKey = (event) => {
|
|
37
|
+
if (event.key !== "Escape")
|
|
38
|
+
return;
|
|
39
|
+
setOpen(false);
|
|
40
|
+
button.current?.focus();
|
|
41
|
+
};
|
|
42
|
+
// Tab past the last control (or Shift+Tab before the button) closes the panel.
|
|
43
|
+
const onFocusOut = (event) => {
|
|
44
|
+
if (!wrapper.current?.contains(event.relatedTarget))
|
|
45
|
+
setOpen(false);
|
|
46
|
+
};
|
|
47
|
+
const box = wrapper.current;
|
|
48
|
+
document.addEventListener("pointerdown", onPointer);
|
|
49
|
+
document.addEventListener("keydown", onKey);
|
|
50
|
+
box?.addEventListener("focusout", onFocusOut);
|
|
51
|
+
return () => {
|
|
52
|
+
document.removeEventListener("pointerdown", onPointer);
|
|
53
|
+
document.removeEventListener("keydown", onKey);
|
|
54
|
+
box?.removeEventListener("focusout", onFocusOut);
|
|
55
|
+
};
|
|
56
|
+
}, [open]);
|
|
57
|
+
return (_jsxs("div", { ref: wrapper, className: cx("relative", className), ...props, children: [_jsx("button", { ref: button, type: "button", "aria-expanded": open, "aria-controls": panelId, "aria-label": buttonLabel, onClick: () => setOpen(!open), className: cx("flex items-center gap-2 font-bold text-c1 text-sm transition-colors hover:text-h1", buttonClassName), children: label }), _jsxs("div", { id: panelId, hidden: !open, className: cx("absolute z-10 mt-2 w-44 flex-col gap-1 rounded-lg border border-b3 bg-b6 p-2", open && "flex", align === "end" ? "right-0" : "left-0"), children: [items.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1", children: items.map((item) => (_jsx("li", { children: _jsx(AutoLink, { href: item.href, className: ITEM, onClick: () => setOpen(false), children: item.label }) }, item.href))) })) : null, children] })] }));
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/LinkNote.tsx
|
|
3
|
+
* @desc The small uppercase note beside a nav or footer entry, like "soon" (internal). NavItem and
|
|
4
|
+
* SiteFooter share it. No class merging, so the header's client list can render it.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @function LinkNote
|
|
11
|
+
* @param props {{ note?: string }} the entry's note, if any
|
|
12
|
+
* @returns {JSX.Element | null} a space and the note in small muted capitals, or nothing
|
|
13
|
+
*/
|
|
14
|
+
export declare function LinkNote({ note }: {
|
|
15
|
+
note?: string | undefined;
|
|
16
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/shell/LinkNote.tsx
|
|
4
|
+
* @desc The small uppercase note beside a nav or footer entry, like "soon" (internal). NavItem and
|
|
5
|
+
* SiteFooter share it. No class merging, so the header's client list can render it.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @function LinkNote
|
|
12
|
+
* @param props {{ note?: string }} the entry's note, if any
|
|
13
|
+
* @returns {JSX.Element | null} a space and the note in small muted capitals, or nothing
|
|
14
|
+
*/
|
|
15
|
+
export function LinkNote({ note }) {
|
|
16
|
+
return note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-c4 text-xs uppercase tracking-wide", children: note })] })) : null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/shell/LinkTabs.tsx
|
|
3
|
+
* @desc A row of link tabs (Pools / Maps, All / Hidden): a named `<nav>` whose current link is
|
|
4
|
+
* marked aria-current="page" and drawn as a b3 pill. Links, not ARIA tabs, since each one
|
|
5
|
+
* loads its own URL. Server-safe: the caller says which link is current.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
11
|
+
/** One tab: where it links, its text, and whether it is the page being shown. */
|
|
12
|
+
export type LinkTabItem = {
|
|
13
|
+
href: string;
|
|
14
|
+
label: ReactNode;
|
|
15
|
+
current?: boolean | undefined;
|
|
16
|
+
};
|
|
17
|
+
/** Every native `<nav>` prop except children, plus the nav's name and the tabs. */
|
|
18
|
+
export type LinkTabsProps = Omit<ComponentProps<"nav">, "children"> & {
|
|
19
|
+
/** The nav landmark's accessible name, e.g. "What to search". */
|
|
20
|
+
label: string;
|
|
21
|
+
/** The tabs, left to right. Give each its own href. */
|
|
22
|
+
items: readonly LinkTabItem[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* @function LinkTabs
|
|
26
|
+
* @param props {LinkTabsProps} the nav's label, the tabs, and native nav props
|
|
27
|
+
* @returns {JSX.Element} a `<nav>` with a list of pill links, the current one aria-current="page"
|
|
28
|
+
*/
|
|
29
|
+
export declare function LinkTabs({ label, items, className, ...props }: LinkTabsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
import { AutoLink } from "../basics/AutoLink.js";
|
|
4
|
+
const TAB = "rounded-full px-3 py-1 font-bold text-sm transition-colors";
|
|
5
|
+
// Forced-colors mode drops the pill's background, so the current tab is underlined there.
|
|
6
|
+
const CURRENT = "bg-b3 text-c1 forced-colors:underline";
|
|
7
|
+
const OTHER = "text-c3 hover:text-c1";
|
|
8
|
+
/**
|
|
9
|
+
* @function LinkTabs
|
|
10
|
+
* @param props {LinkTabsProps} the nav's label, the tabs, and native nav props
|
|
11
|
+
* @returns {JSX.Element} a `<nav>` with a list of pill links, the current one aria-current="page"
|
|
12
|
+
*/
|
|
13
|
+
export function LinkTabs({ label, items, className, ...props }) {
|
|
14
|
+
return (_jsx("nav", { "aria-label": label, className: className, ...props, children: _jsx("ul", { className: "flex flex-wrap gap-2", children: items.map((item) => (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": item.current ? "page" : undefined, className: cx(TAB, item.current ? CURRENT : OTHER), children: item.label }) }, item.href))) }) }));
|
|
15
|
+
}
|
|
@@ -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 Mon Sep 28, 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. */
|
|
@@ -16,12 +16,6 @@ export type NavItemProps = {
|
|
|
16
16
|
/** Classes for the link. Ignored for an entry without `href`. */
|
|
17
17
|
linkClassName: string;
|
|
18
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
19
|
/**
|
|
26
20
|
* @function NavItem
|
|
27
21
|
* @param props {NavItemProps} the entry, its aria-current and its link classes
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* @file src/components/shell/NavItem.tsx
|
|
4
4
|
* @desc One entry of the header's nav list (internal): a link, or dimmed text with its note when
|
|
@@ -6,15 +6,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
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 Mon Sep 28, 2026
|
|
10
10
|
*/
|
|
11
|
-
import { AutoLink } from "
|
|
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;
|
|
11
|
+
import { AutoLink } from "../basics/AutoLink.js";
|
|
12
|
+
import { LinkNote } from "./LinkNote.js";
|
|
18
13
|
/**
|
|
19
14
|
* @function NavItem
|
|
20
15
|
* @param props {NavItemProps} the entry, its aria-current and its link classes
|
|
@@ -22,7 +17,7 @@ export const navItemKey = (item) => item.href || item.label;
|
|
|
22
17
|
*/
|
|
23
18
|
export function NavItem({ item, current, linkClassName }) {
|
|
24
19
|
if (!item.href) {
|
|
25
|
-
return (_jsx("li", { children: _jsxs("span", { "aria-disabled": "true", className: "text-c4", children: [item.label,
|
|
20
|
+
return (_jsx("li", { children: _jsxs("span", { "aria-disabled": "true", className: "text-c4", children: [item.label, _jsx(LinkNote, { note: item.note })] }) }));
|
|
26
21
|
}
|
|
27
22
|
return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: linkClassName, children: item.label }) }));
|
|
28
23
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* it to the small NavListClient, which reads the path to set aria-current.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
9
|
*/
|
|
10
10
|
import type { ComponentProps } from "react";
|
|
11
11
|
import { type SiteLinkItem } from "./links.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
-
import { canBeCurrent } from "./links.js";
|
|
4
|
-
import { NavItem
|
|
3
|
+
import { canBeCurrent, linkItemKey } from "./links.js";
|
|
4
|
+
import { NavItem } from "./NavItem.js";
|
|
5
5
|
import { NavListClient } from "./NavListClient.js";
|
|
6
6
|
const LISTS = {
|
|
7
7
|
start: "flex flex-wrap gap-x-5 gap-y-1 font-bold text-sm",
|
|
@@ -25,7 +25,7 @@ const CURRENT = "text-c1 transition-colors";
|
|
|
25
25
|
export function NavLinks({ links, align = "start", className, ...props }) {
|
|
26
26
|
const listClassName = cx(LISTS[align], className);
|
|
27
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] },
|
|
28
|
+
return (_jsx("ul", { className: listClassName, ...props, children: links.map((item) => (_jsx(NavItem, { item: item, linkClassName: LINKS[align] }, linkItemKey(item)))) }));
|
|
29
29
|
}
|
|
30
30
|
return (_jsx(NavListClient, { links: links, linkClassName: LINKS[align], currentClassName: CURRENT, className: listClassName, ...props }));
|
|
31
31
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* the server, so it imports no class merging and ships no tailwind-merge to the browser.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Thu Sep 24, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
9
|
*/
|
|
10
10
|
import type { ComponentProps } from "react";
|
|
11
11
|
import { type SiteLinkItem } from "./links.js";
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* the server, so it imports no class merging and ships no tailwind-merge to the browser.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Thu Sep 24, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
9
|
*/
|
|
10
10
|
"use client";
|
|
11
11
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
12
12
|
import { usePathname } from "next/navigation.js";
|
|
13
|
-
import { ariaCurrentFor } from "./links.js";
|
|
14
|
-
import { NavItem
|
|
13
|
+
import { ariaCurrentFor, linkItemKey } from "./links.js";
|
|
14
|
+
import { NavItem } from "./NavItem.js";
|
|
15
15
|
/**
|
|
16
16
|
* @function NavListClient
|
|
17
17
|
* @param props {NavListClientProps} the links, the classes for current and other links, and
|
|
@@ -22,6 +22,6 @@ export function NavListClient({ links, linkClassName, currentClassName, ...props
|
|
|
22
22
|
const pathname = usePathname();
|
|
23
23
|
return (_jsx("ul", { ...props, children: links.map((item) => {
|
|
24
24
|
const current = item.href ? ariaCurrentFor(pathname, item.href) : undefined;
|
|
25
|
-
return (_jsx(NavItem, { item: item, current: current, linkClassName: current ? currentClassName : linkClassName },
|
|
25
|
+
return (_jsx(NavItem, { item: item, current: current, linkClassName: current ? currentClassName : linkClassName }, linkItemKey(item)));
|
|
26
26
|
}) }));
|
|
27
27
|
}
|
|
@@ -2,13 +2,14 @@
|
|
|
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 Mon Sep 28, 2026
|
|
9
10
|
*/
|
|
10
11
|
import type { ComponentProps, ReactNode } from "react";
|
|
11
|
-
import type
|
|
12
|
+
import { type SiteLinkItem } from "./links.js";
|
|
12
13
|
/** One footer column: a title (also the nav landmark's name) and its entries. */
|
|
13
14
|
export type SiteFooterColumn = {
|
|
14
15
|
title: string;
|
|
@@ -30,11 +31,15 @@ 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;
|
|
36
|
+
/** The Discord link's accessible name. Default "Discord". */
|
|
37
|
+
discordLabel?: string | undefined;
|
|
33
38
|
};
|
|
34
39
|
/**
|
|
35
40
|
* @function SiteFooter
|
|
36
|
-
* @param props {SiteFooterProps} columns, extra slot, fine print, parent
|
|
37
|
-
* native footer props
|
|
41
|
+
* @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
|
|
42
|
+
* links and their names, and native footer props
|
|
38
43
|
* @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
|
|
39
44
|
*/
|
|
40
|
-
export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
|
|
45
|
+
export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, discordLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
|
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
+
import { AutoLink } from "../basics/AutoLink.js";
|
|
4
|
+
import { DiscordIcon } from "../icons/DiscordIcon.js";
|
|
3
5
|
import { GitHubIcon } from "../icons/GitHubIcon.js";
|
|
4
6
|
import { HaruhimeWordmarkLink } from "../icons/HaruhimeWordmarkLink.js";
|
|
5
|
-
import {
|
|
7
|
+
import { LinkNote } from "./LinkNote.js";
|
|
8
|
+
import { linkItemKey } from "./links.js";
|
|
9
|
+
// The GitHub icon link in the bottom row.
|
|
10
|
+
const ICON_LINK = "shrink-0 text-c3 transition-colors hover:text-c1";
|
|
11
|
+
// Discord's brand guidelines ask for the logo in color, black or white, never recolored. c1 is
|
|
12
|
+
// white at every hue, so the Discord link stays white and dims on hover instead of changing hue.
|
|
13
|
+
const DISCORD_LINK = "shrink-0 text-c1 transition-opacity hover:opacity-80";
|
|
6
14
|
// Static strings so Tailwind sees every class. Four or more columns share the four-column grid.
|
|
7
15
|
const GRID_COLUMNS = ["", "", "sm:grid-cols-2", "sm:grid-cols-3", "sm:grid-cols-4"];
|
|
8
16
|
/**
|
|
9
17
|
* @function SiteFooter
|
|
10
|
-
* @param props {SiteFooterProps} columns, extra slot, fine print, parent
|
|
11
|
-
* native footer props
|
|
18
|
+
* @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
|
|
19
|
+
* links and their names, and native footer props
|
|
12
20
|
* @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
|
|
13
21
|
*/
|
|
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 }) {
|
|
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 }) {
|
|
15
23
|
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:
|
|
24
|
+
const github = githubHref ? (_jsx("a", { href: githubHref, "aria-label": githubLabel, className: ICON_LINK, children: _jsx(GitHubIcon, {}) })) : null;
|
|
25
|
+
const discord = discordHref ? (_jsx("a", { href: discordHref, "aria-label": discordLabel, className: DISCORD_LINK, children: _jsx(DiscordIcon, {}) })) : null;
|
|
26
|
+
// Both icons sit together at the row's end, Discord first. One icon stays a direct child.
|
|
27
|
+
const icons = discord && github ? (_jsxs("div", { className: "flex shrink-0 items-center gap-4", children: [discord, github] })) : ((discord ?? github));
|
|
17
28
|
// With the wordmark, the fine print gets its own line and the row holds wordmark + icon.
|
|
18
29
|
// Without it, the fine print shares the row with the icon.
|
|
19
30
|
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 })),
|
|
31
|
+
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 })), _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] }) }));
|
|
21
32
|
}
|