@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,18 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ const BASE = "inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs";
4
+ // The looks the apps already use: pools' tags and warnings, and its header's beta pill.
5
+ const TONES = {
6
+ neutral: "bg-b3 text-c2",
7
+ accent: "bg-h1 font-bold text-b6",
8
+ warning: "bg-amber-300/20 font-bold text-amber-200",
9
+ muted: "border border-b3 bg-b5 font-bold text-c4 uppercase tracking-wide",
10
+ };
11
+ /**
12
+ * @function Badge
13
+ * @param props {BadgeProps} native span props, plus a tone (default "neutral")
14
+ * @returns {JSX.Element} a rounded `<span>` pill in the tone's colors
15
+ */
16
+ export function Badge({ tone = "neutral", className, ...props }) {
17
+ return _jsx("span", { className: cx(BASE, TONES[tone], className), ...props });
18
+ }
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * @file src/components/basics/ButtonLink.tsx
3
3
  * @desc next/link styled as a pill button. Off-site hrefs (a scheme like https: or mailto:, or
4
- * //host) render a plain <a>, with rel="noreferrer" when opened in a new tab.
4
+ * //host) render a plain <a>, with rel="noreferrer" when opened in a new tab (AutoLink).
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
7
+ * @modified Mon Sep 28, 2026
8
8
  */
9
- import Link from "next/link.js";
10
- import type { ComponentProps } from "react";
9
+ import { type AutoLinkProps } from "./AutoLink.js";
11
10
  import { type ButtonSize, type ButtonVariant } from "./buttonStyles.js";
12
11
  /** Every next/link prop (including `ref`), plus a button variant and size. */
13
- export type ButtonLinkProps = ComponentProps<typeof Link> & {
12
+ export type ButtonLinkProps = AutoLinkProps & {
14
13
  variant?: ButtonVariant | undefined;
15
14
  size?: ButtonSize | undefined;
16
15
  };
@@ -20,4 +19,4 @@ export type ButtonLinkProps = ComponentProps<typeof Link> & {
20
19
  * @returns {JSX.Element} a next/link styled as a pill button, or a plain `<a>` for an external
21
20
  * href (next/link's own props are dropped there)
22
21
  */
23
- export declare function ButtonLink({ variant, size, className, href, target, rel, ...props }: ButtonLinkProps): import("react").JSX.Element;
22
+ export declare function ButtonLink({ variant, size, className, ...props }: ButtonLinkProps): import("react").JSX.Element;
@@ -2,13 +2,12 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * @file src/components/basics/ButtonLink.tsx
4
4
  * @desc next/link styled as a pill button. Off-site hrefs (a scheme like https: or mailto:, or
5
- * //host) render a plain <a>, with rel="noreferrer" when opened in a new tab.
5
+ * //host) render a plain <a>, with rel="noreferrer" when opened in a new tab (AutoLink).
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
- import Link from "next/link.js";
11
- import { isExternalHref } from "../../utils/href.js";
10
+ import { AutoLink } from "./AutoLink.js";
12
11
  import { buttonClasses } from "./buttonStyles.js";
13
12
  /**
14
13
  * @function ButtonLink
@@ -16,11 +15,6 @@ import { buttonClasses } from "./buttonStyles.js";
16
15
  * @returns {JSX.Element} a next/link styled as a pill button, or a plain `<a>` for an external
17
16
  * href (next/link's own props are dropped there)
18
17
  */
19
- export function ButtonLink({ variant, size, className, href, target, rel, ...props }) {
20
- const classes = buttonClasses({ variant, size, className });
21
- if (typeof href === "string" && isExternalHref(href)) {
22
- const { as: _as, replace: _replace, scroll: _scroll, shallow: _shallow, passHref: _passHref, prefetch: _prefetch, locale: _locale, legacyBehavior: _legacyBehavior, onNavigate: _onNavigate, transitionTypes: _transitionTypes, ...anchorProps } = props;
23
- return (_jsx("a", { href: href, target: target, rel: rel ?? (target === "_blank" ? "noreferrer" : undefined), className: classes, ...anchorProps }));
24
- }
25
- return _jsx(Link, { href: href, target: target, rel: rel, className: classes, ...props });
18
+ export function ButtonLink({ variant, size, className, ...props }) {
19
+ return _jsx(AutoLink, { className: buttonClasses({ variant, size, className }), ...props });
26
20
  }
@@ -4,14 +4,15 @@
4
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 Thu Sep 24, 2026
7
+ * @modified Mon Sep 28, 2026
8
8
  */
9
9
  import { type ComponentProps, type ReactNode } from "react";
10
+ import { type HeadingLevel } from "./cardStyles.js";
10
11
  /** Every native `<section>` prop (including `ref`), with `title` rendered as the card's heading. */
11
12
  export type CardProps = Omit<ComponentProps<"section">, "title"> & {
12
13
  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;
14
+ /** Heading level for the title (default 2). Use 3 or lower for a card under another heading. */
15
+ headingLevel?: HeadingLevel | undefined;
15
16
  };
16
17
  /**
17
18
  * @function Card
@@ -5,10 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
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 Thu Sep 24, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
10
  import { useId } from "react";
11
11
  import { cx } from "../../utils/cx.js";
12
+ import { CARD, CARD_HEADING } from "./cardStyles.js";
12
13
  /**
13
14
  * @function Card
14
15
  * @param props {CardProps} an optional title that labels the region, its heading level (default
@@ -18,5 +19,5 @@ import { cx } from "../../utils/cx.js";
18
19
  export function Card({ title, headingLevel = 2, className, children, ...props }) {
19
20
  const headingId = useId();
20
21
  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] }));
22
+ return (_jsxs("section", { "aria-labelledby": title ? headingId : undefined, className: cx(CARD, className), ...props, children: [title ? (_jsx(Heading, { id: headingId, className: cx("mb-2", CARD_HEADING), children: title })) : null, children] }));
22
23
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @file src/components/basics/Disclosure.tsx
3
+ * @desc A button that shows and hides a panel below it (the packs download options): the button
4
+ * carries aria-expanded and aria-controls, and the closed panel stays in the page, hidden,
5
+ * so its form fields keep their values. Uncontrolled by default, or controlled with `open`.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { type ComponentProps, type ReactNode } from "react";
11
+ /** Every native `<div>` prop for the wrapper, plus the button's text and the panel. */
12
+ export type DisclosureProps = Omit<ComponentProps<"div">, "children"> & {
13
+ /** The button's text. It can change with `open` ("Download options" / a summary). */
14
+ summary: ReactNode;
15
+ /** The panel, shown while open. */
16
+ children: ReactNode;
17
+ /** Whether it starts open (default false). Ignored when `open` is set. */
18
+ defaultOpen?: boolean | undefined;
19
+ /** Controls the state from outside; pair it with `onOpenChange`. */
20
+ open?: boolean | undefined;
21
+ /** Called with the new state when the button is pressed. */
22
+ onOpenChange?: ((open: boolean) => void) | undefined;
23
+ /** Classes for the button, merged last. */
24
+ buttonClassName?: string | undefined;
25
+ /** Classes for the panel, merged last. */
26
+ panelClassName?: string | undefined;
27
+ };
28
+ /**
29
+ * @function Disclosure
30
+ * @param props {DisclosureProps} the summary, the panel as children, the open state, and native
31
+ * div props for the wrapper
32
+ * @returns {JSX.Element} the toggle button (with a ▾ / ▴ arrow) and the panel it controls
33
+ */
34
+ export declare function Disclosure({ summary, children, defaultOpen, open, onOpenChange, buttonClassName, panelClassName, className, ...props }: DisclosureProps): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file src/components/basics/Disclosure.tsx
3
+ * @desc A button that shows and hides a panel below it (the packs download options): the button
4
+ * carries aria-expanded and aria-controls, and the closed panel stays in the page, hidden,
5
+ * so its form fields keep their values. Uncontrolled by default, or controlled with `open`.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ "use client";
11
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
+ import { useId, useState } from "react";
13
+ import { cx } from "../../utils/cx.js";
14
+ /**
15
+ * @function Disclosure
16
+ * @param props {DisclosureProps} the summary, the panel as children, the open state, and native
17
+ * div props for the wrapper
18
+ * @returns {JSX.Element} the toggle button (with a ▾ / ▴ arrow) and the panel it controls
19
+ */
20
+ export function Disclosure({ summary, children, defaultOpen = false, open, onOpenChange, buttonClassName, panelClassName, className, ...props }) {
21
+ const panelId = useId();
22
+ const [inner, setInner] = useState(defaultOpen);
23
+ const isOpen = open ?? inner;
24
+ const toggle = () => {
25
+ if (open === undefined)
26
+ setInner(!isOpen);
27
+ onOpenChange?.(!isOpen);
28
+ };
29
+ return (_jsxs("div", { className: cx("flex flex-col gap-2", className), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isOpen, "aria-controls": panelId, onClick: toggle, className: cx("inline-flex items-center gap-1 self-start font-bold text-c2 text-sm transition-colors hover:text-c1", buttonClassName), children: [summary, _jsx("span", { "aria-hidden": "true", children: isOpen ? "▴" : "▾" })] }), _jsx("div", { id: panelId, hidden: !isOpen, className: panelClassName, children: children })] }));
30
+ }
@@ -6,9 +6,10 @@
6
6
  * status region that mounts with its text already inside may not be announced.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Wed Sep 23, 2026
9
- * @modified Wed Sep 23, 2026
9
+ * @modified Mon Sep 28, 2026
10
10
  */
11
11
  import type { ComponentProps } from "react";
12
+ /** The notice's color, and its live role: `info` is c3, `warning` amber, `error` rose. */
12
13
  export type NoticeTone = "info" | "warning" | "error";
13
14
  /** Every native `<p>` prop (including `ref`), plus a tone, a live flag and the element to render. */
14
15
  export type NoticeProps = ComponentProps<"p"> & {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file src/components/basics/TextLink.tsx
3
+ * @desc A text link: next/link inside the app, a plain <a> off-site (with rel="noreferrer" in a
4
+ * new tab), in the `accent` look for running text or the `plain` one for names in lists.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Mon Sep 28, 2026
7
+ * @modified Mon Sep 28, 2026
8
+ */
9
+ import { type AutoLinkProps } from "./AutoLink.js";
10
+ import { type TextLinkVariant } from "./linkStyles.js";
11
+ /** Every next/link prop (including `ref`), plus the link's look. */
12
+ export type TextLinkProps = AutoLinkProps & {
13
+ /** "accent" (default) for running text, "plain" for a name or title in a list or table. */
14
+ variant?: TextLinkVariant | undefined;
15
+ };
16
+ /**
17
+ * @function TextLink
18
+ * @param props {TextLinkProps} next/link props, plus an optional variant
19
+ * @returns {JSX.Element} a next/link, or a plain `<a>` for an external href, styled as a text link
20
+ */
21
+ export declare function TextLink({ variant, className, ...props }: TextLinkProps): import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * @file src/components/basics/TextLink.tsx
4
+ * @desc A text link: next/link inside the app, a plain <a> off-site (with rel="noreferrer" in a
5
+ * new tab), in the `accent` look for running text or the `plain` one for names in lists.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { AutoLink } from "./AutoLink.js";
11
+ import { linkClasses } from "./linkStyles.js";
12
+ /**
13
+ * @function TextLink
14
+ * @param props {TextLinkProps} next/link props, plus an optional variant
15
+ * @returns {JSX.Element} a next/link, or a plain `<a>` for an external href, styled as a text link
16
+ */
17
+ export function TextLink({ variant, className, ...props }) {
18
+ return _jsx(AutoLink, { className: linkClasses({ variant, className }), ...props });
19
+ }
@@ -3,9 +3,11 @@
3
3
  * @desc Shared class builder for Button and ButtonLink (osu!-web pill buttons).
4
4
  * @author David @dvhsh (https://dvh.sh)
5
5
  * @created Wed Sep 23, 2026
6
- * @modified Wed Sep 23, 2026
6
+ * @modified Mon Sep 28, 2026
7
7
  */
8
+ /** The pill's colors: `primary` is h2 (h1 on hover), `secondary` b3, `ghost` transparent. */
8
9
  export type ButtonVariant = "primary" | "secondary" | "ghost";
10
+ /** The pill's height, padding and text size: `md` is h-9 and text-sm, `lg` h-11 and text-base. */
9
11
  export type ButtonSize = "md" | "lg";
10
12
  /** Options for {@link buttonClasses}. */
11
13
  export type ButtonClassOptions = {
@@ -3,7 +3,7 @@
3
3
  * @desc Shared class builder for Button and ButtonLink (osu!-web pill buttons).
4
4
  * @author David @dvhsh (https://dvh.sh)
5
5
  * @created Wed Sep 23, 2026
6
- * @modified Wed Sep 23, 2026
6
+ * @modified Mon Sep 28, 2026
7
7
  */
8
8
  import { cx } from "../../utils/cx.js";
9
9
  const BASE = "inline-flex items-center justify-center gap-2 rounded-full font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-h1 disabled:cursor-not-allowed disabled:opacity-50";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @file src/components/basics/cardStyles.ts
3
+ * @desc The osu!-web panel look that Card and FilterPanel share: the rounded b4 surface and its
4
+ * title, plus the heading levels either one takes for that title.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Mon Sep 28, 2026
7
+ * @modified Mon Sep 28, 2026
8
+ */
9
+ /** A heading level for a component's title: `h2` to `h6` (a page has one `h1`). */
10
+ export type HeadingLevel = 2 | 3 | 4 | 5 | 6;
11
+ /** The panel: rounded, b4 background, p-5, c2 text. */
12
+ export declare const CARD = "rounded-[10px] bg-b4 p-5 text-c2";
13
+ /** The panel's title: bold c1 at text-lg. */
14
+ export declare const CARD_HEADING = "font-bold text-c1 text-lg";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file src/components/basics/cardStyles.ts
3
+ * @desc The osu!-web panel look that Card and FilterPanel share: the rounded b4 surface and its
4
+ * title, plus the heading levels either one takes for that title.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Mon Sep 28, 2026
7
+ * @modified Mon Sep 28, 2026
8
+ */
9
+ /** The panel: rounded, b4 background, p-5, c2 text. */
10
+ export const CARD = "rounded-[10px] bg-b4 p-5 text-c2";
11
+ /** The panel's title: bold c1 at text-lg. */
12
+ export const CARD_HEADING = "font-bold text-c1 text-lg";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file src/components/basics/linkStyles.ts
3
+ * @desc Class builder for text links, shared by TextLink and any element that should look like
4
+ * one: `accent` for links in running text (h1, underlined, like Prose), `plain` for names
5
+ * and titles in lists and tables (bold c1, underlined on hover).
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ /** A text link's look: `accent` in running text, `plain` for a name or title in a list. */
11
+ export type TextLinkVariant = "accent" | "plain";
12
+ /** Options for {@link linkClasses}. */
13
+ export type LinkClassOptions = {
14
+ variant?: TextLinkVariant | undefined;
15
+ className?: string | undefined;
16
+ };
17
+ /**
18
+ * @function linkClasses
19
+ * @param opts {LinkClassOptions} variant (default "accent") and extra classes
20
+ * @returns {string} the text link classes, with the caller's className appended last (it
21
+ * replaces a built-in class that sets the same property)
22
+ */
23
+ export declare const linkClasses: ({ variant, className }?: LinkClassOptions) => string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file src/components/basics/linkStyles.ts
3
+ * @desc Class builder for text links, shared by TextLink and any element that should look like
4
+ * one: `accent` for links in running text (h1, underlined, like Prose), `plain` for names
5
+ * and titles in lists and tables (bold c1, underlined on hover).
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { cx } from "../../utils/cx.js";
11
+ // The accent link is underlined at rest, so it doesn't rely on color alone (WCAG 1.4.1). A plain
12
+ // link sits where the context says it's a link (a list of names), and underlines on hover.
13
+ const VARIANTS = {
14
+ accent: "text-h1 underline underline-offset-2 transition-colors hover:text-c1",
15
+ plain: "font-bold text-c1 underline-offset-2 hover:underline",
16
+ };
17
+ /**
18
+ * @function linkClasses
19
+ * @param opts {LinkClassOptions} variant (default "accent") and extra classes
20
+ * @returns {string} the text link classes, with the caller's className appended last (it
21
+ * replaces a built-in class that sets the same property)
22
+ */
23
+ export const linkClasses = ({ variant = "accent", className } = {}) => cx(VARIANTS[variant], className);
@@ -2,24 +2,32 @@
2
2
  * @file src/components/filters/Chip.tsx
3
3
  * @desc Toggle pill (osu! beatmap listing style): a `<button>` with `aria-pressed`, pink when on.
4
4
  * Same look as the mod chips on packs.haruhime.moe. In forced-colors mode a pressed chip
5
- * takes the system highlight colors, so on and off still look different.
5
+ * takes the system highlight colors, so on and off still look different. With
6
+ * `unavailableReason` it is blocked but stays focusable (aria-disabled), and screen readers
7
+ * hear the reason as its description.
6
8
  * @author David @dvhsh (https://dvh.sh)
7
9
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
10
+ * @modified Mon Sep 28, 2026
9
11
  */
10
- import type { ComponentProps } from "react";
12
+ import { type ComponentProps, type ReactNode } from "react";
11
13
  /** Every native `<button>` prop (including `ref`), plus the pressed state and its handler. */
12
14
  export type ChipProps = Omit<ComponentProps<"button">, "aria-pressed"> & {
13
15
  /** Whether the chip is on. */
14
16
  pressed: boolean;
15
17
  /** Called with the new state when the chip is clicked (or toggled with Enter or Space). */
16
18
  onPressedChange?: ((pressed: boolean) => void) | undefined;
19
+ /**
20
+ * Why the chip can't be pressed right now (EZ with HR picked). The chip is blocked but stays
21
+ * in the tab order (aria-disabled), and the reason is its description (and its title).
22
+ */
23
+ unavailableReason?: ReactNode;
17
24
  };
18
25
  /**
19
26
  * @function Chip
20
27
  * @param props {ChipProps} native button props, plus `pressed` and an optional `onPressedChange`.
21
28
  * A caller's `onClick` runs first; calling `event.preventDefault()` in it skips the toggle.
22
29
  * Callbacks can only be passed from client code.
23
- * @returns {JSX.Element} a `<button type="button" aria-pressed>` styled as a pill
30
+ * @returns {JSX.Element} a `<button type="button" aria-pressed>` styled as a pill, and the
31
+ * screen-reader-only reason beside it when it is unavailable
24
32
  */
25
- export declare function Chip({ pressed, onPressedChange, onClick, className, type, ...props }: ChipProps): import("react").JSX.Element;
33
+ export declare function Chip({ pressed, onPressedChange, unavailableReason, onClick, className, type, title, "aria-describedby": describedBy, ...props }: ChipProps): import("react").JSX.Element;
@@ -2,28 +2,37 @@
2
2
  * @file src/components/filters/Chip.tsx
3
3
  * @desc Toggle pill (osu! beatmap listing style): a `<button>` with `aria-pressed`, pink when on.
4
4
  * Same look as the mod chips on packs.haruhime.moe. In forced-colors mode a pressed chip
5
- * takes the system highlight colors, so on and off still look different.
5
+ * takes the system highlight colors, so on and off still look different. With
6
+ * `unavailableReason` it is blocked but stays focusable (aria-disabled), and screen readers
7
+ * hear the reason as its description.
6
8
  * @author David @dvhsh (https://dvh.sh)
7
9
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
10
+ * @modified Mon Sep 28, 2026
9
11
  */
10
12
  "use client";
11
- import { jsx as _jsx } from "react/jsx-runtime";
13
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
+ import { useId } from "react";
12
15
  import { cx } from "../../utils/cx.js";
13
- const BASE = "rounded-full px-2.5 py-0.5 font-bold text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40";
14
- const PRESSED = "bg-h1 text-b6 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
15
- const UNPRESSED = "bg-b3 text-c2 not-disabled:hover:bg-b2";
16
+ import { CHIP, CHIP_OFF, CHIP_ON, CHIP_UNAVAILABLE } from "./chipStyles.js";
16
17
  /**
17
18
  * @function Chip
18
19
  * @param props {ChipProps} native button props, plus `pressed` and an optional `onPressedChange`.
19
20
  * A caller's `onClick` runs first; calling `event.preventDefault()` in it skips the toggle.
20
21
  * Callbacks can only be passed from client code.
21
- * @returns {JSX.Element} a `<button type="button" aria-pressed>` styled as a pill
22
+ * @returns {JSX.Element} a `<button type="button" aria-pressed>` styled as a pill, and the
23
+ * screen-reader-only reason beside it when it is unavailable
22
24
  */
23
- export function Chip({ pressed, onPressedChange, onClick, className, type = "button", ...props }) {
24
- return (_jsx("button", { type: type, "aria-pressed": pressed, onClick: (event) => {
25
+ export function Chip({ pressed, onPressedChange, unavailableReason, onClick, className, type = "button", title, "aria-describedby": describedBy, ...props }) {
26
+ const reasonId = useId();
27
+ const blocked = unavailableReason !== undefined && unavailableReason !== null;
28
+ const reasonTitle = blocked && typeof unavailableReason === "string" ? unavailableReason : undefined;
29
+ const chip = (_jsx("button", { type: type, "aria-pressed": pressed, "aria-disabled": blocked || undefined, "aria-describedby": blocked ? [reasonId, describedBy].filter(Boolean).join(" ") : describedBy, title: title ?? reasonTitle, onClick: (event) => {
25
30
  onClick?.(event);
26
- if (!event.defaultPrevented)
31
+ if (!event.defaultPrevented && !blocked)
27
32
  onPressedChange?.(!pressed);
28
- }, className: cx(BASE, pressed ? PRESSED : UNPRESSED, className), ...props }));
33
+ }, className: cx(CHIP, "disabled:cursor-not-allowed disabled:opacity-40", pressed ? CHIP_ON : CHIP_OFF, blocked ? CHIP_UNAVAILABLE : !pressed && "not-disabled:hover:bg-b2", className), ...props }));
34
+ if (!blocked)
35
+ return chip;
36
+ // The reason sits beside the button, not inside it, where it would join the button's name.
37
+ return (_jsxs(_Fragment, { children: [chip, _jsx("span", { id: reasonId, className: "sr-only", children: unavailableReason })] }));
29
38
  }
@@ -4,24 +4,23 @@
4
4
  * in the tab order; the group reports the picked values in the options' order.
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
7
+ * @modified Mon Sep 28, 2026
8
8
  */
9
- import { type ComponentProps, type ReactNode } from "react";
9
+ import type { ReactNode } from "react";
10
+ import { type GroupFrameProps } from "./GroupFrame.js";
10
11
  /** One chip: the value it stands for, what it shows, and whether it can be toggled. */
11
12
  export type ChipOption = {
12
13
  value: string;
13
14
  label: ReactNode;
14
15
  disabled?: boolean | undefined;
16
+ /** Why the chip can't be picked right now; it stays focusable. See Chip. */
17
+ unavailableReason?: ReactNode;
15
18
  };
16
- /** Every native `<fieldset>` prop except `onChange` and `children`, plus label, options, value. */
17
- export type ChipGroupProps = Omit<ComponentProps<"fieldset">, "onChange" | "children"> & {
18
- /** Names the group (`role="group"` + `aria-labelledby`). */
19
- label: ReactNode;
20
- /**
21
- * Leave the name to a surrounding FilterRow: no label shows, and the fieldset is not a group
22
- * of its own (role none), so screen readers hear the row's name once.
23
- */
24
- hideLabel?: boolean | undefined;
19
+ /**
20
+ * Every native `<fieldset>` prop except `onChange` and `children`, plus `label` (names the group)
21
+ * and `hideLabel` (leave the name to a surrounding FilterRow), the options and the picked values.
22
+ */
23
+ export type ChipGroupProps = Omit<GroupFrameProps, "onChange" | "children"> & {
25
24
  options: readonly ChipOption[];
26
25
  /** The picked values. */
27
26
  value: readonly string[];
@@ -36,4 +35,4 @@ export type ChipGroupProps = Omit<ComponentProps<"fieldset">, "onChange" | "chil
36
35
  * @returns {JSX.Element} a `<fieldset>` (role group) labeled by its label, one Chip per option.
37
36
  * With `hideLabel`, the fieldset has role none and no label.
38
37
  */
39
- export declare function ChipGroup({ label, hideLabel, options, value, onChange, className, ...props }: ChipGroupProps): import("react").JSX.Element;
38
+ export declare function ChipGroup({ options, value, onChange, ...props }: ChipGroupProps): import("react").JSX.Element;
@@ -4,13 +4,12 @@
4
4
  * in the tab order; the group reports the picked values in the options' order.
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
7
+ * @modified Mon Sep 28, 2026
8
8
  */
9
9
  "use client";
10
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
- import { useId } from "react";
12
- import { cx } from "../../utils/cx.js";
10
+ import { jsx as _jsx } from "react/jsx-runtime";
13
11
  import { Chip } from "./Chip.js";
12
+ import { GroupFrame } from "./GroupFrame.js";
14
13
  /**
15
14
  * @function toggleValue
16
15
  * @param value {readonly string[]} the picked values
@@ -35,10 +34,6 @@ const toggleValue = (value, option, on, options) => {
35
34
  * @returns {JSX.Element} a `<fieldset>` (role group) labeled by its label, one Chip per option.
36
35
  * With `hideLabel`, the fieldset has role none and no label.
37
36
  */
38
- export function ChipGroup({ label, hideLabel = false, options, value, onChange, className, ...props }) {
39
- const labelId = useId();
40
- return (_jsxs("fieldset", {
41
- // Inside a FilterRow (hideLabel), the row's fieldset is the group. A second group with the
42
- // same name would be read twice. The fieldset stays, so `disabled` still reaches every chip.
43
- role: hideLabel ? "none" : undefined, "aria-labelledby": hideLabel ? undefined : labelId, className: cx("flex flex-col gap-2", className), ...props, children: [hideLabel ? null : (_jsx("span", { id: labelId, className: "font-bold text-c3 text-sm", children: label })), _jsx("div", { className: "flex flex-wrap items-center gap-1", children: options.map((option) => (_jsx(Chip, { pressed: value.includes(option.value), disabled: option.disabled, onPressedChange: (on) => onChange(toggleValue(value, option.value, on, options)), children: option.label }, option.value))) })] }));
37
+ export function ChipGroup({ options, value, onChange, ...props }) {
38
+ return (_jsx(GroupFrame, { ...props, children: _jsx("div", { className: "flex flex-wrap items-center gap-1", children: options.map((option) => (_jsx(Chip, { pressed: value.includes(option.value), disabled: option.disabled, unavailableReason: option.unavailableReason, onPressedChange: (on) => onChange(toggleValue(value, option.value, on, options)), children: option.label }, option.value))) }) }));
44
39
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file src/components/filters/ChoiceChips.tsx
3
+ * @desc One choice from a few, as a real radio group drawn as chips (the pools status and type
4
+ * filters): native radio inputs under one name, so Tab reaches the checked chip, the arrow
5
+ * keys move and pick, and screen readers hear "radio, 1 of 3, checked". Same look as Chip,
6
+ * with the focus ring on the chip, not the hidden input. A labelled fieldset like ChipGroup.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import { type ReactNode } from "react";
12
+ import { type GroupFrameProps } from "./GroupFrame.js";
13
+ /** One choice: the value it stands for, what it shows, and whether it can be picked. */
14
+ export type ChoiceChipOption<T extends string = string> = {
15
+ value: T;
16
+ label: ReactNode;
17
+ disabled?: boolean | undefined;
18
+ };
19
+ /**
20
+ * Every native `<fieldset>` prop except `onChange`, `children` and `defaultValue`, plus `label`
21
+ * and `hideLabel` (as on ChipGroup), the options, the picked value and the radios' name.
22
+ */
23
+ export type ChoiceChipsProps<T extends string = string> = Omit<GroupFrameProps, "onChange" | "children" | "defaultValue"> & {
24
+ options: readonly ChoiceChipOption<T>[];
25
+ /** The picked value. */
26
+ value: T;
27
+ /** Called with the value of the chip that was picked. */
28
+ onChange: (value: T) => void;
29
+ /** The radios' name, for a form (default: a generated one). */
30
+ name?: string | undefined;
31
+ };
32
+ /**
33
+ * @function ChoiceChips
34
+ * @param props {ChoiceChipsProps} label, options, the picked value and a change handler, plus
35
+ * native fieldset props (`disabled` turns off every chip). Render it from client code.
36
+ * @returns {JSX.Element} a `<fieldset>` named by its label, with one radio chip per option
37
+ */
38
+ export declare function ChoiceChips<T extends string = string>({ options, value, onChange, name, ...props }: ChoiceChipsProps<T>): import("react").JSX.Element;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @file src/components/filters/ChoiceChips.tsx
3
+ * @desc One choice from a few, as a real radio group drawn as chips (the pools status and type
4
+ * filters): native radio inputs under one name, so Tab reaches the checked chip, the arrow
5
+ * keys move and pick, and screen readers hear "radio, 1 of 3, checked". Same look as Chip,
6
+ * with the focus ring on the chip, not the hidden input. A labelled fieldset like ChipGroup.
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 { useId } from "react";
14
+ import { cx } from "../../utils/cx.js";
15
+ import { CHIP, CHIP_OFF, CHIP_ON, CHIP_UNAVAILABLE } from "./chipStyles.js";
16
+ import { GroupFrame } from "./GroupFrame.js";
17
+ // A label around a hidden radio: it shows the input's focus and disabled state itself.
18
+ const LABEL = "cursor-pointer has-focus-visible:outline-2 has-focus-visible:outline-h1 has-focus-visible:outline-offset-2 has-disabled:cursor-not-allowed has-disabled:opacity-40";
19
+ /**
20
+ * @function ChoiceChips
21
+ * @param props {ChoiceChipsProps} label, options, the picked value and a change handler, plus
22
+ * native fieldset props (`disabled` turns off every chip). Render it from client code.
23
+ * @returns {JSX.Element} a `<fieldset>` named by its label, with one radio chip per option
24
+ */
25
+ export function ChoiceChips({ options, value, onChange, name, ...props }) {
26
+ const generated = useId();
27
+ return (_jsx(GroupFrame, { ...props, children: _jsx("div", { className: "flex flex-wrap items-center gap-1", children: options.map((option) => {
28
+ const on = option.value === value;
29
+ return (_jsxs("label", { className: cx(CHIP, LABEL, on ? CHIP_ON : CHIP_OFF, !on && !option.disabled && "hover:bg-b2", option.disabled && CHIP_UNAVAILABLE), children: [_jsx("input", { type: "radio", name: name ?? generated, value: option.value, checked: on, disabled: option.disabled, onChange: () => onChange(option.value), className: "sr-only" }), option.label] }, option.value));
30
+ }) }) }));
31
+ }
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * @file src/components/filters/FilterPanel.tsx
3
3
  * @desc Filter panel (osu! beatmap listing style): a titled card of FilterRows with a live result
4
- * count and a "Clear filters" link. On phones the rows fold away behind a disclosure button;
5
- * from `sm` up they are always shown.
4
+ * count and a "Clear filters" button. On phones the rows fold away behind a disclosure
5
+ * button; from `sm` up they are always shown.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
10
  import { type ComponentProps, type ReactNode } from "react";
11
+ import { type HeadingLevel } from "../basics/cardStyles.js";
11
12
  /** Every native `<section>` prop except `title`, plus the panel's title, count and clear action. */
12
13
  export type FilterPanelProps = Omit<ComponentProps<"section">, "title"> & {
13
14
  /** The panel heading; it also names the panel and its phone disclosure button. */
14
15
  title: ReactNode;
15
16
  /** Heading level for the title (default 2). */
16
- headingLevel?: 2 | 3 | 4 | 5 | 6 | undefined;
17
+ headingLevel?: HeadingLevel | undefined;
17
18
  /** Shown in an `<output aria-live="polite">`, so screen readers hear each new count. */
18
19
  resultCount?: ReactNode;
19
20
  /** True while any filter is set; shows the clear button. */