@haruhimemoe/ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +252 -27
  3. package/dist/components/actions/AsyncButton.d.ts +30 -0
  4. package/dist/components/actions/AsyncButton.js +48 -0
  5. package/dist/components/actions/CopyButton.d.ts +3 -3
  6. package/dist/components/actions/CopyButton.js +9 -12
  7. package/dist/components/actions/InlineConfirm.d.ts +45 -0
  8. package/dist/components/actions/InlineConfirm.js +80 -0
  9. package/dist/components/actions/Pagination.d.ts +40 -17
  10. package/dist/components/actions/Pagination.js +25 -18
  11. package/dist/components/actions/PaginationButton.d.ts +25 -0
  12. package/dist/components/actions/PaginationButton.js +23 -0
  13. package/dist/components/actions/PaginationStatus.d.ts +7 -4
  14. package/dist/components/actions/PaginationStatus.js +7 -5
  15. package/dist/components/actions/StatusOutput.d.ts +20 -0
  16. package/dist/components/actions/StatusOutput.js +9 -0
  17. package/dist/components/actions/pages.d.ts +21 -0
  18. package/dist/components/actions/pages.js +21 -0
  19. package/dist/components/actions/useLatestStatus.d.ts +26 -0
  20. package/dist/components/actions/useLatestStatus.js +32 -0
  21. package/dist/components/basics/AutoLink.d.ts +21 -0
  22. package/dist/components/basics/AutoLink.js +26 -0
  23. package/dist/components/basics/Badge.d.ts +22 -0
  24. package/dist/components/basics/Badge.js +18 -0
  25. package/dist/components/basics/ButtonLink.d.ts +5 -6
  26. package/dist/components/basics/ButtonLink.js +5 -11
  27. package/dist/components/basics/Card.d.ts +4 -3
  28. package/dist/components/basics/Card.js +3 -2
  29. package/dist/components/basics/Disclosure.d.ts +34 -0
  30. package/dist/components/basics/Disclosure.js +30 -0
  31. package/dist/components/basics/Notice.d.ts +2 -1
  32. package/dist/components/basics/TextLink.d.ts +21 -0
  33. package/dist/components/basics/TextLink.js +19 -0
  34. package/dist/components/basics/buttonStyles.d.ts +3 -1
  35. package/dist/components/basics/buttonStyles.js +1 -1
  36. package/dist/components/basics/cardStyles.d.ts +14 -0
  37. package/dist/components/basics/cardStyles.js +12 -0
  38. package/dist/components/basics/linkStyles.d.ts +23 -0
  39. package/dist/components/basics/linkStyles.js +23 -0
  40. package/dist/components/filters/Chip.d.ts +13 -5
  41. package/dist/components/filters/Chip.js +20 -11
  42. package/dist/components/filters/ChipGroup.d.ts +11 -12
  43. package/dist/components/filters/ChipGroup.js +5 -10
  44. package/dist/components/filters/ChoiceChips.d.ts +38 -0
  45. package/dist/components/filters/ChoiceChips.js +31 -0
  46. package/dist/components/filters/FilterPanel.d.ts +5 -4
  47. package/dist/components/filters/FilterPanel.js +5 -4
  48. package/dist/components/filters/FilterRow.d.ts +1 -1
  49. package/dist/components/filters/FilterRow.js +3 -2
  50. package/dist/components/filters/GroupFrame.d.ts +27 -0
  51. package/dist/components/filters/GroupFrame.js +27 -0
  52. package/dist/components/filters/RangeBox.d.ts +28 -0
  53. package/dist/components/filters/RangeBox.js +41 -0
  54. package/dist/components/filters/RangeSlider.d.ts +13 -14
  55. package/dist/components/filters/RangeSlider.js +40 -103
  56. package/dist/components/filters/chipStyles.d.ts +17 -0
  57. package/dist/components/filters/chipStyles.js +17 -0
  58. package/dist/components/filters/rangeMath.d.ts +106 -0
  59. package/dist/components/filters/rangeMath.js +146 -0
  60. package/dist/components/forms/Checkbox.d.ts +5 -4
  61. package/dist/components/forms/Checkbox.js +3 -3
  62. package/dist/components/forms/FieldFrame.d.ts +28 -2
  63. package/dist/components/forms/FieldFrame.js +14 -1
  64. package/dist/components/forms/RadioGroup.d.ts +46 -0
  65. package/dist/components/forms/RadioGroup.js +32 -0
  66. package/dist/components/forms/Select.d.ts +1 -1
  67. package/dist/components/forms/Select.js +2 -2
  68. package/dist/components/forms/TextInput.d.ts +1 -1
  69. package/dist/components/forms/TextInput.js +2 -2
  70. package/dist/components/forms/Textarea.d.ts +1 -1
  71. package/dist/components/forms/Textarea.js +2 -2
  72. package/dist/components/forms/TypeToConfirm.d.ts +40 -0
  73. package/dist/components/forms/TypeToConfirm.js +46 -0
  74. package/dist/components/forms/fieldStyles.d.ts +5 -2
  75. package/dist/components/forms/fieldStyles.js +5 -2
  76. package/dist/components/{actions → meta}/JsonLd.d.ts +2 -2
  77. package/dist/components/osu/BeatmapStats.d.ts +36 -0
  78. package/dist/components/osu/BeatmapStats.js +44 -0
  79. package/dist/components/osu/ModBadge.d.ts +23 -0
  80. package/dist/components/osu/ModBadge.js +22 -0
  81. package/dist/components/osu/StarRating.d.ts +26 -0
  82. package/dist/components/osu/StarRating.js +17 -0
  83. package/dist/components/osu/starColors.d.ts +23 -0
  84. package/dist/components/osu/starColors.js +48 -0
  85. package/dist/components/shell/HeaderMenu.d.ts +39 -0
  86. package/dist/components/shell/HeaderMenu.js +58 -0
  87. package/dist/components/shell/LinkNote.d.ts +16 -0
  88. package/dist/components/shell/LinkNote.js +17 -0
  89. package/dist/components/shell/LinkTabs.d.ts +29 -0
  90. package/dist/components/shell/LinkTabs.js +15 -0
  91. package/dist/components/shell/NavItem.d.ts +1 -7
  92. package/dist/components/shell/NavItem.js +5 -10
  93. package/dist/components/shell/NavLinks.d.ts +1 -1
  94. package/dist/components/shell/NavLinks.js +3 -3
  95. package/dist/components/shell/NavListClient.d.ts +1 -1
  96. package/dist/components/shell/NavListClient.js +4 -4
  97. package/dist/components/shell/SiteFooter.d.ts +6 -4
  98. package/dist/components/shell/SiteFooter.js +8 -6
  99. package/dist/components/shell/links.d.ts +8 -1
  100. package/dist/components/shell/links.js +8 -1
  101. package/dist/components/tables/TBody.d.ts +16 -0
  102. package/dist/components/tables/TBody.js +10 -0
  103. package/dist/components/tables/THead.d.ts +16 -0
  104. package/dist/components/tables/THead.js +10 -0
  105. package/dist/components/tables/Table.d.ts +26 -0
  106. package/dist/components/tables/Table.js +12 -0
  107. package/dist/components/tables/Td.d.ts +19 -0
  108. package/dist/components/tables/Td.js +11 -0
  109. package/dist/components/tables/Th.d.ts +20 -0
  110. package/dist/components/tables/Th.js +11 -0
  111. package/dist/components/tables/tableStyles.d.ts +12 -0
  112. package/dist/components/tables/tableStyles.js +12 -0
  113. package/dist/index.d.ts +23 -2
  114. package/dist/index.js +27 -2
  115. package/dist/utils/href.d.ts +7 -4
  116. package/dist/utils/href.js +23 -7
  117. package/package.json +3 -3
  118. package/dist/components/shell/AutoLink.d.ts +0 -19
  119. package/dist/components/shell/AutoLink.js +0 -23
  120. /package/dist/components/{actions → meta}/JsonLd.js +0 -0
@@ -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 Thu Sep 24, 2026
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 { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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 Thu Sep 24, 2026
9
+ * @modified Mon Sep 28, 2026
10
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;
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, item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-xs uppercase tracking-wide", children: item.note })] })) : null] }) }));
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 Thu Sep 24, 2026
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, navItemKey } from "./NavItem.js";
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] }, navItemKey(item)))) }));
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 Thu Sep 24, 2026
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 Thu Sep 24, 2026
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, navItemKey } from "./NavItem.js";
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 }, navItemKey(item)));
25
+ return (_jsx(NavItem, { item: item, current: current, linkClassName: current ? currentClassName : linkClassName }, linkItemKey(item)));
26
26
  }) }));
27
27
  }
@@ -6,10 +6,10 @@
6
6
  * Discord icon link beside it (white, as Discord's brand guidelines ask).
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Wed Sep 23, 2026
9
- * @modified Fri Sep 25, 2026
9
+ * @modified Mon Sep 28, 2026
10
10
  */
11
11
  import type { ComponentProps, ReactNode } from "react";
12
- import type { SiteLinkItem } from "./links.js";
12
+ import { type SiteLinkItem } from "./links.js";
13
13
  /** One footer column: a title (also the nav landmark's name) and its entries. */
14
14
  export type SiteFooterColumn = {
15
15
  title: string;
@@ -33,11 +33,13 @@ export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
33
33
  githubLabel?: string | undefined;
34
34
  /** Where the Discord icon links, e.g. a server invite. No Discord icon without it. */
35
35
  discordHref?: string | undefined;
36
+ /** The Discord link's accessible name. Default "Discord". */
37
+ discordLabel?: string | undefined;
36
38
  };
37
39
  /**
38
40
  * @function SiteFooter
39
41
  * @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
40
- * links, and native footer props
42
+ * links and their names, and native footer props
41
43
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
42
44
  */
43
- export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, 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,9 +1,11 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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";
3
4
  import { DiscordIcon } from "../icons/DiscordIcon.js";
4
5
  import { GitHubIcon } from "../icons/GitHubIcon.js";
5
6
  import { HaruhimeWordmarkLink } from "../icons/HaruhimeWordmarkLink.js";
6
- import { AutoLink } from "./AutoLink.js";
7
+ import { LinkNote } from "./LinkNote.js";
8
+ import { linkItemKey } from "./links.js";
7
9
  // The GitHub icon link in the bottom row.
8
10
  const ICON_LINK = "shrink-0 text-c3 transition-colors hover:text-c1";
9
11
  // Discord's brand guidelines ask for the logo in color, black or white, never recolored. c1 is
@@ -14,17 +16,17 @@ const GRID_COLUMNS = ["", "", "sm:grid-cols-2", "sm:grid-cols-3", "sm:grid-cols-
14
16
  /**
15
17
  * @function SiteFooter
16
18
  * @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
17
- * links, and native footer props
19
+ * links and their names, and native footer props
18
20
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
19
21
  */
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 }) {
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 }) {
21
23
  const fine = finePrint ? _jsx("p", { className: "text-c4 text-xs", children: finePrint }) : null;
22
24
  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;
25
+ const discord = discordHref ? (_jsx("a", { href: discordHref, "aria-label": discordLabel, className: DISCORD_LINK, children: _jsx(DiscordIcon, {}) })) : null;
24
26
  // Both icons sit together at the row's end, Discord first. One icon stays a direct child.
25
27
  const icons = discord && github ? (_jsxs("div", { className: "flex shrink-0 items-center gap-4", children: [discord, github] })) : ((discord ?? github));
26
28
  // With the wordmark, the fine print gets its own line and the row holds wordmark + icon.
27
29
  // Without it, the fine print shares the row with the icon.
28
30
  const rowStart = parentLink ? _jsx(HaruhimeWordmarkLink, { href: parentHref }) : fine;
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] }) }));
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] }) }));
30
32
  }
@@ -5,7 +5,7 @@
5
5
  * ever be the current page.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Thu Sep 24, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
10
  import { isExternalHref } from "../../utils/href.js";
11
11
  export { isExternalHref };
@@ -15,6 +15,13 @@ export type SiteLinkItem = {
15
15
  href?: string | undefined;
16
16
  note?: string | undefined;
17
17
  };
18
+ /**
19
+ * @function linkItemKey
20
+ * @param item {SiteLinkItem} a nav or footer entry
21
+ * @returns {string} its React key, from the label and the href together, so two entries with the
22
+ * same href (or two text-only entries) still get different keys
23
+ */
24
+ export declare const linkItemKey: (item: SiteLinkItem) => string;
18
25
  /**
19
26
  * @function canBeCurrent
20
27
  * @param href {string} a nav link target
@@ -5,10 +5,17 @@
5
5
  * ever be the current page.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Thu Sep 24, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
10
  import { isExternalHref } from "../../utils/href.js";
11
11
  export { isExternalHref };
12
+ /**
13
+ * @function linkItemKey
14
+ * @param item {SiteLinkItem} a nav or footer entry
15
+ * @returns {string} its React key, from the label and the href together, so two entries with the
16
+ * same href (or two text-only entries) still get different keys
17
+ */
18
+ export const linkItemKey = (item) => `${item.label} ${item.href ?? ""}`;
12
19
  const trimSlash = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
13
20
  // The app path an href points at, without query, fragment or trailing slash. Undefined for an
14
21
  // href that leaves the app or is relative ("packs", "#main", "?page=2").