@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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
8
|
*/
|
|
9
|
-
import
|
|
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
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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({
|
|
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
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
8
|
*/
|
|
9
9
|
"use client";
|
|
10
|
-
import { jsx as _jsx
|
|
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({
|
|
39
|
-
|
|
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"
|
|
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
|
|
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?:
|
|
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. */
|
|
@@ -1,16 +1,17 @@
|
|
|
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"
|
|
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
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
9
|
*/
|
|
10
10
|
"use client";
|
|
11
11
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
12
12
|
import { useEffect, useId, useRef, useState } from "react";
|
|
13
13
|
import { cx } from "../../utils/cx.js";
|
|
14
|
+
import { CARD, CARD_HEADING } from "../basics/cardStyles.js";
|
|
14
15
|
/**
|
|
15
16
|
* @function FilterPanel
|
|
16
17
|
* @param props {FilterPanelProps} title, FilterRows as children, an optional result count and
|
|
@@ -38,7 +39,7 @@ export function FilterPanel({ title, headingLevel = 2, resultCount, active = fal
|
|
|
38
39
|
cleared.current = false;
|
|
39
40
|
}, [showClear]);
|
|
40
41
|
const Heading = `h${headingLevel}`;
|
|
41
|
-
return (_jsxs("section", { "aria-labelledby": titleId, className: cx("flex flex-col gap-4
|
|
42
|
+
return (_jsxs("section", { "aria-labelledby": titleId, className: cx("flex flex-col gap-4", CARD, className), ...props, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Heading, { id: titleId, ref: headingRef, tabIndex: -1, className: CARD_HEADING, children: title }), _jsx("button", { type: "button", "aria-labelledby": titleId, "aria-expanded": open, "aria-controls": bodyId, onClick: () => setOpen(!open), className: "inline-flex size-8 items-center justify-center rounded-full text-c2 transition-colors hover:bg-b3 hover:text-c1 sm:hidden", children: _jsx("span", { "aria-hidden": "true", children: open ? "▴" : "▾" }) })] }), _jsx("output", { "aria-live": "polite", className: "text-c3 text-sm", children: resultCount }), showClear ? (_jsx("button", { type: "button", onClick: () => {
|
|
42
43
|
cleared.current = true;
|
|
43
44
|
onClear();
|
|
44
45
|
}, className: "ml-auto font-bold text-h1 text-sm transition-colors hover:text-c1", children: clearLabel })) : null] }), _jsx("div", { id: bodyId, className: cx("flex-col gap-3", open ? "flex" : "hidden sm:flex"), children: children })] }));
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* the left and the controls on the right from `sm` up, stacked on phones.
|
|
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, type ReactNode } from "react";
|
|
10
10
|
/** Every native `<fieldset>` prop (including `ref`), plus the row's label. */
|
|
@@ -5,10 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
* the left and the controls on the right from `sm` up, stacked on phones.
|
|
6
6
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
7
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
9
|
*/
|
|
10
10
|
import { useId } from "react";
|
|
11
11
|
import { cx } from "../../utils/cx.js";
|
|
12
|
+
import { FIELD_LABEL } from "../forms/fieldStyles.js";
|
|
12
13
|
/**
|
|
13
14
|
* @function FilterRow
|
|
14
15
|
* @param props {FilterRowProps} the label, the row's controls as children, and native fieldset
|
|
@@ -18,5 +19,5 @@ import { cx } from "../../utils/cx.js";
|
|
|
18
19
|
*/
|
|
19
20
|
export function FilterRow({ label, className, children, ...props }) {
|
|
20
21
|
const labelId = useId();
|
|
21
|
-
return (_jsxs("fieldset", { "aria-labelledby": labelId, className: cx("flex flex-col gap-2 sm:flex-row sm:items-baseline sm:gap-4", className), ...props, children: [_jsx("span", { id: labelId, className:
|
|
22
|
+
return (_jsxs("fieldset", { "aria-labelledby": labelId, className: cx("flex flex-col gap-2 sm:flex-row sm:items-baseline sm:gap-4", className), ...props, children: [_jsx("span", { id: labelId, className: `shrink-0 sm:w-28 ${FIELD_LABEL}`, children: label }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
|
|
22
23
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/GroupFrame.tsx
|
|
3
|
+
* @desc The labelled fieldset that ChipGroup, ChoiceChips and RangeSlider share (internal): a
|
|
4
|
+
* `<fieldset>` named by a bold label, or, with `hideLabel`, a bare fieldset with role none
|
|
5
|
+
* that leaves the name to a surrounding FilterRow. Server-safe.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
11
|
+
/** Every native `<fieldset>` prop, plus the group's label and whether a FilterRow names it. */
|
|
12
|
+
export type GroupFrameProps = ComponentProps<"fieldset"> & {
|
|
13
|
+
/** Names the group (`aria-labelledby`), shown above the controls. */
|
|
14
|
+
label: ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* Leave the name to a surrounding FilterRow: no label shows, and the fieldset is not a group
|
|
17
|
+
* of its own (role none), so screen readers hear the row's name once.
|
|
18
|
+
*/
|
|
19
|
+
hideLabel?: boolean | undefined;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* @function GroupFrame
|
|
23
|
+
* @param props {GroupFrameProps} the label, hideLabel, the controls as children, and native
|
|
24
|
+
* fieldset props (`className` merges last)
|
|
25
|
+
* @returns {JSX.Element} a `<fieldset>` labelled by its label, or with role none and no label
|
|
26
|
+
*/
|
|
27
|
+
export declare function GroupFrame({ label, hideLabel, className, children, ...props }: GroupFrameProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @file src/components/filters/GroupFrame.tsx
|
|
4
|
+
* @desc The labelled fieldset that ChipGroup, ChoiceChips and RangeSlider share (internal): a
|
|
5
|
+
* `<fieldset>` named by a bold label, or, with `hideLabel`, a bare fieldset with role none
|
|
6
|
+
* that leaves the name to a surrounding FilterRow. Server-safe.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
import { useId } from "react";
|
|
12
|
+
import { cx } from "../../utils/cx.js";
|
|
13
|
+
import { FIELD_LABEL } from "../forms/fieldStyles.js";
|
|
14
|
+
/**
|
|
15
|
+
* @function GroupFrame
|
|
16
|
+
* @param props {GroupFrameProps} the label, hideLabel, the controls as children, and native
|
|
17
|
+
* fieldset props (`className` merges last)
|
|
18
|
+
* @returns {JSX.Element} a `<fieldset>` labelled by its label, or with role none and no label
|
|
19
|
+
*/
|
|
20
|
+
export function GroupFrame({ label, hideLabel = false, className, children, ...props }) {
|
|
21
|
+
const labelId = useId();
|
|
22
|
+
return (_jsxs("fieldset", {
|
|
23
|
+
// Inside a FilterRow (hideLabel), the row's fieldset is the group. A second group with the
|
|
24
|
+
// same name would be read twice. The fieldset stays, so `disabled` still reaches every
|
|
25
|
+
// control inside it.
|
|
26
|
+
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: FIELD_LABEL, children: label })), children] }));
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/RangeBox.tsx
|
|
3
|
+
* @desc One of RangeSlider's two edit boxes (internal): a short text field on the shared field
|
|
4
|
+
* look that holds what is being typed, commits it on blur or Enter, and puts the value
|
|
5
|
+
* back on Escape.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import { type HTMLAttributes } from "react";
|
|
11
|
+
/** The box's name, the value it shows, its keyboard and state, and what to do with typing. */
|
|
12
|
+
export type RangeBoxProps = {
|
|
13
|
+
/** The accessible name ("Minimum Star rating"). */
|
|
14
|
+
label: string;
|
|
15
|
+
/** The current value as display text, shown while nothing is being typed. */
|
|
16
|
+
text: string;
|
|
17
|
+
inputMode: HTMLAttributes<HTMLInputElement>["inputMode"];
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
/** Gets the typed text on blur or Enter, when there is any. */
|
|
20
|
+
onCommit: (draft: string) => void;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* @function RangeBox
|
|
24
|
+
* @param props {RangeBoxProps} the name, the shown text, the input mode, disabled, and the
|
|
25
|
+
* commit handler
|
|
26
|
+
* @returns {JSX.Element} a text `<input>` that shows the value, or the typing in progress
|
|
27
|
+
*/
|
|
28
|
+
export declare function RangeBox({ label, text, inputMode, disabled, onCommit }: RangeBoxProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/RangeBox.tsx
|
|
3
|
+
* @desc One of RangeSlider's two edit boxes (internal): a short text field on the shared field
|
|
4
|
+
* look that holds what is being typed, commits it on blur or Enter, and puts the value
|
|
5
|
+
* back on Escape.
|
|
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 } from "react/jsx-runtime";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { fieldClasses } from "../forms/fieldStyles.js";
|
|
14
|
+
// The field look, sized for a short value. The slider's group dims itself when disabled, so the
|
|
15
|
+
// box keeps full opacity instead of dimming twice.
|
|
16
|
+
const BOX = fieldClasses("w-18 shrink-0 px-2 py-1 text-center tabular-nums disabled:cursor-not-allowed disabled:opacity-100");
|
|
17
|
+
/**
|
|
18
|
+
* @function RangeBox
|
|
19
|
+
* @param props {RangeBoxProps} the name, the shown text, the input mode, disabled, and the
|
|
20
|
+
* commit handler
|
|
21
|
+
* @returns {JSX.Element} a text `<input>` that shows the value, or the typing in progress
|
|
22
|
+
*/
|
|
23
|
+
export function RangeBox({ label, text, inputMode, disabled, onCommit }) {
|
|
24
|
+
const [draft, setDraft] = useState(null);
|
|
25
|
+
const commit = () => {
|
|
26
|
+
if (draft === null)
|
|
27
|
+
return;
|
|
28
|
+
setDraft(null);
|
|
29
|
+
onCommit(draft);
|
|
30
|
+
};
|
|
31
|
+
return (_jsx("input", { type: "text", inputMode: inputMode, autoComplete: "off", "aria-label": label, disabled: disabled, value: draft ?? text, onChange: (event) => setDraft(event.currentTarget.value), onBlur: commit, onKeyDown: (event) => {
|
|
32
|
+
if (event.key === "Enter") {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
commit();
|
|
35
|
+
}
|
|
36
|
+
else if (event.key === "Escape" && draft !== null) {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
setDraft(null);
|
|
39
|
+
}
|
|
40
|
+
}, className: BOX }));
|
|
41
|
+
}
|
|
@@ -3,26 +3,25 @@
|
|
|
3
3
|
* @desc Two-thumb range slider with an editable box at each end (star rating, length, BPM). Two
|
|
4
4
|
* native range inputs share one track; the thumbs can't cross. With `openEnded`, the top
|
|
5
5
|
* end at `max` means "no upper limit": it shows "max+" and reports `null`. When both thumbs
|
|
6
|
-
* sit on one value, a drag moves whichever end can go the way the pointer goes.
|
|
6
|
+
* sit on one value, a drag moves whichever end can go the way the pointer goes. The math
|
|
7
|
+
* lives in rangeMath.ts and the boxes in RangeBox.tsx.
|
|
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
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
import type { HTMLAttributes } from "react";
|
|
13
|
+
import { type GroupFrameProps } from "./GroupFrame.js";
|
|
14
|
+
import { type RangeSliderValue } from "./rangeMath.js";
|
|
15
|
+
export type { RangeSliderValue };
|
|
16
|
+
/** Every native `<fieldset>` prop except `onChange`, `children` and `inputMode`, plus the range. */
|
|
17
|
+
export type RangeSliderProps = Omit<GroupFrameProps, "onChange" | "children" | "inputMode"> & {
|
|
16
18
|
/** Names the group, and the ends as "Minimum <label>" and "Maximum <label>". */
|
|
17
19
|
label: string;
|
|
18
|
-
/**
|
|
19
|
-
* Leave the group name to a surrounding FilterRow: no label shows, and the fieldset is not a
|
|
20
|
-
* group of its own (role none). The ends are still named from `label`.
|
|
21
|
-
*/
|
|
22
|
-
hideLabel?: boolean | undefined;
|
|
20
|
+
/** The low bound. If it is above `max`, the two swap. */
|
|
23
21
|
min: number;
|
|
22
|
+
/** The high bound. */
|
|
24
23
|
max: number;
|
|
25
|
-
/** Step between values (default 1). Typed values snap to it. */
|
|
24
|
+
/** Step between values (default 1; 0, negative or NaN also mean 1). Typed values snap to it. */
|
|
26
25
|
step?: number | undefined;
|
|
27
26
|
/** The current range. A `null` top end means open (shown at `max`). */
|
|
28
27
|
value: Readonly<RangeSliderValue>;
|
|
@@ -59,4 +58,4 @@ export type RangeSliderProps = Omit<ComponentProps<"fieldset">, "onChange" | "ch
|
|
|
59
58
|
* Page Up/Down ten steps, Home/End as far as it can go. The boxes commit on blur or
|
|
60
59
|
* Enter; Escape puts the value back.
|
|
61
60
|
*/
|
|
62
|
-
export declare function RangeSlider({ label,
|
|
61
|
+
export declare function RangeSlider({ label, min: rawMin, max: rawMax, step: rawStep, value, onChange, format, parse, inputMode, openEnded, minLabel, maxLabel, disabled, className, ...props }: RangeSliderProps): import("react").JSX.Element;
|