@haruhimemoe/ui 0.1.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 +23 -0
- package/LICENSE +21 -0
- package/README.md +561 -0
- package/dist/components/actions/CopyButton.d.ts +33 -0
- package/dist/components/actions/CopyButton.js +40 -0
- package/dist/components/actions/JsonLd.d.ts +20 -0
- package/dist/components/actions/JsonLd.js +17 -0
- package/dist/components/actions/Pagination.d.ts +33 -0
- package/dist/components/actions/Pagination.js +27 -0
- package/dist/components/actions/PaginationStatus.d.ts +19 -0
- package/dist/components/actions/PaginationStatus.js +44 -0
- package/dist/components/basics/Button.d.ts +20 -0
- package/dist/components/basics/Button.js +10 -0
- package/dist/components/basics/ButtonLink.d.ts +23 -0
- package/dist/components/basics/ButtonLink.js +26 -0
- package/dist/components/basics/Card.d.ts +19 -0
- package/dist/components/basics/Card.js +20 -0
- package/dist/components/basics/Notice.d.ts +30 -0
- package/dist/components/basics/Notice.js +22 -0
- package/dist/components/basics/PageHeader.d.ts +25 -0
- package/dist/components/basics/PageHeader.js +10 -0
- package/dist/components/basics/Prose.d.ts +18 -0
- package/dist/components/basics/Prose.js +11 -0
- package/dist/components/basics/buttonStyles.d.ts +23 -0
- package/dist/components/basics/buttonStyles.js +28 -0
- package/dist/components/filters/Chip.d.ts +25 -0
- package/dist/components/filters/Chip.js +29 -0
- package/dist/components/filters/ChipGroup.d.ts +39 -0
- package/dist/components/filters/ChipGroup.js +44 -0
- package/dist/components/filters/FilterPanel.d.ts +36 -0
- package/dist/components/filters/FilterPanel.js +45 -0
- package/dist/components/filters/FilterRow.d.ts +22 -0
- package/dist/components/filters/FilterRow.js +22 -0
- package/dist/components/filters/RangeSlider.d.ts +62 -0
- package/dist/components/filters/RangeSlider.js +161 -0
- package/dist/components/forms/Checkbox.d.ts +20 -0
- package/dist/components/forms/Checkbox.js +13 -0
- package/dist/components/forms/FieldFrame.d.ts +66 -0
- package/dist/components/forms/FieldFrame.js +44 -0
- package/dist/components/forms/Select.d.ts +19 -0
- package/dist/components/forms/Select.js +12 -0
- package/dist/components/forms/TextInput.d.ts +19 -0
- package/dist/components/forms/TextInput.js +12 -0
- package/dist/components/forms/Textarea.d.ts +19 -0
- package/dist/components/forms/Textarea.js +13 -0
- package/dist/components/forms/fieldStyles.d.ts +17 -0
- package/dist/components/forms/fieldStyles.js +19 -0
- package/dist/components/icons/GitHubIcon.d.ts +18 -0
- package/dist/components/icons/GitHubIcon.js +10 -0
- package/dist/components/icons/HaruhimeWordmark.d.ts +23 -0
- package/dist/components/icons/HaruhimeWordmark.js +16 -0
- package/dist/components/icons/HaruhimeWordmarkLink.d.ts +21 -0
- package/dist/components/icons/HaruhimeWordmarkLink.js +12 -0
- package/dist/components/shell/AutoLink.d.ts +19 -0
- package/dist/components/shell/AutoLink.js +23 -0
- package/dist/components/shell/NavLinks.d.ts +24 -0
- package/dist/components/shell/NavLinks.js +40 -0
- package/dist/components/shell/PageShell.d.ts +29 -0
- package/dist/components/shell/PageShell.js +11 -0
- package/dist/components/shell/SiteFooter.d.ts +40 -0
- package/dist/components/shell/SiteFooter.js +21 -0
- package/dist/components/shell/SiteHeader.d.ts +32 -0
- package/dist/components/shell/SiteHeader.js +16 -0
- package/dist/components/shell/links.d.ts +25 -0
- package/dist/components/shell/links.js +32 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +41 -0
- package/dist/theme.css +54 -0
- package/dist/utils/cx.d.ts +18 -0
- package/dist/utils/cx.js +17 -0
- package/dist/utils/href.d.ts +15 -0
- package/dist/utils/href.js +17 -0
- package/package.json +90 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/CopyButton.tsx
|
|
3
|
+
* @desc A button that copies text to the clipboard and reports the result in an <output> beside
|
|
4
|
+
* it (a polite live region), like the packs export and doc pages. If the clipboard is
|
|
5
|
+
* missing or refuses, it says so and tells the reader to copy by hand. Each press empties
|
|
6
|
+
* the status first and then writes the result as a new node, so a second copy is announced
|
|
7
|
+
* too.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Wed Sep 23, 2026
|
|
10
|
+
* @modified Wed Sep 23, 2026
|
|
11
|
+
*/
|
|
12
|
+
"use client";
|
|
13
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
|
+
import { useRef, useState } from "react";
|
|
15
|
+
import { cx } from "../../utils/cx.js";
|
|
16
|
+
import { Button } from "../basics/Button.js";
|
|
17
|
+
/**
|
|
18
|
+
* @function CopyButton
|
|
19
|
+
* @param props {CopyButtonProps} the text to copy, optional label and status messages, plus
|
|
20
|
+
* Button props (`variant` defaults to "secondary"; `className` styles the button itself)
|
|
21
|
+
* @returns {JSX.Element} the button and an `<output>` that announces "Copied." or the failure
|
|
22
|
+
*/
|
|
23
|
+
export function CopyButton({ text, label = "Copy", copiedMessage = "Copied.", failedMessage = "Couldn't copy. Select the text and copy it by hand.", wrapperClassName, variant = "secondary", ...props }) {
|
|
24
|
+
const [status, setStatus] = useState(null);
|
|
25
|
+
const presses = useRef(0);
|
|
26
|
+
const copy = async () => {
|
|
27
|
+
// Empty the live region, then fill it with a fresh node: a status that never changes (the
|
|
28
|
+
// same "Copied." twice) is not announced again.
|
|
29
|
+
setStatus(null);
|
|
30
|
+
const press = ++presses.current;
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(text);
|
|
33
|
+
setStatus({ result: "copied", press });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
setStatus({ result: "failed", press });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return (_jsxs("div", { className: cx("flex flex-wrap items-center gap-3", wrapperClassName), children: [_jsx(Button, { variant: variant, onClick: copy, ...props, children: label }), _jsx("output", { className: "text-c3 text-sm", children: status ? (_jsx("span", { children: status.result === "copied" ? copiedMessage : failedMessage }, status.press)) : null })] }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/JsonLd.tsx
|
|
3
|
+
* @desc schema.org structured data as a JSON-LD script tag. Every "<" is escaped, so no string
|
|
4
|
+
* in the data (a user-supplied name, say) can close the tag or open a comment.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Wed Sep 23, 2026
|
|
7
|
+
* @modified Wed Sep 23, 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { ComponentProps } from "react";
|
|
10
|
+
/** Native `<script>` props (like `id` or `nonce`), plus the schema.org data. */
|
|
11
|
+
export type JsonLdProps = Omit<ComponentProps<"script">, "children" | "dangerouslySetInnerHTML" | "type" | "src"> & {
|
|
12
|
+
/** A schema.org object. `@context` defaults to https://schema.org; set it here to override. */
|
|
13
|
+
data: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* @function JsonLd
|
|
17
|
+
* @param props {JsonLdProps} the schema.org data, plus native script props
|
|
18
|
+
* @returns {JSX.Element} a `<script type="application/ld+json">` tag with the escaped data
|
|
19
|
+
*/
|
|
20
|
+
export declare function JsonLd({ data, ...props }: JsonLdProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @function jsonLdString
|
|
4
|
+
* @param data {Record<string, unknown>} a schema.org object
|
|
5
|
+
* @returns {string} JSON with `@context` added and every "<" written as the escape `\u003c`
|
|
6
|
+
*/
|
|
7
|
+
const jsonLdString = (data) => JSON.stringify({ "@context": "https://schema.org", ...data }).replace(/</g, "\\u003c");
|
|
8
|
+
/**
|
|
9
|
+
* @function JsonLd
|
|
10
|
+
* @param props {JsonLdProps} the schema.org data, plus native script props
|
|
11
|
+
* @returns {JSX.Element} a `<script type="application/ld+json">` tag with the escaped data
|
|
12
|
+
*/
|
|
13
|
+
export function JsonLd({ data, ...props }) {
|
|
14
|
+
return (_jsx("script", { ...props, type: "application/ld+json",
|
|
15
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD must be raw JSON; jsonLdString escapes "<".
|
|
16
|
+
dangerouslySetInnerHTML: { __html: jsonLdString(data) } }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/Pagination.tsx
|
|
3
|
+
* @desc Previous / next links around "Page X of Y". Renders nothing for a single page. The
|
|
4
|
+
* caller builds each page's URL, so it works with any query string or route shape. When
|
|
5
|
+
* the link a keyboard user pressed goes away (Next on the last page), focus moves to the
|
|
6
|
+
* status text instead of the page body.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Wed Sep 23, 2026
|
|
9
|
+
* @modified Wed Sep 23, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
12
|
+
/** Every native `<nav>` prop except children, plus the page state and the link builder. */
|
|
13
|
+
export type PaginationProps = Omit<ComponentProps<"nav">, "children"> & {
|
|
14
|
+
/** The current page, 1-based. */
|
|
15
|
+
page: number;
|
|
16
|
+
/** How many pages there are. At 1 or fewer, nothing renders. */
|
|
17
|
+
pageCount: number;
|
|
18
|
+
/** Builds the URL for a page number, e.g. `(p) => \`/packs?page=${p}\``. */
|
|
19
|
+
hrefFor: (page: number) => string;
|
|
20
|
+
/** Text of the link to the page before. Defaults to "Previous". */
|
|
21
|
+
previousLabel?: ReactNode | undefined;
|
|
22
|
+
/** Text of the link to the page after. Defaults to "Next". */
|
|
23
|
+
nextLabel?: ReactNode | undefined;
|
|
24
|
+
/** The middle text. Defaults to "Page X of Y". */
|
|
25
|
+
formatStatus?: ((page: number, pageCount: number) => ReactNode) | undefined;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* @function Pagination
|
|
29
|
+
* @param props {PaginationProps} page, pageCount and hrefFor, optional labels, plus native nav
|
|
30
|
+
* props (`aria-label` defaults to "Pages")
|
|
31
|
+
* @returns {JSX.Element | null} a `<nav>` with previous / next pill links, or null for one page
|
|
32
|
+
*/
|
|
33
|
+
export declare function Pagination({ page, pageCount, hrefFor, previousLabel, nextLabel, formatStatus, className, ...props }: PaginationProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/actions/Pagination.tsx
|
|
4
|
+
* @desc Previous / next links around "Page X of Y". Renders nothing for a single page. The
|
|
5
|
+
* caller builds each page's URL, so it works with any query string or route shape. When
|
|
6
|
+
* the link a keyboard user pressed goes away (Next on the last page), focus moves to the
|
|
7
|
+
* status text instead of the page body.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Wed Sep 23, 2026
|
|
10
|
+
* @modified Wed Sep 23, 2026
|
|
11
|
+
*/
|
|
12
|
+
import Link from "next/link.js";
|
|
13
|
+
import { cx } from "../../utils/cx.js";
|
|
14
|
+
import { buttonClasses } from "../basics/buttonStyles.js";
|
|
15
|
+
import { PaginationStatus } from "./PaginationStatus.js";
|
|
16
|
+
const defaultStatus = (page, pageCount) => `Page ${page} of ${pageCount}`;
|
|
17
|
+
/**
|
|
18
|
+
* @function Pagination
|
|
19
|
+
* @param props {PaginationProps} page, pageCount and hrefFor, optional labels, plus native nav
|
|
20
|
+
* props (`aria-label` defaults to "Pages")
|
|
21
|
+
* @returns {JSX.Element | null} a `<nav>` with previous / next pill links, or null for one page
|
|
22
|
+
*/
|
|
23
|
+
export function Pagination({ page, pageCount, hrefFor, previousLabel = "Previous", nextLabel = "Next", formatStatus = defaultStatus, className, ...props }) {
|
|
24
|
+
if (pageCount <= 1)
|
|
25
|
+
return null;
|
|
26
|
+
return (_jsxs("nav", { "aria-label": "Pages", className: cx("flex items-center justify-between gap-3 text-sm", className), ...props, children: [page > 1 ? (_jsx(Link, { href: hrefFor(page - 1), rel: "prev", className: buttonClasses({ variant: "secondary" }), children: previousLabel })) : (_jsx("span", {})), _jsx(PaginationStatus, { children: formatStatus(page, pageCount) }), page < pageCount ? (_jsx(Link, { href: hrefFor(page + 1), rel: "next", className: buttonClasses({ variant: "secondary" }), children: nextLabel })) : (_jsx("span", {}))] }));
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/PaginationStatus.tsx
|
|
3
|
+
* @desc Pagination's "Page X of Y" text (internal). It also keeps keyboard focus in place: when
|
|
4
|
+
* the link that had focus goes away (Next on the last page, Previous on the first), focus
|
|
5
|
+
* moves here instead of falling back to the page body.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Wed Sep 23, 2026
|
|
8
|
+
* @modified Wed Sep 23, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactNode } from "react";
|
|
11
|
+
/**
|
|
12
|
+
* @function PaginationStatus
|
|
13
|
+
* @param props {{ children: ReactNode }} the status text
|
|
14
|
+
* @returns {JSX.Element} a `<span aria-current="page">` that script can focus (tabIndex -1), so
|
|
15
|
+
* it stays out of the tab order
|
|
16
|
+
*/
|
|
17
|
+
export declare function PaginationStatus({ children }: {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/PaginationStatus.tsx
|
|
3
|
+
* @desc Pagination's "Page X of Y" text (internal). It also keeps keyboard focus in place: when
|
|
4
|
+
* the link that had focus goes away (Next on the last page, Previous on the first), focus
|
|
5
|
+
* moves here instead of falling back to the page body.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Wed Sep 23, 2026
|
|
8
|
+
* @modified Wed Sep 23, 2026
|
|
9
|
+
*/
|
|
10
|
+
"use client";
|
|
11
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
12
|
+
import { useEffect, useRef } from "react";
|
|
13
|
+
/**
|
|
14
|
+
* @function PaginationStatus
|
|
15
|
+
* @param props {{ children: ReactNode }} the status text
|
|
16
|
+
* @returns {JSX.Element} a `<span aria-current="page">` that script can focus (tabIndex -1), so
|
|
17
|
+
* it stays out of the tab order
|
|
18
|
+
*/
|
|
19
|
+
export function PaginationStatus({ children }) {
|
|
20
|
+
const ref = useRef(null);
|
|
21
|
+
const lastFocused = useRef(null);
|
|
22
|
+
// Remember what last had focus inside the nav.
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const nav = ref.current?.parentElement;
|
|
25
|
+
if (!nav)
|
|
26
|
+
return;
|
|
27
|
+
const onFocusIn = (event) => {
|
|
28
|
+
lastFocused.current = event.target;
|
|
29
|
+
};
|
|
30
|
+
nav.addEventListener("focusin", onFocusIn);
|
|
31
|
+
return () => nav.removeEventListener("focusin", onFocusIn);
|
|
32
|
+
}, []);
|
|
33
|
+
// After each render: if that element left the page and focus fell to the body, take it here.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const last = lastFocused.current;
|
|
36
|
+
if (last === null || last.isConnected)
|
|
37
|
+
return;
|
|
38
|
+
lastFocused.current = null;
|
|
39
|
+
const active = document.activeElement;
|
|
40
|
+
if (active === null || active === document.body)
|
|
41
|
+
ref.current?.focus();
|
|
42
|
+
});
|
|
43
|
+
return (_jsx("span", { ref: ref, tabIndex: -1, "aria-current": "page", className: "text-c4", children: children }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/Button.tsx
|
|
3
|
+
* @desc Pill button primitive. Defaults to type="button" so it never submits a form by accident.
|
|
4
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
5
|
+
* @created Wed Sep 23, 2026
|
|
6
|
+
* @modified Wed Sep 23, 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { ComponentProps } from "react";
|
|
9
|
+
import { type ButtonSize, type ButtonVariant } from "./buttonStyles.js";
|
|
10
|
+
/** Every native `<button>` prop (including `ref`), plus a variant and a size. */
|
|
11
|
+
export type ButtonProps = ComponentProps<"button"> & {
|
|
12
|
+
variant?: ButtonVariant | undefined;
|
|
13
|
+
size?: ButtonSize | undefined;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* @function Button
|
|
17
|
+
* @param props {ButtonProps} native button props, plus an optional variant and size
|
|
18
|
+
* @returns {JSX.Element} a `<button>` styled as a pill
|
|
19
|
+
*/
|
|
20
|
+
export declare function Button({ variant, size, className, type, ...props }: ButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { buttonClasses } from "./buttonStyles.js";
|
|
3
|
+
/**
|
|
4
|
+
* @function Button
|
|
5
|
+
* @param props {ButtonProps} native button props, plus an optional variant and size
|
|
6
|
+
* @returns {JSX.Element} a `<button>` styled as a pill
|
|
7
|
+
*/
|
|
8
|
+
export function Button({ variant, size, className, type = "button", ...props }) {
|
|
9
|
+
return _jsx("button", { type: type, className: buttonClasses({ variant, size, className }), ...props });
|
|
10
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/ButtonLink.tsx
|
|
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.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Wed Sep 23, 2026
|
|
7
|
+
* @modified Wed Sep 23, 2026
|
|
8
|
+
*/
|
|
9
|
+
import Link from "next/link.js";
|
|
10
|
+
import type { ComponentProps } from "react";
|
|
11
|
+
import { type ButtonSize, type ButtonVariant } from "./buttonStyles.js";
|
|
12
|
+
/** Every next/link prop (including `ref`), plus a button variant and size. */
|
|
13
|
+
export type ButtonLinkProps = ComponentProps<typeof Link> & {
|
|
14
|
+
variant?: ButtonVariant | undefined;
|
|
15
|
+
size?: ButtonSize | undefined;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @function ButtonLink
|
|
19
|
+
* @param props {ButtonLinkProps} next/link props, plus an optional button variant and size
|
|
20
|
+
* @returns {JSX.Element} a next/link styled as a pill button, or a plain `<a>` for an external
|
|
21
|
+
* href (next/link's own props are dropped there)
|
|
22
|
+
*/
|
|
23
|
+
export declare function ButtonLink({ variant, size, className, href, target, rel, ...props }: ButtonLinkProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/basics/ButtonLink.tsx
|
|
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.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Wed Sep 23, 2026
|
|
8
|
+
* @modified Wed Sep 23, 2026
|
|
9
|
+
*/
|
|
10
|
+
import Link from "next/link.js";
|
|
11
|
+
import { isExternalHref } from "../../utils/href.js";
|
|
12
|
+
import { buttonClasses } from "./buttonStyles.js";
|
|
13
|
+
/**
|
|
14
|
+
* @function ButtonLink
|
|
15
|
+
* @param props {ButtonLinkProps} next/link props, plus an optional button variant and size
|
|
16
|
+
* @returns {JSX.Element} a next/link styled as a pill button, or a plain `<a>` for an external
|
|
17
|
+
* href (next/link's own props are dropped there)
|
|
18
|
+
*/
|
|
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 });
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/Card.tsx
|
|
3
|
+
* @desc osu!-web panel: rounded b4 surface, optional h2 title that labels the region. Server-safe
|
|
4
|
+
* (useId works in Server Components).
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Wed Sep 23, 2026
|
|
7
|
+
* @modified Wed Sep 23, 2026
|
|
8
|
+
*/
|
|
9
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
10
|
+
/** Every native `<section>` prop (including `ref`), with `title` rendered as the card's h2. */
|
|
11
|
+
export type CardProps = Omit<ComponentProps<"section">, "title"> & {
|
|
12
|
+
title?: ReactNode | undefined;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* @function Card
|
|
16
|
+
* @param props {CardProps} an optional title that labels the region, plus native section props
|
|
17
|
+
* @returns {JSX.Element} a rounded panel, labelled by its title when one is given
|
|
18
|
+
*/
|
|
19
|
+
export declare function Card({ title, className, children, ...props }: CardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/basics/Card.tsx
|
|
4
|
+
* @desc osu!-web panel: rounded b4 surface, optional h2 title that labels the region. Server-safe
|
|
5
|
+
* (useId works in Server Components).
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Wed Sep 23, 2026
|
|
8
|
+
* @modified Wed Sep 23, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { useId } from "react";
|
|
11
|
+
import { cx } from "../../utils/cx.js";
|
|
12
|
+
/**
|
|
13
|
+
* @function Card
|
|
14
|
+
* @param props {CardProps} an optional title that labels the region, plus native section props
|
|
15
|
+
* @returns {JSX.Element} a rounded panel, labelled by its title when one is given
|
|
16
|
+
*/
|
|
17
|
+
export function Card({ title, className, children, ...props }) {
|
|
18
|
+
const headingId = useId();
|
|
19
|
+
return (_jsxs("section", { "aria-labelledby": title ? headingId : undefined, className: cx("rounded-[10px] bg-b4 p-5 text-c2", className), ...props, children: [title ? (_jsx("h2", { id: headingId, className: "mb-2 font-bold text-c1 text-lg", children: title })) : null, children] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/Notice.tsx
|
|
3
|
+
* @desc Short status text in one of three tones (info, warning, error). Silent to screen readers
|
|
4
|
+
* unless `live` is set: then errors get role="alert" and the other tones role="status". A
|
|
5
|
+
* live info or warning notice should stay mounted with its children changing, since a
|
|
6
|
+
* status region that mounts with its text already inside may not be announced.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Wed Sep 23, 2026
|
|
9
|
+
* @modified Wed Sep 23, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { ComponentProps } from "react";
|
|
12
|
+
export type NoticeTone = "info" | "warning" | "error";
|
|
13
|
+
/** Every native `<p>` prop (including `ref`), plus a tone, a live flag and the element to render. */
|
|
14
|
+
export type NoticeProps = ComponentProps<"p"> & {
|
|
15
|
+
tone?: NoticeTone | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Make it a live region: role="alert" for errors, role="status" otherwise. Keep a live info or
|
|
18
|
+
* warning notice mounted and change its children (empty when idle) so each message is read.
|
|
19
|
+
*/
|
|
20
|
+
live?: boolean | undefined;
|
|
21
|
+
/** Render a `<div>` instead of a `<p>` when the notice holds block content like a list. */
|
|
22
|
+
as?: "p" | "div" | undefined;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* @function Notice
|
|
26
|
+
* @param props {NoticeProps} native paragraph props, plus tone (default "info"), live (default
|
|
27
|
+
* false) and as (default "p")
|
|
28
|
+
* @returns {JSX.Element} a small toned `<p>` (or `<div>`), announced only when live
|
|
29
|
+
*/
|
|
30
|
+
export declare function Notice({ tone, live, as, className, ...props }: NoticeProps): 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 TONES = {
|
|
4
|
+
info: "text-c3",
|
|
5
|
+
warning: "text-amber-300",
|
|
6
|
+
error: "text-rose-300",
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* @function Notice
|
|
10
|
+
* @param props {NoticeProps} native paragraph props, plus tone (default "info"), live (default
|
|
11
|
+
* false) and as (default "p")
|
|
12
|
+
* @returns {JSX.Element} a small toned `<p>` (or `<div>`), announced only when live
|
|
13
|
+
*/
|
|
14
|
+
export function Notice({ tone = "info", live = false, as = "p", className, ...props }) {
|
|
15
|
+
const role = live ? (tone === "error" ? "alert" : "status") : undefined;
|
|
16
|
+
const classes = cx("text-sm", TONES[tone], className);
|
|
17
|
+
if (as === "div") {
|
|
18
|
+
// Same attributes either way; only the ref's element type differs.
|
|
19
|
+
return _jsx("div", { role: role, className: classes, ...props });
|
|
20
|
+
}
|
|
21
|
+
return _jsx("p", { role: role, className: classes, ...props });
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/PageHeader.tsx
|
|
3
|
+
* @desc The one way to title a page: h1 at the shared scale, optional lead, meta line, actions.
|
|
4
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
5
|
+
* @created Wed Sep 23, 2026
|
|
6
|
+
* @modified Wed Sep 23, 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
9
|
+
/** Native `<div>` props (including `ref`) for the wrapper, plus the header's slots. */
|
|
10
|
+
export type PageHeaderProps = Omit<ComponentProps<"div">, "title"> & {
|
|
11
|
+
/** The page's h1. */
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
/** A sentence under the title. Rendered in a `<p>`, so keep it inline content. */
|
|
14
|
+
lead?: ReactNode | undefined;
|
|
15
|
+
/** A small muted line under the lead (dates, counts, owner). Also a `<p>`. */
|
|
16
|
+
meta?: ReactNode | undefined;
|
|
17
|
+
/** Buttons or links shown to the right of the title, wrapping below it on narrow screens. */
|
|
18
|
+
actions?: ReactNode | undefined;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* @function PageHeader
|
|
22
|
+
* @param props {PageHeaderProps} the page's title, and optional lead text, meta line, and actions
|
|
23
|
+
* @returns {JSX.Element} the page's one h1, with its optional lead, meta line and actions
|
|
24
|
+
*/
|
|
25
|
+
export declare function PageHeader({ title, lead, meta, actions, className, ...props }: PageHeaderProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
/**
|
|
4
|
+
* @function PageHeader
|
|
5
|
+
* @param props {PageHeaderProps} the page's title, and optional lead text, meta line, and actions
|
|
6
|
+
* @returns {JSX.Element} the page's one h1, with its optional lead, meta line and actions
|
|
7
|
+
*/
|
|
8
|
+
export function PageHeader({ title, lead, meta, actions, className, ...props }) {
|
|
9
|
+
return (_jsxs("div", { className: cx("flex flex-wrap items-end justify-between gap-x-6 gap-y-4", className), ...props, children: [_jsxs("div", { className: "min-w-0 max-w-3xl", children: [_jsx("h1", { className: "wrap-anywhere font-extrabold text-3xl text-c1 tracking-tight sm:text-4xl", children: title }), lead ? _jsx("p", { className: "mt-2 max-w-2xl text-base text-c3 sm:text-lg", children: lead }) : null, meta ? _jsx("p", { className: "mt-2 text-c4 text-sm", children: meta }) : null] }), actions ? _jsx("div", { className: "flex flex-wrap items-center gap-2", children: actions }) : null] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/Prose.tsx
|
|
3
|
+
* @desc Long-form typography for MDX, docs and legal text, styled with the osu!-web tokens.
|
|
4
|
+
* Styles the plain elements inside it (headings, links, lists, code, tables).
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Wed Sep 23, 2026
|
|
7
|
+
* @modified Wed Sep 23, 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { ComponentProps } from "react";
|
|
10
|
+
/** Every native `<div>` prop (including `ref`). */
|
|
11
|
+
export type ProseProps = ComponentProps<"div">;
|
|
12
|
+
/**
|
|
13
|
+
* @function Prose
|
|
14
|
+
* @param props {ProseProps} native div props; children are the rendered Markdown or MDX
|
|
15
|
+
* @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
|
|
16
|
+
* tables inside it
|
|
17
|
+
*/
|
|
18
|
+
export declare function Prose({ className, ...props }: ProseProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
/**
|
|
4
|
+
* @function Prose
|
|
5
|
+
* @param props {ProseProps} native div props; children are the rendered Markdown or MDX
|
|
6
|
+
* @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
|
|
7
|
+
* tables inside it
|
|
8
|
+
*/
|
|
9
|
+
export function Prose({ className, ...props }) {
|
|
10
|
+
return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre]:mt-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-b6 [&_pre]:p-3 [&_pre]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/buttonStyles.ts
|
|
3
|
+
* @desc Shared class builder for Button and ButtonLink (osu!-web pill buttons).
|
|
4
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
5
|
+
* @created Wed Sep 23, 2026
|
|
6
|
+
* @modified Wed Sep 23, 2026
|
|
7
|
+
*/
|
|
8
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost";
|
|
9
|
+
export type ButtonSize = "md" | "lg";
|
|
10
|
+
/** Options for {@link buttonClasses}. */
|
|
11
|
+
export type ButtonClassOptions = {
|
|
12
|
+
variant?: ButtonVariant | undefined;
|
|
13
|
+
size?: ButtonSize | undefined;
|
|
14
|
+
className?: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* @function buttonClasses
|
|
18
|
+
* @param opts {ButtonClassOptions} variant (default "primary"), size (default "md") and extra
|
|
19
|
+
* classes
|
|
20
|
+
* @returns {string} the pill button 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 buttonClasses: ({ variant, size, className, }?: ButtonClassOptions) => string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/basics/buttonStyles.ts
|
|
3
|
+
* @desc Shared class builder for Button and ButtonLink (osu!-web pill buttons).
|
|
4
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
5
|
+
* @created Wed Sep 23, 2026
|
|
6
|
+
* @modified Wed Sep 23, 2026
|
|
7
|
+
*/
|
|
8
|
+
import { cx } from "../../utils/cx.js";
|
|
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";
|
|
10
|
+
// Hover only when not disabled. `not-disabled:` (not `enabled:`) so links, which are never
|
|
11
|
+
// :disabled, keep their hover colors.
|
|
12
|
+
const VARIANTS = {
|
|
13
|
+
primary: "bg-h2 text-c1 not-disabled:hover:bg-h1 not-disabled:hover:text-b6",
|
|
14
|
+
secondary: "bg-b3 text-c1 not-disabled:hover:bg-b2",
|
|
15
|
+
ghost: "bg-transparent text-c2 not-disabled:hover:bg-b4 not-disabled:hover:text-c1",
|
|
16
|
+
};
|
|
17
|
+
const SIZES = {
|
|
18
|
+
md: "h-9 px-4 text-sm",
|
|
19
|
+
lg: "h-11 px-6 text-base",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* @function buttonClasses
|
|
23
|
+
* @param opts {ButtonClassOptions} variant (default "primary"), size (default "md") and extra
|
|
24
|
+
* classes
|
|
25
|
+
* @returns {string} the pill button classes, with the caller's className appended last (it
|
|
26
|
+
* replaces a built-in class that sets the same property)
|
|
27
|
+
*/
|
|
28
|
+
export const buttonClasses = ({ variant = "primary", size = "md", className, } = {}) => cx(BASE, VARIANTS[variant], SIZES[size], className);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/Chip.tsx
|
|
3
|
+
* @desc Toggle pill (osu! beatmap listing style): a `<button>` with `aria-pressed`, pink when on.
|
|
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.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Wed Sep 23, 2026
|
|
8
|
+
* @modified Wed Sep 23, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps } from "react";
|
|
11
|
+
/** Every native `<button>` prop (including `ref`), plus the pressed state and its handler. */
|
|
12
|
+
export type ChipProps = Omit<ComponentProps<"button">, "aria-pressed"> & {
|
|
13
|
+
/** Whether the chip is on. */
|
|
14
|
+
pressed: boolean;
|
|
15
|
+
/** Called with the new state when the chip is clicked (or toggled with Enter or Space). */
|
|
16
|
+
onPressedChange?: ((pressed: boolean) => void) | undefined;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* @function Chip
|
|
20
|
+
* @param props {ChipProps} native button props, plus `pressed` and an optional `onPressedChange`.
|
|
21
|
+
* A caller's `onClick` runs first; calling `event.preventDefault()` in it skips the toggle.
|
|
22
|
+
* Callbacks can only be passed from client code.
|
|
23
|
+
* @returns {JSX.Element} a `<button type="button" aria-pressed>` styled as a pill
|
|
24
|
+
*/
|
|
25
|
+
export declare function Chip({ pressed, onPressedChange, onClick, className, type, ...props }: ChipProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/Chip.tsx
|
|
3
|
+
* @desc Toggle pill (osu! beatmap listing style): a `<button>` with `aria-pressed`, pink when on.
|
|
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.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Wed Sep 23, 2026
|
|
8
|
+
* @modified Wed Sep 23, 2026
|
|
9
|
+
*/
|
|
10
|
+
"use client";
|
|
11
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
12
|
+
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
|
+
/**
|
|
17
|
+
* @function Chip
|
|
18
|
+
* @param props {ChipProps} native button props, plus `pressed` and an optional `onPressedChange`.
|
|
19
|
+
* A caller's `onClick` runs first; calling `event.preventDefault()` in it skips the toggle.
|
|
20
|
+
* Callbacks can only be passed from client code.
|
|
21
|
+
* @returns {JSX.Element} a `<button type="button" aria-pressed>` styled as a pill
|
|
22
|
+
*/
|
|
23
|
+
export function Chip({ pressed, onPressedChange, onClick, className, type = "button", ...props }) {
|
|
24
|
+
return (_jsx("button", { type: type, "aria-pressed": pressed, onClick: (event) => {
|
|
25
|
+
onClick?.(event);
|
|
26
|
+
if (!event.defaultPrevented)
|
|
27
|
+
onPressedChange?.(!pressed);
|
|
28
|
+
}, className: cx(BASE, pressed ? PRESSED : UNPRESSED, className), ...props }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/ChipGroup.tsx
|
|
3
|
+
* @desc Labeled multi-select row of Chips (mods, game modes). Each chip is its own toggle button
|
|
4
|
+
* in the tab order; the group reports the picked values in the options' order.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Wed Sep 23, 2026
|
|
7
|
+
* @modified Wed Sep 23, 2026
|
|
8
|
+
*/
|
|
9
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
10
|
+
/** One chip: the value it stands for, what it shows, and whether it can be toggled. */
|
|
11
|
+
export type ChipOption = {
|
|
12
|
+
value: string;
|
|
13
|
+
label: ReactNode;
|
|
14
|
+
disabled?: boolean | undefined;
|
|
15
|
+
};
|
|
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;
|
|
25
|
+
options: readonly ChipOption[];
|
|
26
|
+
/** The picked values. */
|
|
27
|
+
value: readonly string[];
|
|
28
|
+
/** Called with the new list of picked values, in the options' order. */
|
|
29
|
+
onChange: (value: string[]) => void;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* @function ChipGroup
|
|
33
|
+
* @param props {ChipGroupProps} label, options, picked values and a change handler, plus native
|
|
34
|
+
* fieldset props (`disabled` turns off every chip). `onChange` is a function, so render
|
|
35
|
+
* this from client code.
|
|
36
|
+
* @returns {JSX.Element} a `<fieldset>` (role group) labeled by its label, one Chip per option.
|
|
37
|
+
* With `hideLabel`, the fieldset has role none and no label.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ChipGroup({ label, hideLabel, options, value, onChange, className, ...props }: ChipGroupProps): import("react").JSX.Element;
|