@haruhimemoe/ui 0.3.0 → 0.5.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 +52 -1
- package/README.md +336 -27
- package/dist/components/actions/AsyncButton.d.ts +30 -0
- package/dist/components/actions/AsyncButton.js +48 -0
- package/dist/components/actions/CopyButton.d.ts +3 -3
- package/dist/components/actions/CopyButton.js +9 -12
- package/dist/components/actions/InlineConfirm.d.ts +45 -0
- package/dist/components/actions/InlineConfirm.js +80 -0
- package/dist/components/actions/Pagination.d.ts +40 -17
- package/dist/components/actions/Pagination.js +25 -18
- package/dist/components/actions/PaginationButton.d.ts +25 -0
- package/dist/components/actions/PaginationButton.js +23 -0
- package/dist/components/actions/PaginationStatus.d.ts +7 -4
- package/dist/components/actions/PaginationStatus.js +7 -5
- package/dist/components/actions/StatusOutput.d.ts +20 -0
- package/dist/components/actions/StatusOutput.js +9 -0
- package/dist/components/actions/pages.d.ts +21 -0
- package/dist/components/actions/pages.js +21 -0
- package/dist/components/actions/useLatestStatus.d.ts +26 -0
- package/dist/components/actions/useLatestStatus.js +32 -0
- package/dist/components/basics/AutoLink.d.ts +21 -0
- package/dist/components/basics/AutoLink.js +26 -0
- package/dist/components/basics/Badge.d.ts +22 -0
- package/dist/components/basics/Badge.js +18 -0
- package/dist/components/basics/ButtonLink.d.ts +5 -6
- package/dist/components/basics/ButtonLink.js +5 -11
- package/dist/components/basics/Card.d.ts +4 -3
- package/dist/components/basics/Card.js +3 -2
- package/dist/components/basics/Disclosure.d.ts +34 -0
- package/dist/components/basics/Disclosure.js +30 -0
- package/dist/components/basics/Notice.d.ts +2 -1
- package/dist/components/basics/Tabs.d.ts +37 -0
- package/dist/components/basics/Tabs.js +47 -0
- package/dist/components/basics/TextLink.d.ts +21 -0
- package/dist/components/basics/TextLink.js +19 -0
- package/dist/components/basics/buttonStyles.d.ts +3 -1
- package/dist/components/basics/buttonStyles.js +1 -1
- package/dist/components/basics/cardStyles.d.ts +14 -0
- package/dist/components/basics/cardStyles.js +12 -0
- package/dist/components/basics/linkStyles.d.ts +23 -0
- package/dist/components/basics/linkStyles.js +23 -0
- package/dist/components/basics/tabIds.d.ts +23 -0
- package/dist/components/basics/tabIds.js +23 -0
- package/dist/components/filters/Chip.d.ts +13 -5
- package/dist/components/filters/Chip.js +20 -11
- package/dist/components/filters/ChipGroup.d.ts +11 -12
- package/dist/components/filters/ChipGroup.js +5 -10
- package/dist/components/filters/ChoiceChips.d.ts +38 -0
- package/dist/components/filters/ChoiceChips.js +31 -0
- package/dist/components/filters/FilterPanel.d.ts +5 -4
- package/dist/components/filters/FilterPanel.js +5 -4
- package/dist/components/filters/FilterRow.d.ts +1 -1
- package/dist/components/filters/FilterRow.js +3 -2
- package/dist/components/filters/GroupFrame.d.ts +27 -0
- package/dist/components/filters/GroupFrame.js +27 -0
- package/dist/components/filters/RangeBox.d.ts +28 -0
- package/dist/components/filters/RangeBox.js +41 -0
- package/dist/components/filters/RangeSlider.d.ts +13 -14
- package/dist/components/filters/RangeSlider.js +40 -103
- package/dist/components/filters/chipStyles.d.ts +17 -0
- package/dist/components/filters/chipStyles.js +17 -0
- package/dist/components/filters/rangeMath.d.ts +106 -0
- package/dist/components/filters/rangeMath.js +146 -0
- package/dist/components/forms/CharCounter.d.ts +25 -0
- package/dist/components/forms/CharCounter.js +12 -0
- package/dist/components/forms/Checkbox.d.ts +5 -4
- package/dist/components/forms/Checkbox.js +3 -3
- package/dist/components/forms/FieldFrame.d.ts +28 -2
- package/dist/components/forms/FieldFrame.js +14 -1
- package/dist/components/forms/RadioGroup.d.ts +46 -0
- package/dist/components/forms/RadioGroup.js +32 -0
- package/dist/components/forms/ReportDisclosure.d.ts +52 -0
- package/dist/components/forms/ReportDisclosure.js +54 -0
- package/dist/components/forms/Select.d.ts +1 -1
- package/dist/components/forms/Select.js +2 -2
- package/dist/components/forms/TextInput.d.ts +1 -1
- package/dist/components/forms/TextInput.js +2 -2
- package/dist/components/forms/Textarea.d.ts +1 -1
- package/dist/components/forms/Textarea.js +2 -2
- package/dist/components/forms/TypeToConfirm.d.ts +40 -0
- package/dist/components/forms/TypeToConfirm.js +46 -0
- package/dist/components/forms/VisibilitySelect.d.ts +52 -0
- package/dist/components/forms/VisibilitySelect.js +45 -0
- package/dist/components/forms/fieldStyles.d.ts +5 -2
- package/dist/components/forms/fieldStyles.js +5 -2
- package/dist/components/{actions → meta}/JsonLd.d.ts +2 -2
- package/dist/components/osu/BeatmapStats.d.ts +36 -0
- package/dist/components/osu/BeatmapStats.js +44 -0
- package/dist/components/osu/ModBadge.d.ts +23 -0
- package/dist/components/osu/ModBadge.js +22 -0
- package/dist/components/osu/StarRating.d.ts +26 -0
- package/dist/components/osu/StarRating.js +17 -0
- package/dist/components/osu/starColors.d.ts +23 -0
- package/dist/components/osu/starColors.js +48 -0
- package/dist/components/shell/HeaderMenu.d.ts +39 -0
- package/dist/components/shell/HeaderMenu.js +58 -0
- package/dist/components/shell/LinkNote.d.ts +16 -0
- package/dist/components/shell/LinkNote.js +17 -0
- package/dist/components/shell/LinkTabs.d.ts +29 -0
- package/dist/components/shell/LinkTabs.js +15 -0
- package/dist/components/shell/NavItem.d.ts +1 -7
- package/dist/components/shell/NavItem.js +5 -10
- package/dist/components/shell/NavLinks.d.ts +1 -1
- package/dist/components/shell/NavLinks.js +3 -3
- package/dist/components/shell/NavListClient.d.ts +1 -1
- package/dist/components/shell/NavListClient.js +4 -4
- package/dist/components/shell/SiteFooter.d.ts +6 -4
- package/dist/components/shell/SiteFooter.js +8 -6
- package/dist/components/shell/links.d.ts +8 -1
- package/dist/components/shell/links.js +8 -1
- package/dist/components/tables/TBody.d.ts +16 -0
- package/dist/components/tables/TBody.js +10 -0
- package/dist/components/tables/THead.d.ts +16 -0
- package/dist/components/tables/THead.js +10 -0
- package/dist/components/tables/Table.d.ts +26 -0
- package/dist/components/tables/Table.js +12 -0
- package/dist/components/tables/Td.d.ts +19 -0
- package/dist/components/tables/Td.js +11 -0
- package/dist/components/tables/Th.d.ts +20 -0
- package/dist/components/tables/Th.js +11 -0
- package/dist/components/tables/tableStyles.d.ts +12 -0
- package/dist/components/tables/tableStyles.js +12 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +32 -2
- package/dist/utils/href.d.ts +7 -4
- package/dist/utils/href.js +23 -7
- package/package.json +3 -3
- package/dist/components/shell/AutoLink.d.ts +0 -19
- package/dist/components/shell/AutoLink.js +0 -23
- /package/dist/components/{actions → meta}/JsonLd.js +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/RadioGroup.tsx
|
|
3
|
+
* @desc A native radio group on the Checkbox look: a fieldset named by its legend, one radio per
|
|
4
|
+
* option with a bold label and an inline hint, then the group's hint and error. Each
|
|
5
|
+
* option's label is its accessible name and its hint the description; the group's hint and
|
|
6
|
+
* error describe the fieldset, and an error marks it invalid. Arrow keys move and pick, as
|
|
7
|
+
* native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
13
|
+
/** One radio: its value, its label, an optional inline hint, and whether it can be picked. */
|
|
14
|
+
export type RadioOption = {
|
|
15
|
+
value: string;
|
|
16
|
+
label: ReactNode;
|
|
17
|
+
hint?: ReactNode;
|
|
18
|
+
disabled?: boolean | undefined;
|
|
19
|
+
};
|
|
20
|
+
/** Every native `<fieldset>` prop except `onChange` and `children`, plus the group's parts. */
|
|
21
|
+
export type RadioGroupProps = Omit<ComponentProps<"fieldset">, "onChange" | "children" | "defaultValue"> & {
|
|
22
|
+
/** The legend, which names the group. */
|
|
23
|
+
label: ReactNode;
|
|
24
|
+
options: readonly RadioOption[];
|
|
25
|
+
/** The radios' name, for a form (default: a generated one). */
|
|
26
|
+
name?: string | undefined;
|
|
27
|
+
/** The picked value, when the caller holds it. */
|
|
28
|
+
value?: string | undefined;
|
|
29
|
+
/** The value picked at first, when the group holds it. */
|
|
30
|
+
defaultValue?: string | undefined;
|
|
31
|
+
/** Called with the picked option's value. */
|
|
32
|
+
onChange?: ((value: string) => void) | undefined;
|
|
33
|
+
/** Help text under the options, linked to the group with aria-describedby. */
|
|
34
|
+
hint?: ReactNode;
|
|
35
|
+
/** Error text under the options (a list is fine). Marks the group invalid and links it. */
|
|
36
|
+
error?: ReactNode;
|
|
37
|
+
/** Every radio gets `required`, so a form won't submit without a pick. */
|
|
38
|
+
required?: boolean | undefined;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* @function RadioGroup
|
|
42
|
+
* @param props {RadioGroupProps} the legend, options, value or defaultValue, a change handler,
|
|
43
|
+
* the group's hint and error, plus native fieldset props (`disabled` turns off every radio)
|
|
44
|
+
* @returns {JSX.Element} a `<fieldset>` with its `<legend>`, the radios, and the hint and error
|
|
45
|
+
*/
|
|
46
|
+
export declare function RadioGroup({ label, options, name, value, defaultValue, onChange, hint, error, required, className, "aria-describedby": describedBy, ...props }: RadioGroupProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/RadioGroup.tsx
|
|
3
|
+
* @desc A native radio group on the Checkbox look: a fieldset named by its legend, one radio per
|
|
4
|
+
* option with a bold label and an inline hint, then the group's hint and error. Each
|
|
5
|
+
* option's label is its accessible name and its hint the description; the group's hint and
|
|
6
|
+
* error describe the fieldset, and an error marks it invalid. Arrow keys move and pick, as
|
|
7
|
+
* native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Mon Sep 28, 2026
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
|
+
*/
|
|
12
|
+
"use client";
|
|
13
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
|
+
import { useId } from "react";
|
|
15
|
+
import { cx } from "../../utils/cx.js";
|
|
16
|
+
import { FieldError, fieldDescribedBy, hintId } from "./FieldFrame.js";
|
|
17
|
+
import { FIELD_LABEL } from "./fieldStyles.js";
|
|
18
|
+
/**
|
|
19
|
+
* @function RadioGroup
|
|
20
|
+
* @param props {RadioGroupProps} the legend, options, value or defaultValue, a change handler,
|
|
21
|
+
* the group's hint and error, plus native fieldset props (`disabled` turns off every radio)
|
|
22
|
+
* @returns {JSX.Element} a `<fieldset>` with its `<legend>`, the radios, and the hint and error
|
|
23
|
+
*/
|
|
24
|
+
export function RadioGroup({ label, options, name, value, defaultValue, onChange, hint, error, required, className, "aria-describedby": describedBy, ...props }) {
|
|
25
|
+
const id = useId();
|
|
26
|
+
return (_jsxs("fieldset", { "aria-describedby": fieldDescribedBy(id, hint, error, describedBy), className: cx("flex flex-col gap-2", className), ...props, children: [_jsx("legend", { className: cx("mb-1", FIELD_LABEL), children: label }), options.map((option, i) => {
|
|
27
|
+
const optionId = `${id}-${i}`;
|
|
28
|
+
return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "radio", id: optionId, name: name ?? id, value: option.value, ...(value === undefined
|
|
29
|
+
? { defaultChecked: option.value === defaultValue }
|
|
30
|
+
: { checked: option.value === value }), disabled: option.disabled, required: required, "aria-invalid": error ? true : undefined, "aria-labelledby": `${optionId}-label`, "aria-describedby": option.hint ? hintId(optionId) : undefined, onChange: () => onChange?.(option.value), className: "mt-1 accent-h1" }), _jsxs("span", { children: [_jsx("span", { id: `${optionId}-label`, className: "font-bold text-c1", children: option.label }), option.hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(optionId), children: option.hint })] })) : null] })] }, option.value));
|
|
31
|
+
}), hint ? (_jsx("div", { id: hintId(id), className: "text-c4 text-xs", children: hint })) : null, _jsx(FieldError, { id: id, error: error })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/ReportDisclosure.tsx
|
|
3
|
+
* @desc "Report this": a Disclosure holding a reason field and a submit button. The caller sends
|
|
4
|
+
* the reason (`onSubmit`) and says how it went: done (the form goes away and the page says
|
|
5
|
+
* so, thanks or "already reported") or an error (shown on the field, for a retry). One send
|
|
6
|
+
* at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
|
|
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
|
+
/** What `onSubmit` says: done (with what to say) or not (with the error). */
|
|
13
|
+
export type ReportResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
message?: ReactNode;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
message: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
/** ReportDisclosure's props. */
|
|
21
|
+
export type ReportDisclosureProps = {
|
|
22
|
+
/** Sends the trimmed reason. */
|
|
23
|
+
onSubmit: (reason: string) => Promise<ReportResult>;
|
|
24
|
+
/** The disclosure button's text (default "Report"). */
|
|
25
|
+
summary?: ReactNode;
|
|
26
|
+
/** The reason field's label (default "What's wrong with it?"). */
|
|
27
|
+
label?: ReactNode;
|
|
28
|
+
/** Help under the field. */
|
|
29
|
+
hint?: ReactNode;
|
|
30
|
+
/** The submit button's text (default "Send report"). */
|
|
31
|
+
submitLabel?: ReactNode;
|
|
32
|
+
/** The submit button's text while sending (default "Sending…"). */
|
|
33
|
+
pendingLabel?: ReactNode;
|
|
34
|
+
/** Said once sent, when the result has no message (default "Thanks. Your report was sent."). */
|
|
35
|
+
sentMessage?: ReactNode;
|
|
36
|
+
/** Said when `onSubmit` throws (default "Couldn't send the report. Try again."). */
|
|
37
|
+
failedMessage?: ReactNode;
|
|
38
|
+
/** The reason's shortest length (default 3). */
|
|
39
|
+
minLength?: number | undefined;
|
|
40
|
+
/** The reason's longest length. */
|
|
41
|
+
maxLength?: number | undefined;
|
|
42
|
+
/** The field's height in rows (default 3). */
|
|
43
|
+
rows?: number | undefined;
|
|
44
|
+
/** Classes for the wrapper. */
|
|
45
|
+
className?: string | undefined;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* @function ReportDisclosure
|
|
49
|
+
* @param props {ReportDisclosureProps} the send callback, the words and the reason's limits
|
|
50
|
+
* @returns {JSX.Element} the disclosure with its form, or the status once sent
|
|
51
|
+
*/
|
|
52
|
+
export declare function ReportDisclosure({ onSubmit, summary, label, hint, submitLabel, pendingLabel, sentMessage, failedMessage, minLength, maxLength, rows, className, }: ReportDisclosureProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/ReportDisclosure.tsx
|
|
3
|
+
* @desc "Report this": a Disclosure holding a reason field and a submit button. The caller sends
|
|
4
|
+
* the reason (`onSubmit`) and says how it went: done (the form goes away and the page says
|
|
5
|
+
* so, thanks or "already reported") or an error (shown on the field, for a retry). One send
|
|
6
|
+
* at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
|
|
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, useRef, useState } from "react";
|
|
14
|
+
import { cx } from "../../utils/cx.js";
|
|
15
|
+
import { Button } from "../basics/Button.js";
|
|
16
|
+
import { Disclosure } from "../basics/Disclosure.js";
|
|
17
|
+
import { Textarea } from "./Textarea.js";
|
|
18
|
+
/**
|
|
19
|
+
* @function ReportDisclosure
|
|
20
|
+
* @param props {ReportDisclosureProps} the send callback, the words and the reason's limits
|
|
21
|
+
* @returns {JSX.Element} the disclosure with its form, or the status once sent
|
|
22
|
+
*/
|
|
23
|
+
export function ReportDisclosure({ onSubmit, summary = "Report", label = "What's wrong with it?", hint, submitLabel = "Send report", pendingLabel = "Sending…", sentMessage = "Thanks. Your report was sent.", failedMessage = "Couldn't send the report. Try again.", minLength = 3, maxLength, rows = 3, className, }) {
|
|
24
|
+
const id = useId();
|
|
25
|
+
const [reason, setReason] = useState("");
|
|
26
|
+
const [pending, setPending] = useState(false);
|
|
27
|
+
const [error, setError] = useState(null);
|
|
28
|
+
const [done, setDone] = useState(null);
|
|
29
|
+
const running = useRef(false);
|
|
30
|
+
if (done !== null) {
|
|
31
|
+
return (_jsx("p", { role: "status", className: cx("text-c2 text-sm", className), children: done }));
|
|
32
|
+
}
|
|
33
|
+
const send = async (event) => {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
if (running.current)
|
|
36
|
+
return;
|
|
37
|
+
running.current = true;
|
|
38
|
+
setPending(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
const result = await onSubmit(reason.trim());
|
|
42
|
+
if (result.ok)
|
|
43
|
+
setDone(result.message ?? sentMessage);
|
|
44
|
+
else
|
|
45
|
+
setError(result.message);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
setError(failedMessage);
|
|
49
|
+
}
|
|
50
|
+
running.current = false;
|
|
51
|
+
setPending(false);
|
|
52
|
+
};
|
|
53
|
+
return (_jsx(Disclosure, { summary: summary, className: className, children: _jsxs("form", { className: "flex flex-col gap-2", onSubmit: send, children: [_jsx(Textarea, { id: `${id}-reason`, label: label, hint: hint, error: error ?? undefined, value: reason, rows: rows, minLength: minLength, maxLength: maxLength, required: true, onChange: (event) => setReason(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: "secondary", disabled: pending, className: "self-start", children: pending ? pendingLabel : submitLabel })] }) }));
|
|
54
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* through aria-describedby and aria-invalid. Options come in as children. Server-safe.
|
|
5
5
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
6
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
8
|
*/
|
|
9
9
|
import type { ComponentProps } from "react";
|
|
10
10
|
import { type FieldProps } from "./FieldFrame.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { FieldFrame,
|
|
2
|
+
import { FieldFrame, fieldControlProps } from "./FieldFrame.js";
|
|
3
3
|
import { fieldClasses } from "./fieldStyles.js";
|
|
4
4
|
/**
|
|
5
5
|
* @function Select
|
|
@@ -8,5 +8,5 @@ import { fieldClasses } from "./fieldStyles.js";
|
|
|
8
8
|
* @returns {JSX.Element} a label, the select, and the hint and error when given
|
|
9
9
|
*/
|
|
10
10
|
export function Select({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
|
|
11
|
-
return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("select", { ...props,
|
|
11
|
+
return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("select", { ...props, ...fieldControlProps({ id, hint, error, describedBy, invalid }), className: fieldClasses(className) }) }));
|
|
12
12
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* aria-describedby and aria-invalid. Server-safe: the required id names the hint and error.
|
|
5
5
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
6
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
8
|
*/
|
|
9
9
|
import type { ComponentProps } from "react";
|
|
10
10
|
import { type FieldProps } from "./FieldFrame.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { FieldFrame,
|
|
2
|
+
import { FieldFrame, fieldControlProps } from "./FieldFrame.js";
|
|
3
3
|
import { fieldClasses } from "./fieldStyles.js";
|
|
4
4
|
/**
|
|
5
5
|
* @function TextInput
|
|
@@ -8,5 +8,5 @@ import { fieldClasses } from "./fieldStyles.js";
|
|
|
8
8
|
* @returns {JSX.Element} a label, the input, and the hint and error when given
|
|
9
9
|
*/
|
|
10
10
|
export function TextInput({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
|
|
11
|
-
return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("input", { ...props,
|
|
11
|
+
return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("input", { ...props, ...fieldControlProps({ id, hint, error, describedBy, invalid }), className: fieldClasses(className) }) }));
|
|
12
12
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* an optional hint and error wired through aria-describedby and aria-invalid. Server-safe.
|
|
5
5
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
6
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
8
|
*/
|
|
9
9
|
import type { ComponentProps } from "react";
|
|
10
10
|
import { type FieldProps } from "./FieldFrame.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
-
import { FieldFrame,
|
|
3
|
+
import { FieldFrame, fieldControlProps } from "./FieldFrame.js";
|
|
4
4
|
import { fieldClasses } from "./fieldStyles.js";
|
|
5
5
|
/**
|
|
6
6
|
* @function Textarea
|
|
@@ -9,5 +9,5 @@ import { fieldClasses } from "./fieldStyles.js";
|
|
|
9
9
|
* @returns {JSX.Element} a label, the textarea, and the hint and error when given
|
|
10
10
|
*/
|
|
11
11
|
export function Textarea({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
|
|
12
|
-
return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("textarea", { ...props,
|
|
12
|
+
return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("textarea", { ...props, ...fieldControlProps({ id, hint, error, describedBy, invalid }), className: fieldClasses(cx("min-h-24 resize-y", className)) }) }));
|
|
13
13
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/TypeToConfirm.tsx
|
|
3
|
+
* @desc A confirm for something that can't be undone (delete a pool, hand it to someone else):
|
|
4
|
+
* a form whose submit button stays off until the expected name is typed exactly, with no
|
|
5
|
+
* autocomplete or spellcheck in the way. Enter submits once it matches. The pools
|
|
6
|
+
* delete and transfer forms, as one component.
|
|
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
|
+
import type { ButtonVariant } from "../basics/buttonStyles.js";
|
|
13
|
+
/** Every native `<form>` prop except `onSubmit`, plus the name to type and the action. */
|
|
14
|
+
export type TypeToConfirmProps = Omit<ComponentProps<"form">, "onSubmit"> & {
|
|
15
|
+
/** The text field's id; its hint and error get `<id>-hint` and `<id>-error`. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** What has to be typed, exactly (spaces around it don't count). */
|
|
18
|
+
expected: string;
|
|
19
|
+
/** The field's label. Default "Type <expected> to confirm". */
|
|
20
|
+
label?: ReactNode;
|
|
21
|
+
/** Help text under the field. */
|
|
22
|
+
hint?: ReactNode;
|
|
23
|
+
/** Error text under the field, e.g. when the action failed. */
|
|
24
|
+
error?: ReactNode;
|
|
25
|
+
/** The submit button's text, e.g. "Delete this pool". */
|
|
26
|
+
submitLabel: ReactNode;
|
|
27
|
+
/** The submit button's text while `onConfirm` runs. Defaults to `submitLabel`. */
|
|
28
|
+
pendingLabel?: ReactNode;
|
|
29
|
+
/** Runs on submit once the text matches. */
|
|
30
|
+
onConfirm: () => void | Promise<void>;
|
|
31
|
+
/** The submit button's look (default "secondary"). */
|
|
32
|
+
variant?: ButtonVariant | undefined;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* @function TypeToConfirm
|
|
36
|
+
* @param props {TypeToConfirmProps} the id, the expected text, the labels, the action, and native
|
|
37
|
+
* form props; children show above the field (what the action does)
|
|
38
|
+
* @returns {JSX.Element} a `<form>` with the children, the text field and the submit button
|
|
39
|
+
*/
|
|
40
|
+
export declare function TypeToConfirm({ id, expected, label, hint, error, submitLabel, pendingLabel, onConfirm, variant, className, children, ...props }: TypeToConfirmProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/TypeToConfirm.tsx
|
|
3
|
+
* @desc A confirm for something that can't be undone (delete a pool, hand it to someone else):
|
|
4
|
+
* a form whose submit button stays off until the expected name is typed exactly, with no
|
|
5
|
+
* autocomplete or spellcheck in the way. Enter submits once it matches. The pools
|
|
6
|
+
* delete and transfer forms, as one component.
|
|
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 { useRef, useState } from "react";
|
|
14
|
+
import { cx } from "../../utils/cx.js";
|
|
15
|
+
import { Button } from "../basics/Button.js";
|
|
16
|
+
import { TextInput } from "./TextInput.js";
|
|
17
|
+
/**
|
|
18
|
+
* @function TypeToConfirm
|
|
19
|
+
* @param props {TypeToConfirmProps} the id, the expected text, the labels, the action, and native
|
|
20
|
+
* form props; children show above the field (what the action does)
|
|
21
|
+
* @returns {JSX.Element} a `<form>` with the children, the text field and the submit button
|
|
22
|
+
*/
|
|
23
|
+
export function TypeToConfirm({ id, expected, label = `Type ${expected} to confirm`, hint, error, submitLabel, pendingLabel, onConfirm, variant = "secondary", className, children, ...props }) {
|
|
24
|
+
const [typed, setTyped] = useState("");
|
|
25
|
+
const [pending, setPending] = useState(false);
|
|
26
|
+
const running = useRef(false);
|
|
27
|
+
const matches = typed.trim() === expected.trim();
|
|
28
|
+
const submit = async (event) => {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
if (!matches || running.current)
|
|
31
|
+
return;
|
|
32
|
+
running.current = true;
|
|
33
|
+
setPending(true);
|
|
34
|
+
try {
|
|
35
|
+
await onConfirm();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// The caller says what went wrong through `error`; the form stays as typed for a retry.
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
running.current = false;
|
|
42
|
+
setPending(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return (_jsxs("form", { onSubmit: submit, className: cx("flex flex-col gap-3", className), ...props, children: [children, _jsx(TextInput, { id: id, label: label, hint: hint, error: error, value: typed, autoComplete: "off", autoCapitalize: "off", spellCheck: false, onChange: (event) => setTyped(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: variant, className: "self-start", disabled: !matches || pending, children: pending && pendingLabel !== undefined ? pendingLabel : submitLabel })] }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/VisibilitySelect.tsx
|
|
3
|
+
* @desc Who can see something: private, unlisted or public, each with a line saying who that is.
|
|
4
|
+
* As radios (RadioGroup, every option's line shown) or as a native select (Select, the
|
|
5
|
+
* picked option's line as the hint). Controlled. The pools pool editor and the bb template
|
|
6
|
+
* forms, as one component; the words come from props, with generic defaults.
|
|
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
|
+
/** The three visibilities, from most to least closed. */
|
|
13
|
+
export declare const VISIBILITIES: readonly ["private", "unlisted", "public"];
|
|
14
|
+
/** One of VISIBILITIES. */
|
|
15
|
+
export type Visibility = (typeof VISIBILITIES)[number];
|
|
16
|
+
/** A visibility's name and the line that says who sees it. */
|
|
17
|
+
export type VisibilityText = {
|
|
18
|
+
label: string;
|
|
19
|
+
hint?: ReactNode;
|
|
20
|
+
};
|
|
21
|
+
/** The default words, generic English. */
|
|
22
|
+
export declare const VISIBILITY_TEXT: Readonly<Record<Visibility, VisibilityText>>;
|
|
23
|
+
/** VisibilitySelect's props. */
|
|
24
|
+
export type VisibilitySelectProps = {
|
|
25
|
+
/** The picked visibility. */
|
|
26
|
+
value: Visibility;
|
|
27
|
+
/** Called with the new visibility. */
|
|
28
|
+
onChange: (value: Visibility) => void;
|
|
29
|
+
/** The group's legend or the select's label (default "Who can see it"). */
|
|
30
|
+
label?: ReactNode;
|
|
31
|
+
/** "radio" (default) shows every option's line; "select" is a dropdown. */
|
|
32
|
+
as?: "radio" | "select" | undefined;
|
|
33
|
+
/** Words per visibility, merged over VISIBILITY_TEXT. */
|
|
34
|
+
text?: Partial<Record<Visibility, Partial<VisibilityText>>> | undefined;
|
|
35
|
+
/** The select's id, or the radios' name (default: generated). */
|
|
36
|
+
id?: string | undefined;
|
|
37
|
+
/** Help under the radios; for a select, replaces the picked option's line. */
|
|
38
|
+
hint?: ReactNode;
|
|
39
|
+
/** Error text; marks the field invalid. */
|
|
40
|
+
error?: ReactNode;
|
|
41
|
+
/** Turns the field off. */
|
|
42
|
+
disabled?: boolean | undefined;
|
|
43
|
+
/** Classes for the fieldset, or the select's wrapper. */
|
|
44
|
+
className?: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* @function VisibilitySelect
|
|
48
|
+
* @param props {VisibilitySelectProps} the value, the handler, the look, the words, id, hint,
|
|
49
|
+
* error and disabled
|
|
50
|
+
* @returns {JSX.Element} a RadioGroup of the three, or a Select with the picked one's line
|
|
51
|
+
*/
|
|
52
|
+
export declare function VisibilitySelect({ value, onChange, label, as, text, id, hint, error, disabled, className, }: VisibilitySelectProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/VisibilitySelect.tsx
|
|
3
|
+
* @desc Who can see something: private, unlisted or public, each with a line saying who that is.
|
|
4
|
+
* As radios (RadioGroup, every option's line shown) or as a native select (Select, the
|
|
5
|
+
* picked option's line as the hint). Controlled. The pools pool editor and the bb template
|
|
6
|
+
* forms, as one component; the words come from props, with generic defaults.
|
|
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 } from "react/jsx-runtime";
|
|
13
|
+
import { useId } from "react";
|
|
14
|
+
import { RadioGroup } from "./RadioGroup.js";
|
|
15
|
+
import { Select } from "./Select.js";
|
|
16
|
+
/** The three visibilities, from most to least closed. */
|
|
17
|
+
export const VISIBILITIES = ["private", "unlisted", "public"];
|
|
18
|
+
/** The default words, generic English. */
|
|
19
|
+
export const VISIBILITY_TEXT = {
|
|
20
|
+
private: { label: "Private", hint: "Only you can see it." },
|
|
21
|
+
unlisted: { label: "Unlisted", hint: "Anyone with the link can see it. It isn't listed." },
|
|
22
|
+
public: { label: "Public", hint: "Anyone can see it, and it's listed." },
|
|
23
|
+
};
|
|
24
|
+
const isVisibility = (value) => VISIBILITIES.includes(value);
|
|
25
|
+
/**
|
|
26
|
+
* @function VisibilitySelect
|
|
27
|
+
* @param props {VisibilitySelectProps} the value, the handler, the look, the words, id, hint,
|
|
28
|
+
* error and disabled
|
|
29
|
+
* @returns {JSX.Element} a RadioGroup of the three, or a Select with the picked one's line
|
|
30
|
+
*/
|
|
31
|
+
export function VisibilitySelect({ value, onChange, label = "Who can see it", as = "radio", text, id, hint, error, disabled, className, }) {
|
|
32
|
+
const generated = useId();
|
|
33
|
+
const words = (visibility) => ({
|
|
34
|
+
...VISIBILITY_TEXT[visibility],
|
|
35
|
+
...text?.[visibility],
|
|
36
|
+
});
|
|
37
|
+
const pick = (next) => {
|
|
38
|
+
if (isVisibility(next))
|
|
39
|
+
onChange(next);
|
|
40
|
+
};
|
|
41
|
+
if (as === "select") {
|
|
42
|
+
return (_jsx(Select, { id: id ?? generated, label: label, hint: hint ?? words(value).hint, error: error, value: value, disabled: disabled, wrapperClassName: className, onChange: (event) => pick(event.currentTarget.value), children: VISIBILITIES.map((visibility) => (_jsx("option", { value: visibility, children: words(visibility).label }, visibility))) }));
|
|
43
|
+
}
|
|
44
|
+
return (_jsx(RadioGroup, { label: label, name: id, options: VISIBILITIES.map((visibility) => ({ value: visibility, ...words(visibility) })), value: value, onChange: pick, hint: hint, error: error, disabled: disabled, className: className }));
|
|
45
|
+
}
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
* @desc Shared classes for inputs, selects, and textareas (osu!-web dark fields). Focus shows as
|
|
4
4
|
* an h1 border. On an invalid field it also gets an h1 ring, since rose to pink alone is
|
|
5
5
|
* hard to see. `outline-hidden` (not `outline-none`) leaves a transparent outline that
|
|
6
|
-
* forced-colors mode paints, so focus shows there too.
|
|
6
|
+
* forced-colors mode paints, so focus shows there too. Also the one label look that fields,
|
|
7
|
+
* groups and filter rows share.
|
|
7
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
9
|
* @created Wed Sep 23, 2026
|
|
9
|
-
* @modified
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
10
11
|
*/
|
|
12
|
+
/** A field or group label: bold c3 at text-sm (internal). */
|
|
13
|
+
export declare const FIELD_LABEL = "font-bold text-c3 text-sm";
|
|
11
14
|
/**
|
|
12
15
|
* @function fieldClasses
|
|
13
16
|
* @param className {string} extra classes, appended last. One that sets the same property as a
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
* @desc Shared classes for inputs, selects, and textareas (osu!-web dark fields). Focus shows as
|
|
4
4
|
* an h1 border. On an invalid field it also gets an h1 ring, since rose to pink alone is
|
|
5
5
|
* hard to see. `outline-hidden` (not `outline-none`) leaves a transparent outline that
|
|
6
|
-
* forced-colors mode paints, so focus shows there too.
|
|
6
|
+
* forced-colors mode paints, so focus shows there too. Also the one label look that fields,
|
|
7
|
+
* groups and filter rows share.
|
|
7
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
9
|
* @created Wed Sep 23, 2026
|
|
9
|
-
* @modified
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
10
11
|
*/
|
|
11
12
|
import { cx } from "../../utils/cx.js";
|
|
12
13
|
const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 focus-visible:outline-hidden disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1 aria-invalid:focus-visible:ring-1 aria-invalid:focus-visible:ring-h1";
|
|
14
|
+
/** A field or group label: bold c3 at text-sm (internal). */
|
|
15
|
+
export const FIELD_LABEL = "font-bold text-c3 text-sm";
|
|
13
16
|
/**
|
|
14
17
|
* @function fieldClasses
|
|
15
18
|
* @param className {string} extra classes, appended last. One that sets the same property as a
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file src/components/
|
|
2
|
+
* @file src/components/meta/JsonLd.tsx
|
|
3
3
|
* @desc schema.org structured data as a JSON-LD script tag. Every "<" is escaped, so no string
|
|
4
4
|
* in the data (a user-supplied name, say) can close the tag or open a comment.
|
|
5
5
|
* @author David @dvhsh (https://dvh.sh)
|
|
6
6
|
* @created Wed Sep 23, 2026
|
|
7
|
-
* @modified
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
8
|
*/
|
|
9
9
|
import type { ComponentProps } from "react";
|
|
10
10
|
/** Native `<script>` props (like `id` or `nonce`), plus the schema.org data. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/BeatmapStats.tsx
|
|
3
|
+
* @desc A beatmap's CS / AR / OD / HP / BPM / length as a compact description list (the packs
|
|
4
|
+
* and pools map rows). Takes plain numbers, so it needs no osu! types; a stat left out or
|
|
5
|
+
* not finite doesn't show. The short names are `<abbr>`s with the full name as a title.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
11
|
+
/** The stats BeatmapStats shows, in order. */
|
|
12
|
+
export type BeatmapStatKey = "cs" | "ar" | "od" | "hp" | "bpm" | "length";
|
|
13
|
+
/** Every native `<dl>` prop except children, plus the numbers and optional labels. */
|
|
14
|
+
export type BeatmapStatsProps = Omit<ComponentProps<"dl">, "children"> & {
|
|
15
|
+
/** Circle size. */
|
|
16
|
+
cs?: number | null | undefined;
|
|
17
|
+
/** Approach rate. */
|
|
18
|
+
ar?: number | null | undefined;
|
|
19
|
+
/** Overall difficulty. */
|
|
20
|
+
od?: number | null | undefined;
|
|
21
|
+
/** HP drain. */
|
|
22
|
+
hp?: number | null | undefined;
|
|
23
|
+
/** Beats per minute, shown whole. */
|
|
24
|
+
bpm?: number | null | undefined;
|
|
25
|
+
/** Drain length in seconds, shown as m:ss (h:mm:ss from an hour). */
|
|
26
|
+
lengthSeconds?: number | null | undefined;
|
|
27
|
+
/** Replaces a stat's label (to translate it, say). */
|
|
28
|
+
labels?: Partial<Record<BeatmapStatKey, ReactNode>> | undefined;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* @function BeatmapStats
|
|
32
|
+
* @param props {BeatmapStatsProps} the stats as numbers, optional labels, and native dl props
|
|
33
|
+
* @returns {JSX.Element} a wrapping `<dl>` of label and value pairs, in CS, AR, OD, HP, BPM,
|
|
34
|
+
* length order
|
|
35
|
+
*/
|
|
36
|
+
export declare function BeatmapStats({ cs, ar, od, hp, bpm, lengthSeconds, labels, className, ...props }: BeatmapStatsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
const abbr = (short, full) => _jsx("abbr", { title: full, children: short });
|
|
4
|
+
const LABELS = {
|
|
5
|
+
cs: abbr("CS", "Circle size"),
|
|
6
|
+
ar: abbr("AR", "Approach rate"),
|
|
7
|
+
od: abbr("OD", "Overall difficulty"),
|
|
8
|
+
hp: abbr("HP", "HP drain"),
|
|
9
|
+
bpm: abbr("BPM", "Beats per minute"),
|
|
10
|
+
length: "Length",
|
|
11
|
+
};
|
|
12
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
13
|
+
/**
|
|
14
|
+
* @function formatLength
|
|
15
|
+
* @param seconds {number} a length in seconds
|
|
16
|
+
* @returns {string} "m:ss", or "h:mm:ss" from an hour up
|
|
17
|
+
*/
|
|
18
|
+
const formatLength = (seconds) => {
|
|
19
|
+
const total = Math.round(seconds);
|
|
20
|
+
const hours = Math.floor(total / 3600);
|
|
21
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
22
|
+
return hours > 0
|
|
23
|
+
? `${hours}:${pad(minutes)}:${pad(total % 60)}`
|
|
24
|
+
: `${minutes}:${pad(total % 60)}`;
|
|
25
|
+
};
|
|
26
|
+
// One decimal at most, with float noise gone (9.300000001 is 9.3).
|
|
27
|
+
const stat = (n) => String(Math.round(n * 10) / 10);
|
|
28
|
+
/**
|
|
29
|
+
* @function BeatmapStats
|
|
30
|
+
* @param props {BeatmapStatsProps} the stats as numbers, optional labels, and native dl props
|
|
31
|
+
* @returns {JSX.Element} a wrapping `<dl>` of label and value pairs, in CS, AR, OD, HP, BPM,
|
|
32
|
+
* length order
|
|
33
|
+
*/
|
|
34
|
+
export function BeatmapStats({ cs, ar, od, hp, bpm, lengthSeconds, labels, className, ...props }) {
|
|
35
|
+
const values = [
|
|
36
|
+
["cs", cs, stat],
|
|
37
|
+
["ar", ar, stat],
|
|
38
|
+
["od", od, stat],
|
|
39
|
+
["hp", hp, stat],
|
|
40
|
+
["bpm", bpm, (n) => String(Math.round(n))],
|
|
41
|
+
["length", lengthSeconds, formatLength],
|
|
42
|
+
];
|
|
43
|
+
return (_jsx("dl", { className: cx("flex flex-wrap gap-x-3 gap-y-1 text-xs", className), ...props, children: values.map(([key, value, format]) => typeof value === "number" && Number.isFinite(value) ? (_jsxs("div", { className: "flex gap-1", children: [_jsx("dt", { className: "text-c4", children: labels?.[key] ?? LABELS[key] }), _jsx("dd", { className: "font-bold text-c1 tabular-nums", children: format(value) })] }, key)) : null) }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/osu/ModBadge.tsx
|
|
3
|
+
* @desc A mod pool slot's pill (NM1, HD2, TB), colored by its mod bucket the way tournament
|
|
4
|
+
* sheets and packs.haruhime.moe color them: NM sky, HD amber, HR rose, DT and NC violet,
|
|
5
|
+
* FM emerald, TB orange. Anything else is a quiet b3 pill; `className` recolors it (a
|
|
6
|
+
* custom bucket). Server-safe.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { ComponentProps } from "react";
|
|
12
|
+
/** Every native `<span>` prop (including `ref`), plus the slot or mod it shows. */
|
|
13
|
+
export type ModBadgeProps = ComponentProps<"span"> & {
|
|
14
|
+
/** A mod or slot label ("HD", "NM1", "TB"). Its first two letters pick the color. */
|
|
15
|
+
mod: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @function ModBadge
|
|
19
|
+
* @param props {ModBadgeProps} the mod or slot label, and native span props (children replace
|
|
20
|
+
* the shown text; a `title` can name the bucket)
|
|
21
|
+
* @returns {JSX.Element} a rounded pill in the bucket's color with dark text, or b3 with c2 text
|
|
22
|
+
*/
|
|
23
|
+
export declare function ModBadge({ mod, className, children, ...props }: ModBadgeProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
const BUCKETS = {
|
|
4
|
+
NM: "bg-sky-400",
|
|
5
|
+
HD: "bg-amber-300",
|
|
6
|
+
HR: "bg-rose-400",
|
|
7
|
+
DT: "bg-violet-400",
|
|
8
|
+
NC: "bg-violet-400",
|
|
9
|
+
FM: "bg-emerald-400",
|
|
10
|
+
TB: "bg-orange-400",
|
|
11
|
+
};
|
|
12
|
+
const BASE = "inline-flex min-w-11 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 font-extrabold text-xs";
|
|
13
|
+
/**
|
|
14
|
+
* @function ModBadge
|
|
15
|
+
* @param props {ModBadgeProps} the mod or slot label, and native span props (children replace
|
|
16
|
+
* the shown text; a `title` can name the bucket)
|
|
17
|
+
* @returns {JSX.Element} a rounded pill in the bucket's color with dark text, or b3 with c2 text
|
|
18
|
+
*/
|
|
19
|
+
export function ModBadge({ mod, className, children, ...props }) {
|
|
20
|
+
const color = BUCKETS[mod.slice(0, 2).toUpperCase()];
|
|
21
|
+
return (_jsx("span", { className: cx(BASE, color ? `${color} text-b6` : "bg-b3 text-c2", className), ...props, children: children ?? mod }));
|
|
22
|
+
}
|