@haruhimemoe/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +561 -0
- package/dist/components/actions/CopyButton.d.ts +33 -0
- package/dist/components/actions/CopyButton.js +40 -0
- package/dist/components/actions/JsonLd.d.ts +20 -0
- package/dist/components/actions/JsonLd.js +17 -0
- package/dist/components/actions/Pagination.d.ts +33 -0
- package/dist/components/actions/Pagination.js +27 -0
- package/dist/components/actions/PaginationStatus.d.ts +19 -0
- package/dist/components/actions/PaginationStatus.js +44 -0
- package/dist/components/basics/Button.d.ts +20 -0
- package/dist/components/basics/Button.js +10 -0
- package/dist/components/basics/ButtonLink.d.ts +23 -0
- package/dist/components/basics/ButtonLink.js +26 -0
- package/dist/components/basics/Card.d.ts +19 -0
- package/dist/components/basics/Card.js +20 -0
- package/dist/components/basics/Notice.d.ts +30 -0
- package/dist/components/basics/Notice.js +22 -0
- package/dist/components/basics/PageHeader.d.ts +25 -0
- package/dist/components/basics/PageHeader.js +10 -0
- package/dist/components/basics/Prose.d.ts +18 -0
- package/dist/components/basics/Prose.js +11 -0
- package/dist/components/basics/buttonStyles.d.ts +23 -0
- package/dist/components/basics/buttonStyles.js +28 -0
- package/dist/components/filters/Chip.d.ts +25 -0
- package/dist/components/filters/Chip.js +29 -0
- package/dist/components/filters/ChipGroup.d.ts +39 -0
- package/dist/components/filters/ChipGroup.js +44 -0
- package/dist/components/filters/FilterPanel.d.ts +36 -0
- package/dist/components/filters/FilterPanel.js +45 -0
- package/dist/components/filters/FilterRow.d.ts +22 -0
- package/dist/components/filters/FilterRow.js +22 -0
- package/dist/components/filters/RangeSlider.d.ts +62 -0
- package/dist/components/filters/RangeSlider.js +161 -0
- package/dist/components/forms/Checkbox.d.ts +20 -0
- package/dist/components/forms/Checkbox.js +13 -0
- package/dist/components/forms/FieldFrame.d.ts +66 -0
- package/dist/components/forms/FieldFrame.js +44 -0
- package/dist/components/forms/Select.d.ts +19 -0
- package/dist/components/forms/Select.js +12 -0
- package/dist/components/forms/TextInput.d.ts +19 -0
- package/dist/components/forms/TextInput.js +12 -0
- package/dist/components/forms/Textarea.d.ts +19 -0
- package/dist/components/forms/Textarea.js +13 -0
- package/dist/components/forms/fieldStyles.d.ts +17 -0
- package/dist/components/forms/fieldStyles.js +19 -0
- package/dist/components/icons/GitHubIcon.d.ts +18 -0
- package/dist/components/icons/GitHubIcon.js +10 -0
- package/dist/components/icons/HaruhimeWordmark.d.ts +23 -0
- package/dist/components/icons/HaruhimeWordmark.js +16 -0
- package/dist/components/icons/HaruhimeWordmarkLink.d.ts +21 -0
- package/dist/components/icons/HaruhimeWordmarkLink.js +12 -0
- package/dist/components/shell/AutoLink.d.ts +19 -0
- package/dist/components/shell/AutoLink.js +23 -0
- package/dist/components/shell/NavLinks.d.ts +24 -0
- package/dist/components/shell/NavLinks.js +40 -0
- package/dist/components/shell/PageShell.d.ts +29 -0
- package/dist/components/shell/PageShell.js +11 -0
- package/dist/components/shell/SiteFooter.d.ts +40 -0
- package/dist/components/shell/SiteFooter.js +21 -0
- package/dist/components/shell/SiteHeader.d.ts +32 -0
- package/dist/components/shell/SiteHeader.js +16 -0
- package/dist/components/shell/links.d.ts +25 -0
- package/dist/components/shell/links.js +32 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +41 -0
- package/dist/theme.css +54 -0
- package/dist/utils/cx.d.ts +18 -0
- package/dist/utils/cx.js +17 -0
- package/dist/utils/href.d.ts +15 -0
- package/dist/utils/href.js +17 -0
- package/package.json +90 -0
|
@@ -0,0 +1,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;
|