@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +561 -0
  4. package/dist/components/actions/CopyButton.d.ts +33 -0
  5. package/dist/components/actions/CopyButton.js +40 -0
  6. package/dist/components/actions/JsonLd.d.ts +20 -0
  7. package/dist/components/actions/JsonLd.js +17 -0
  8. package/dist/components/actions/Pagination.d.ts +33 -0
  9. package/dist/components/actions/Pagination.js +27 -0
  10. package/dist/components/actions/PaginationStatus.d.ts +19 -0
  11. package/dist/components/actions/PaginationStatus.js +44 -0
  12. package/dist/components/basics/Button.d.ts +20 -0
  13. package/dist/components/basics/Button.js +10 -0
  14. package/dist/components/basics/ButtonLink.d.ts +23 -0
  15. package/dist/components/basics/ButtonLink.js +26 -0
  16. package/dist/components/basics/Card.d.ts +19 -0
  17. package/dist/components/basics/Card.js +20 -0
  18. package/dist/components/basics/Notice.d.ts +30 -0
  19. package/dist/components/basics/Notice.js +22 -0
  20. package/dist/components/basics/PageHeader.d.ts +25 -0
  21. package/dist/components/basics/PageHeader.js +10 -0
  22. package/dist/components/basics/Prose.d.ts +18 -0
  23. package/dist/components/basics/Prose.js +11 -0
  24. package/dist/components/basics/buttonStyles.d.ts +23 -0
  25. package/dist/components/basics/buttonStyles.js +28 -0
  26. package/dist/components/filters/Chip.d.ts +25 -0
  27. package/dist/components/filters/Chip.js +29 -0
  28. package/dist/components/filters/ChipGroup.d.ts +39 -0
  29. package/dist/components/filters/ChipGroup.js +44 -0
  30. package/dist/components/filters/FilterPanel.d.ts +36 -0
  31. package/dist/components/filters/FilterPanel.js +45 -0
  32. package/dist/components/filters/FilterRow.d.ts +22 -0
  33. package/dist/components/filters/FilterRow.js +22 -0
  34. package/dist/components/filters/RangeSlider.d.ts +62 -0
  35. package/dist/components/filters/RangeSlider.js +161 -0
  36. package/dist/components/forms/Checkbox.d.ts +20 -0
  37. package/dist/components/forms/Checkbox.js +13 -0
  38. package/dist/components/forms/FieldFrame.d.ts +66 -0
  39. package/dist/components/forms/FieldFrame.js +44 -0
  40. package/dist/components/forms/Select.d.ts +19 -0
  41. package/dist/components/forms/Select.js +12 -0
  42. package/dist/components/forms/TextInput.d.ts +19 -0
  43. package/dist/components/forms/TextInput.js +12 -0
  44. package/dist/components/forms/Textarea.d.ts +19 -0
  45. package/dist/components/forms/Textarea.js +13 -0
  46. package/dist/components/forms/fieldStyles.d.ts +17 -0
  47. package/dist/components/forms/fieldStyles.js +19 -0
  48. package/dist/components/icons/GitHubIcon.d.ts +18 -0
  49. package/dist/components/icons/GitHubIcon.js +10 -0
  50. package/dist/components/icons/HaruhimeWordmark.d.ts +23 -0
  51. package/dist/components/icons/HaruhimeWordmark.js +16 -0
  52. package/dist/components/icons/HaruhimeWordmarkLink.d.ts +21 -0
  53. package/dist/components/icons/HaruhimeWordmarkLink.js +12 -0
  54. package/dist/components/shell/AutoLink.d.ts +19 -0
  55. package/dist/components/shell/AutoLink.js +23 -0
  56. package/dist/components/shell/NavLinks.d.ts +24 -0
  57. package/dist/components/shell/NavLinks.js +40 -0
  58. package/dist/components/shell/PageShell.d.ts +29 -0
  59. package/dist/components/shell/PageShell.js +11 -0
  60. package/dist/components/shell/SiteFooter.d.ts +40 -0
  61. package/dist/components/shell/SiteFooter.js +21 -0
  62. package/dist/components/shell/SiteHeader.d.ts +32 -0
  63. package/dist/components/shell/SiteHeader.js +16 -0
  64. package/dist/components/shell/links.d.ts +25 -0
  65. package/dist/components/shell/links.js +32 -0
  66. package/dist/index.d.ts +38 -0
  67. package/dist/index.js +41 -0
  68. package/dist/theme.css +54 -0
  69. package/dist/utils/cx.d.ts +18 -0
  70. package/dist/utils/cx.js +17 -0
  71. package/dist/utils/href.d.ts +15 -0
  72. package/dist/utils/href.js +17 -0
  73. package/package.json +90 -0
@@ -0,0 +1,44 @@
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
+ "use client";
10
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
+ import { useId } from "react";
12
+ import { cx } from "../../utils/cx.js";
13
+ import { Chip } from "./Chip.js";
14
+ /**
15
+ * @function toggleValue
16
+ * @param value {readonly string[]} the picked values
17
+ * @param option {string} the value to switch on or off
18
+ * @param on {boolean} true to add it, false to remove it
19
+ * @param options {readonly ChipOption[]} the group's options, for ordering
20
+ * @returns {string[]} the new picked values without duplicates, in option order (values that
21
+ * aren't options keep their place at the end)
22
+ */
23
+ const toggleValue = (value, option, on, options) => {
24
+ const rest = [...new Set(value)].filter((v) => v !== option);
25
+ const next = on ? [...rest, option] : rest;
26
+ const order = new Map(options.map((o, i) => [o.value, i]));
27
+ const rank = (v) => order.get(v) ?? options.length;
28
+ return next.sort((a, b) => rank(a) - rank(b));
29
+ };
30
+ /**
31
+ * @function ChipGroup
32
+ * @param props {ChipGroupProps} label, options, picked values and a change handler, plus native
33
+ * fieldset props (`disabled` turns off every chip). `onChange` is a function, so render
34
+ * this from client code.
35
+ * @returns {JSX.Element} a `<fieldset>` (role group) labeled by its label, one Chip per option.
36
+ * With `hideLabel`, the fieldset has role none and no label.
37
+ */
38
+ export function ChipGroup({ label, hideLabel = false, options, value, onChange, className, ...props }) {
39
+ const labelId = useId();
40
+ return (_jsxs("fieldset", {
41
+ // Inside a FilterRow (hideLabel), the row's fieldset is the group. A second group with the
42
+ // same name would be read twice. The fieldset stays, so `disabled` still reaches every chip.
43
+ role: hideLabel ? "none" : undefined, "aria-labelledby": hideLabel ? undefined : labelId, className: cx("flex flex-col gap-2", className), ...props, children: [hideLabel ? null : (_jsx("span", { id: labelId, className: "font-bold text-c3 text-sm", children: label })), _jsx("div", { className: "flex flex-wrap items-center gap-1", children: options.map((option) => (_jsx(Chip, { pressed: value.includes(option.value), disabled: option.disabled, onPressedChange: (on) => onChange(toggleValue(value, option.value, on, options)), children: option.label }, option.value))) })] }));
44
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file src/components/filters/FilterPanel.tsx
3
+ * @desc Filter panel (osu! beatmap listing style): a titled card of FilterRows with a live result
4
+ * count and a "Clear filters" link. On phones the rows fold away behind a disclosure button;
5
+ * from `sm` up they are always shown.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Wed Sep 23, 2026
8
+ * @modified Wed Sep 23, 2026
9
+ */
10
+ import { type ComponentProps, type ReactNode } from "react";
11
+ /** Every native `<section>` prop except `title`, plus the panel's title, count and clear action. */
12
+ export type FilterPanelProps = Omit<ComponentProps<"section">, "title"> & {
13
+ /** The panel heading; it also names the panel and its phone disclosure button. */
14
+ title: ReactNode;
15
+ /** Heading level for the title (default 2). */
16
+ headingLevel?: 2 | 3 | 4 | 5 | 6 | undefined;
17
+ /** Shown in an `<output aria-live="polite">`, so screen readers hear each new count. */
18
+ resultCount?: ReactNode;
19
+ /** True while any filter is set; shows the clear button. */
20
+ active?: boolean | undefined;
21
+ /** Called by the clear button. The button shows only when `active` is true and this is set. */
22
+ onClear?: (() => void) | undefined;
23
+ /** Text of the clear button (default "Clear filters"). */
24
+ clearLabel?: ReactNode;
25
+ /** Whether the rows start open on phones (default false). Ignored from `sm` up. */
26
+ defaultOpen?: boolean | undefined;
27
+ };
28
+ /**
29
+ * @function FilterPanel
30
+ * @param props {FilterPanelProps} title, FilterRows as children, an optional result count and
31
+ * clear action, plus native section props. `onClear` is a function, so render this from
32
+ * client code.
33
+ * @returns {JSX.Element} a `<section>` named by its heading. When the clear button is used and
34
+ * disappears, focus moves to the heading instead of getting lost.
35
+ */
36
+ export declare function FilterPanel({ title, headingLevel, resultCount, active, onClear, clearLabel, defaultOpen, className, children, ...props }: FilterPanelProps): import("react").JSX.Element;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file src/components/filters/FilterPanel.tsx
3
+ * @desc Filter panel (osu! beatmap listing style): a titled card of FilterRows with a live result
4
+ * count and a "Clear filters" link. On phones the rows fold away behind a disclosure button;
5
+ * from `sm` up they are always shown.
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, jsxs as _jsxs } from "react/jsx-runtime";
12
+ import { useEffect, useId, useRef, useState } from "react";
13
+ import { cx } from "../../utils/cx.js";
14
+ /**
15
+ * @function FilterPanel
16
+ * @param props {FilterPanelProps} title, FilterRows as children, an optional result count and
17
+ * clear action, plus native section props. `onClear` is a function, so render this from
18
+ * client code.
19
+ * @returns {JSX.Element} a `<section>` named by its heading. When the clear button is used and
20
+ * disappears, focus moves to the heading instead of getting lost.
21
+ */
22
+ export function FilterPanel({ title, headingLevel = 2, resultCount, active = false, onClear, clearLabel = "Clear filters", defaultOpen = false, className, children, ...props }) {
23
+ const titleId = useId();
24
+ const bodyId = useId();
25
+ const [open, setOpen] = useState(defaultOpen);
26
+ const headingRef = useRef(null);
27
+ const cleared = useRef(false);
28
+ const showClear = active && onClear !== undefined;
29
+ // The clear button unmounts once the filters are cleared. If it took focus with it (focus fell
30
+ // back to the body), put focus on the heading so keyboard users keep their place.
31
+ useEffect(() => {
32
+ if (showClear)
33
+ return;
34
+ const focused = document.activeElement;
35
+ if (cleared.current && (focused === null || focused === document.body)) {
36
+ headingRef.current?.focus();
37
+ }
38
+ cleared.current = false;
39
+ }, [showClear]);
40
+ const Heading = `h${headingLevel}`;
41
+ return (_jsxs("section", { "aria-labelledby": titleId, className: cx("flex flex-col gap-4 rounded-[10px] bg-b4 p-5 text-c2", 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: "font-bold text-c1 text-lg", 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
+ cleared.current = true;
43
+ onClear();
44
+ }, 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 })] }));
45
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file src/components/filters/FilterRow.tsx
3
+ * @desc One labeled row of a filter panel (osu! beatmap listing layout): the label in a column on
4
+ * the left and the controls on the right from `sm` up, stacked on phones.
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 `<fieldset>` prop (including `ref`), plus the row's label. */
11
+ export type FilterRowProps = ComponentProps<"fieldset"> & {
12
+ /** Shown in the label column; names the row (`role="group"` + `aria-labelledby`). */
13
+ label: ReactNode;
14
+ };
15
+ /**
16
+ * @function FilterRow
17
+ * @param props {FilterRowProps} the label, the row's controls as children, and native fieldset
18
+ * props
19
+ * @returns {JSX.Element} a `<fieldset>` (role group) labeled by its label column. Give a
20
+ * ChipGroup or RangeSlider inside it `hideLabel`, so the label shows and is read once.
21
+ */
22
+ export declare function FilterRow({ label, className, children, ...props }: FilterRowProps): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @file src/components/filters/FilterRow.tsx
4
+ * @desc One labeled row of a filter panel (osu! beatmap listing layout): the label in a column on
5
+ * the left and the controls on the right from `sm` up, stacked on phones.
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 FilterRow
14
+ * @param props {FilterRowProps} the label, the row's controls as children, and native fieldset
15
+ * props
16
+ * @returns {JSX.Element} a `<fieldset>` (role group) labeled by its label column. Give a
17
+ * ChipGroup or RangeSlider inside it `hideLabel`, so the label shows and is read once.
18
+ */
19
+ export function FilterRow({ label, className, children, ...props }) {
20
+ 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: "shrink-0 font-bold text-c3 text-sm sm:w-28", children: label }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
22
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @file src/components/filters/RangeSlider.tsx
3
+ * @desc Two-thumb range slider with an editable box at each end (star rating, length, BPM). Two
4
+ * native range inputs share one track; the thumbs can't cross. With `openEnded`, the top
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.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Wed Sep 23, 2026
9
+ * @modified Wed Sep 23, 2026
10
+ */
11
+ import { type ComponentProps, type HTMLAttributes } from "react";
12
+ /** A range as `[low, high]`. `high` is `null` for an open top end (no upper limit). */
13
+ export type RangeSliderValue = [number, number | null];
14
+ /** Every native `<fieldset>` prop except `onChange` and `children`, plus the range. */
15
+ export type RangeSliderProps = Omit<ComponentProps<"fieldset">, "onChange" | "children" | "inputMode"> & {
16
+ /** Names the group, and the ends as "Minimum <label>" and "Maximum <label>". */
17
+ 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;
23
+ min: number;
24
+ max: number;
25
+ /** Step between values (default 1). Typed values snap to it. */
26
+ step?: number | undefined;
27
+ /** The current range. A `null` top end means open (shown at `max`). */
28
+ value: Readonly<RangeSliderValue>;
29
+ /** Called with the new range. The top end is `null` when `openEnded` and it reaches `max`. */
30
+ onChange: (value: RangeSliderValue) => void;
31
+ /** Turns a value into display text for the boxes and screen readers (default `String`). */
32
+ format?: ((value: number) => string) | undefined;
33
+ /**
34
+ * Reads a typed value back (default: a plain number). It gets the text without a trailing
35
+ * "+"; return `null` for text it can't read, and the box goes back to the current value.
36
+ */
37
+ parse?: ((text: string) => number | null) | undefined;
38
+ /**
39
+ * The on-screen keyboard for the boxes. Default "decimal" (digits and a decimal separator), or
40
+ * "text" when `parse` is set, since formats like m:ss need keys the decimal keypad lacks.
41
+ */
42
+ inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"] | undefined;
43
+ /** When true, the top end at `max` means "no upper limit" and reports `null`. */
44
+ openEnded?: boolean | undefined;
45
+ /** Accessible name of the low end (default "Minimum <label>"). */
46
+ minLabel?: string | undefined;
47
+ /** Accessible name of the high end (default "Maximum <label>"). */
48
+ maxLabel?: string | undefined;
49
+ /** Disables both thumbs and both boxes. */
50
+ disabled?: boolean | undefined;
51
+ };
52
+ /**
53
+ * @function RangeSlider
54
+ * @param props {RangeSliderProps} label, bounds, step, the current range and a change handler,
55
+ * plus native fieldset props. `onChange`, `format` and `parse` are functions, so render this
56
+ * from client code.
57
+ * @returns {JSX.Element} a `<fieldset>` (role group, or role none with `hideLabel`) with a low
58
+ * box, two slider thumbs on one track, and a high box. Arrow keys move a thumb one step,
59
+ * Page Up/Down ten steps, Home/End as far as it can go. The boxes commit on blur or
60
+ * Enter; Escape puts the value back.
61
+ */
62
+ export declare function RangeSlider({ label, hideLabel, min, max, step, value, onChange, format, parse, inputMode, openEnded, minLabel, maxLabel, disabled, className, ...props }: RangeSliderProps): import("react").JSX.Element;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @file src/components/filters/RangeSlider.tsx
3
+ * @desc Two-thumb range slider with an editable box at each end (star rating, length, BPM). Two
4
+ * native range inputs share one track; the thumbs can't cross. With `openEnded`, the top
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.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Wed Sep 23, 2026
9
+ * @modified Wed Sep 23, 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
+ const decimalsOf = (n) => {
16
+ const text = String(n);
17
+ const dot = text.indexOf(".");
18
+ return dot === -1 ? 0 : text.length - dot - 1;
19
+ };
20
+ const clamp = (n, low, high) => Math.min(Math.max(n, low), high);
21
+ // A comma reads as the decimal point: comma-locale keypads (de, fr, pl, pt-BR, ru) type "5,5".
22
+ const defaultParse = (text) => {
23
+ const n = Number(text.replace(",", "."));
24
+ return Number.isFinite(n) ? n : null;
25
+ };
26
+ // Native range inputs, stacked on one track. Only the thumbs take the pointer, so either thumb
27
+ // can be dragged wherever they sit. The focus ring goes on the thumb, not the full-width input:
28
+ // solid h1, so it clears 3:1 on the panel. Forced-colors mode drops box-shadow rings, so the
29
+ // input keeps a transparent outline (`outline-hidden`) that mode paints instead.
30
+ const RANGE = "pointer-events-none absolute inset-x-0 top-1/2 h-4 w-full -translate-y-1/2 appearance-none bg-transparent focus-visible:outline-hidden disabled:cursor-not-allowed " +
31
+ "[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:box-border [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-h1 [&::-webkit-slider-thumb]:bg-c1 " +
32
+ "[&::-moz-range-track]:bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:box-border [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-h1 [&::-moz-range-thumb]:bg-c1 " +
33
+ "focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-h1 focus-visible:[&::-moz-range-thumb]:ring-4 focus-visible:[&::-moz-range-thumb]:ring-h1";
34
+ // The field look (border b3 on b6, h1 border on focus), sized for a short value. The group dims
35
+ // itself when disabled, so the boxes do not dim twice.
36
+ const BOX = "w-18 shrink-0 rounded-md border border-b3 bg-b6 px-2 py-1 text-center text-c1 text-sm tabular-nums placeholder:text-c4 focus-visible:border-h1 focus-visible:outline-hidden disabled:cursor-not-allowed";
37
+ /**
38
+ * @function RangeSlider
39
+ * @param props {RangeSliderProps} label, bounds, step, the current range and a change handler,
40
+ * plus native fieldset props. `onChange`, `format` and `parse` are functions, so render this
41
+ * from client code.
42
+ * @returns {JSX.Element} a `<fieldset>` (role group, or role none with `hideLabel`) with a low
43
+ * box, two slider thumbs on one track, and a high box. Arrow keys move a thumb one step,
44
+ * Page Up/Down ten steps, Home/End as far as it can go. The boxes commit on blur or
45
+ * Enter; Escape puts the value back.
46
+ */
47
+ export function RangeSlider({ label, hideLabel = false, min, max, step = 1, value, onChange, format = String, parse = defaultParse, inputMode = parse === defaultParse ? "decimal" : "text", openEnded = false, minLabel = `Minimum ${label}`, maxLabel = `Maximum ${label}`, disabled = false, className, ...props }) {
48
+ const labelId = useId();
49
+ const [drafts, setDrafts] = useState({ low: null, high: null });
50
+ // The pointer drag in progress, and the end it moves (picked on its first change).
51
+ const drag = useRef(null);
52
+ // Normalize what came in (it may come from a URL): inside the bounds, low <= high. A NaN or
53
+ // infinite end means no limit on that end, like an empty box.
54
+ const low = Number.isFinite(value[0]) ? clamp(value[0], min, max) : min;
55
+ const top = value[1] !== null && Number.isFinite(value[1]) ? value[1] : null;
56
+ const highOpen = openEnded && (top === null || top >= max);
57
+ const high = top === null ? max : clamp(top, low, max);
58
+ const highOut = highOpen ? null : high;
59
+ const decimals = Math.max(decimalsOf(step), decimalsOf(min));
60
+ const snap = (n) => Number((min + Math.round((n - min) / step) * step).toFixed(decimals));
61
+ const percent = (n) => (max === min ? 0 : ((n - min) / (max - min)) * 100);
62
+ const openText = `${format(max)}+`;
63
+ const text = {
64
+ low: format(low),
65
+ high: highOpen ? openText : format(high),
66
+ };
67
+ const commit = (end, n) => {
68
+ if (end === "low") {
69
+ const next = clamp(snap(n), min, high);
70
+ if (next !== low)
71
+ onChange([next, highOut]);
72
+ return;
73
+ }
74
+ const snapped = clamp(snap(n), low, max);
75
+ const next = openEnded && (n >= max || snapped >= max) ? null : snapped;
76
+ if (next !== highOut)
77
+ onChange([low, next]);
78
+ };
79
+ const setDraft = (end, draft) => setDrafts((current) => ({ ...current, [end]: draft }));
80
+ const commitDraft = (end) => {
81
+ const draft = drafts[end];
82
+ if (draft === null)
83
+ return;
84
+ setDraft(end, null);
85
+ const trimmed = draft.trim().replace(/\+$/, "").trim();
86
+ // An empty box means no limit on that end.
87
+ if (trimmed === "") {
88
+ commit(end, end === "low" ? min : max);
89
+ return;
90
+ }
91
+ const n = parse(trimmed);
92
+ if (n !== null && Number.isFinite(n))
93
+ commit(end, n);
94
+ };
95
+ const onBoxKeyDown = (end) => (event) => {
96
+ if (event.key === "Enter") {
97
+ event.preventDefault();
98
+ commitDraft(end);
99
+ }
100
+ else if (event.key === "Escape" && drafts[end] !== null) {
101
+ event.preventDefault();
102
+ setDraft(end, null);
103
+ }
104
+ };
105
+ const onThumbKeyDown = (end) => (event) => {
106
+ const current = end === "low" ? low : high;
107
+ const big = step * 10;
108
+ const moves = {
109
+ ArrowLeft: current - step,
110
+ ArrowDown: current - step,
111
+ ArrowRight: current + step,
112
+ ArrowUp: current + step,
113
+ PageDown: current - big,
114
+ PageUp: current + big,
115
+ Home: min,
116
+ End: max,
117
+ };
118
+ const next = moves[event.key];
119
+ if (next === undefined)
120
+ return;
121
+ event.preventDefault();
122
+ commit(end, next);
123
+ };
124
+ const onThumbPointerDown = () => {
125
+ drag.current = { end: null };
126
+ const done = () => {
127
+ drag.current = null;
128
+ window.removeEventListener("pointerup", done);
129
+ window.removeEventListener("pointercancel", done);
130
+ };
131
+ window.addEventListener("pointerup", done);
132
+ window.addEventListener("pointercancel", done);
133
+ };
134
+ // With both thumbs on one value, only the top one can be grabbed, and it can only move one way.
135
+ // So a drag's first move picks the end: toward the other thumb's side, the other end moves.
136
+ // The drag keeps that end until the pointer lifts. Changes without a pointer (assistive tech
137
+ // stepping a slider) always move their own end.
138
+ const onThumbChange = (end) => (event) => {
139
+ const n = Number(event.currentTarget.value);
140
+ const gesture = drag.current;
141
+ if (gesture && gesture.end === null) {
142
+ const parked = low === high;
143
+ gesture.end =
144
+ parked && end === "high" && n < low
145
+ ? "low"
146
+ : parked && end === "low" && n > high
147
+ ? "high"
148
+ : end;
149
+ }
150
+ commit(gesture?.end ?? end, n);
151
+ };
152
+ const box = (end) => (_jsx("input", { type: "text", inputMode: inputMode, autoComplete: "off", "aria-label": end === "low" ? minLabel : maxLabel, disabled: disabled, value: drafts[end] ?? text[end], onChange: (event) => setDraft(end, event.currentTarget.value), onBlur: () => commitDraft(end), onKeyDown: onBoxKeyDown(end), className: BOX }));
153
+ const lowPercent = percent(low);
154
+ const highPercent = highOpen ? 100 : percent(high);
155
+ return (_jsxs("fieldset", {
156
+ // Inside a FilterRow (hideLabel), the row's fieldset is the group; see ChipGroup.
157
+ role: hideLabel ? "none" : undefined, "aria-labelledby": hideLabel ? undefined : labelId, disabled: disabled, className: cx("flex flex-col gap-2 disabled:opacity-50", className), ...props, children: [hideLabel ? null : (_jsx("span", { id: labelId, className: "font-bold text-c3 text-sm", children: label })), _jsxs("div", { className: "flex items-center gap-3", children: [box("low"), _jsxs("div", { className: "relative h-5 min-w-0 flex-1", children: [_jsx("div", { "aria-hidden": "true", className: "absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-b3" }), _jsx("div", { "aria-hidden": "true", className: "absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2", children: _jsx("div", { "data-range-fill": "", className: "absolute inset-y-0 rounded-full bg-h1 forced-colors:bg-[Highlight]", style: { left: `${lowPercent}%`, right: `${100 - highPercent}%` } }) }), _jsx("input", { type: "range", "aria-label": minLabel, "aria-valuetext": format(low), min: min, max: max, step: step, value: low, disabled: disabled, onChange: onThumbChange("low"), onKeyDown: onThumbKeyDown("low"), onPointerDown: onThumbPointerDown,
158
+ // Past the middle, the low thumb sits on top, so two thumbs parked at the top end can
159
+ // still be pulled apart.
160
+ className: cx(RANGE, lowPercent > 50 && "z-10") }), _jsx("input", { type: "range", "aria-label": maxLabel, "aria-valuetext": highOpen ? openText : format(high), min: min, max: max, step: step, value: high, disabled: disabled, onChange: onThumbChange("high"), onKeyDown: onThumbKeyDown("high"), onPointerDown: onThumbPointerDown, className: RANGE })] }), box("high")] })] }));
161
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file src/components/forms/Checkbox.tsx
3
+ * @desc Checkbox with a bold label and an inline hint (the packs download-options look). The whole
4
+ * row toggles it; the label alone is its accessible name and the hint its description.
5
+ * Server-safe: the required id names the label, hint and error.
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
+ import { type FieldProps } from "./FieldFrame.js";
12
+ /** Every native checkbox `<input>` prop (including `ref`), plus an id, label, hint and error. */
13
+ export type CheckboxProps = Omit<ComponentProps<"input">, "id" | "type"> & FieldProps;
14
+ /**
15
+ * @function Checkbox
16
+ * @param props {CheckboxProps} native input props (`checked`, `defaultChecked`, `onChange`...),
17
+ * plus id, label, hint, error and wrapperClassName; `className` goes on the `<input>`
18
+ * @returns {JSX.Element} the checkbox row, and the error under it when given
19
+ */
20
+ export declare function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }: CheckboxProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ import { FieldError, fieldDescribedBy, hintId } from "./FieldFrame.js";
4
+ /**
5
+ * @function Checkbox
6
+ * @param props {CheckboxProps} native input props (`checked`, `defaultChecked`, `onChange`...),
7
+ * plus id, label, hint, error and wrapperClassName; `className` goes on the `<input>`
8
+ * @returns {JSX.Element} the checkbox row, and the error under it when given
9
+ */
10
+ export function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
11
+ const labelId = `${id}-label`;
12
+ return (_jsxs("div", { className: cx("flex flex-col gap-1", wrapperClassName), children: [_jsxs("label", { htmlFor: id, className: "flex items-start gap-2 text-sm", children: [_jsx("input", { ...props, type: "checkbox", id: id, "aria-labelledby": labelId, "aria-describedby": fieldDescribedBy(id, hint, error, describedBy), "aria-invalid": error ? true : invalid, className: cx("mt-1 accent-h1", className) }), _jsxs("span", { children: [_jsx("span", { id: labelId, className: "font-bold text-c1", children: label }), hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(id), children: hint })] })) : null] })] }), _jsx(FieldError, { id: id, error: error })] }));
13
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file src/components/forms/FieldFrame.tsx
3
+ * @desc Internal layout shared by the form fields: label on top, the control, then the hint and
4
+ * the error. Ids derive from the control's required id, so the fields need no useId and stay
5
+ * server components. The hint and error sit in `<div>`s, so they can hold a list.
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
+ /** The label, hint and error every form field takes, keyed on the control's required id. */
12
+ export type FieldProps = {
13
+ /** The control's id. The label points at it; the hint and error get `<id>-hint`, `<id>-error`. */
14
+ id: string;
15
+ /** Visible label text. */
16
+ label: ReactNode;
17
+ /** Help text under the control, linked with aria-describedby. Checkbox shows it inline. */
18
+ hint?: ReactNode | undefined;
19
+ /** Error text under the control (a list is fine). Marks the control aria-invalid and links it. */
20
+ error?: ReactNode | undefined;
21
+ /** Classes for the wrapper around the label, control, hint and error (layout, width). */
22
+ wrapperClassName?: string | undefined;
23
+ };
24
+ /**
25
+ * @function hintId
26
+ * @param id {string} the control's id
27
+ * @returns {string} the id of the control's hint element
28
+ */
29
+ export declare const hintId: (id: string) => string;
30
+ /**
31
+ * @function errorId
32
+ * @param id {string} the control's id
33
+ * @returns {string} the id of the control's error element
34
+ */
35
+ export declare const errorId: (id: string) => string;
36
+ /**
37
+ * @function fieldDescribedBy
38
+ * @param id {string} the control's id
39
+ * @param hint {ReactNode} the hint, if any
40
+ * @param error {ReactNode} the error, if any
41
+ * @param extra {string} the caller's own aria-describedby, kept last
42
+ * @returns {string | undefined} the aria-describedby value, or undefined when there is nothing
43
+ */
44
+ export declare const fieldDescribedBy: (id: string, hint: ReactNode, error: ReactNode, extra: string | undefined) => string | undefined;
45
+ /**
46
+ * @function FieldError
47
+ * @param props {{ id: string; error?: ReactNode }} the control's id and the error, if any
48
+ * @returns {JSX.Element | null} the error in a `<div>` (block content like a list is fine), or
49
+ * nothing without an error
50
+ */
51
+ export declare function FieldError({ id, error }: {
52
+ id: string;
53
+ error?: ReactNode | undefined;
54
+ }): import("react").JSX.Element | null;
55
+ type FieldFrameProps = Omit<FieldProps, "wrapperClassName"> & {
56
+ className?: string | undefined;
57
+ children: ReactNode;
58
+ };
59
+ /**
60
+ * @function FieldFrame
61
+ * @param props {FieldFrameProps} the control's id, label, hint, error, wrapper classes and the
62
+ * control itself as children
63
+ * @returns {JSX.Element} the label, control, hint and error stacked in a column
64
+ */
65
+ export declare function FieldFrame({ id, label, hint, error, className, children }: FieldFrameProps): import("react").JSX.Element;
66
+ export {};
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ /**
4
+ * @function hintId
5
+ * @param id {string} the control's id
6
+ * @returns {string} the id of the control's hint element
7
+ */
8
+ export const hintId = (id) => `${id}-hint`;
9
+ /**
10
+ * @function errorId
11
+ * @param id {string} the control's id
12
+ * @returns {string} the id of the control's error element
13
+ */
14
+ export const errorId = (id) => `${id}-error`;
15
+ /**
16
+ * @function fieldDescribedBy
17
+ * @param id {string} the control's id
18
+ * @param hint {ReactNode} the hint, if any
19
+ * @param error {ReactNode} the error, if any
20
+ * @param extra {string} the caller's own aria-describedby, kept last
21
+ * @returns {string | undefined} the aria-describedby value, or undefined when there is nothing
22
+ */
23
+ export const fieldDescribedBy = (id, hint, error, extra) =>
24
+ // A plain join, not cx: these are ids, and cx would treat an id like "text-hint" as a class.
25
+ [hint ? hintId(id) : null, error ? errorId(id) : null, extra].filter(Boolean).join(" ") ||
26
+ undefined;
27
+ /**
28
+ * @function FieldError
29
+ * @param props {{ id: string; error?: ReactNode }} the control's id and the error, if any
30
+ * @returns {JSX.Element | null} the error in a `<div>` (block content like a list is fine), or
31
+ * nothing without an error
32
+ */
33
+ export function FieldError({ id, error }) {
34
+ return error ? (_jsx("div", { id: errorId(id), role: "alert", className: "text-rose-300 text-sm", children: error })) : null;
35
+ }
36
+ /**
37
+ * @function FieldFrame
38
+ * @param props {FieldFrameProps} the control's id, label, hint, error, wrapper classes and the
39
+ * control itself as children
40
+ * @returns {JSX.Element} the label, control, hint and error stacked in a column
41
+ */
42
+ export function FieldFrame({ id, label, hint, error, className, children }) {
43
+ return (_jsxs("div", { className: cx("flex flex-col gap-1", className), children: [_jsx("label", { htmlFor: id, className: "font-bold text-c3 text-sm", children: label }), children, hint ? (_jsx("div", { id: hintId(id), className: "text-c4 text-xs", children: hint })) : null, _jsx(FieldError, { id: id, error: error })] }));
44
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file src/components/forms/Select.tsx
3
+ * @desc Labeled native select on the shared field look, with an optional hint and error wired
4
+ * through aria-describedby and aria-invalid. Options come in as children. Server-safe.
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
+ import { type FieldProps } from "./FieldFrame.js";
11
+ /** Every native `<select>` prop (including `ref`), plus a required id, label, hint and error. */
12
+ export type SelectProps = Omit<ComponentProps<"select">, "id"> & FieldProps;
13
+ /**
14
+ * @function Select
15
+ * @param props {SelectProps} native select props with `<option>` children, plus id, label, hint,
16
+ * error and wrapperClassName; `className` goes on the `<select>`
17
+ * @returns {JSX.Element} a label, the select, and the hint and error when given
18
+ */
19
+ export declare function Select({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }: SelectProps): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { FieldFrame, fieldDescribedBy } from "./FieldFrame.js";
3
+ import { fieldClasses } from "./fieldStyles.js";
4
+ /**
5
+ * @function Select
6
+ * @param props {SelectProps} native select props with `<option>` children, plus id, label, hint,
7
+ * error and wrapperClassName; `className` goes on the `<select>`
8
+ * @returns {JSX.Element} a label, the select, and the hint and error when given
9
+ */
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, id: id, "aria-describedby": fieldDescribedBy(id, hint, error, describedBy), "aria-invalid": error ? true : invalid, className: fieldClasses(className) }) }));
12
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file src/components/forms/TextInput.tsx
3
+ * @desc Labeled text input on the shared field look, with an optional hint and error wired through
4
+ * aria-describedby and aria-invalid. Server-safe: the required id names the hint and error.
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
+ import { type FieldProps } from "./FieldFrame.js";
11
+ /** Every native `<input>` prop (including `ref` and `type`), plus an id, label, hint and error. */
12
+ export type TextInputProps = Omit<ComponentProps<"input">, "id"> & FieldProps;
13
+ /**
14
+ * @function TextInput
15
+ * @param props {TextInputProps} native input props, plus id, label, hint, error and
16
+ * wrapperClassName; `className` goes on the `<input>`
17
+ * @returns {JSX.Element} a label, the input, and the hint and error when given
18
+ */
19
+ export declare function TextInput({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }: TextInputProps): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { FieldFrame, fieldDescribedBy } from "./FieldFrame.js";
3
+ import { fieldClasses } from "./fieldStyles.js";
4
+ /**
5
+ * @function TextInput
6
+ * @param props {TextInputProps} native input props, plus id, label, hint, error and
7
+ * wrapperClassName; `className` goes on the `<input>`
8
+ * @returns {JSX.Element} a label, the input, and the hint and error when given
9
+ */
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, id: id, "aria-describedby": fieldDescribedBy(id, hint, error, describedBy), "aria-invalid": error ? true : invalid, className: fieldClasses(className) }) }));
12
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file src/components/forms/Textarea.tsx
3
+ * @desc Labeled textarea on the shared field look (at least 6rem tall, resizes vertically), with
4
+ * an optional hint and error wired through aria-describedby and aria-invalid. Server-safe.
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
+ import { type FieldProps } from "./FieldFrame.js";
11
+ /** Every native `<textarea>` prop (including `ref`), plus a required id, label, hint and error. */
12
+ export type TextareaProps = Omit<ComponentProps<"textarea">, "id"> & FieldProps;
13
+ /**
14
+ * @function Textarea
15
+ * @param props {TextareaProps} native textarea props, plus id, label, hint, error and
16
+ * wrapperClassName; `className` goes on the `<textarea>`
17
+ * @returns {JSX.Element} a label, the textarea, and the hint and error when given
18
+ */
19
+ export declare function Textarea({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }: TextareaProps): import("react").JSX.Element;