@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
|
@@ -3,26 +3,19 @@
|
|
|
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
12
|
"use client";
|
|
12
13
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
-
import {
|
|
14
|
+
import { useRef } from "react";
|
|
14
15
|
import { cx } from "../../utils/cx.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
};
|
|
16
|
+
import { GroupFrame } from "./GroupFrame.js";
|
|
17
|
+
import { RangeBox } from "./RangeBox.js";
|
|
18
|
+
import { defaultParse, dragEnd, nextRange, normalizeRange, percent, rangeBounds, readDraft, thumbTarget, } from "./rangeMath.js";
|
|
26
19
|
// Native range inputs, stacked on one track. Only the thumbs take the pointer, so either thumb
|
|
27
20
|
// can be dragged wherever they sit. The focus ring goes on the thumb, not the full-width input:
|
|
28
21
|
// solid h1, so it clears 3:1 on the panel. Forced-colors mode drops box-shadow rings, so the
|
|
@@ -31,9 +24,6 @@ const RANGE = "pointer-events-none absolute inset-x-0 top-1/2 h-4 w-full -transl
|
|
|
31
24
|
"[&::-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
25
|
"[&::-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
26
|
"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
27
|
/**
|
|
38
28
|
* @function RangeSlider
|
|
39
29
|
* @param props {RangeSliderProps} label, bounds, step, the current range and a change handler,
|
|
@@ -44,78 +34,26 @@ const BOX = "w-18 shrink-0 rounded-md border border-b3 bg-b6 px-2 py-1 text-cent
|
|
|
44
34
|
* Page Up/Down ten steps, Home/End as far as it can go. The boxes commit on blur or
|
|
45
35
|
* Enter; Escape puts the value back.
|
|
46
36
|
*/
|
|
47
|
-
export function RangeSlider({ label,
|
|
48
|
-
const labelId = useId();
|
|
49
|
-
const [drafts, setDrafts] = useState({ low: null, high: null });
|
|
37
|
+
export function RangeSlider({ label, min: rawMin, max: rawMax, step: rawStep = 1, value, onChange, format = String, parse = defaultParse, inputMode = parse === defaultParse ? "decimal" : "text", openEnded = false, minLabel = `Minimum ${label}`, maxLabel = `Maximum ${label}`, disabled = false, className, ...props }) {
|
|
50
38
|
// The pointer drag in progress, and the end it moves (picked on its first change).
|
|
51
39
|
const drag = useRef(null);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
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);
|
|
40
|
+
const bounds = rangeBounds(rawMin, rawMax, rawStep);
|
|
41
|
+
const { min, max, step } = bounds;
|
|
42
|
+
const state = normalizeRange(value, bounds, openEnded);
|
|
43
|
+
const { low, high, highOpen } = state;
|
|
62
44
|
const openText = `${format(max)}+`;
|
|
63
|
-
const text = {
|
|
64
|
-
low: format(low),
|
|
65
|
-
high: highOpen ? openText : format(high),
|
|
66
|
-
};
|
|
67
45
|
const commit = (end, n) => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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]);
|
|
46
|
+
const next = nextRange(end, n, state, bounds, openEnded);
|
|
47
|
+
if (next)
|
|
48
|
+
onChange(next);
|
|
78
49
|
};
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
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))
|
|
50
|
+
const commitDraft = (end) => (draft) => {
|
|
51
|
+
const n = readDraft(draft, end, bounds, parse);
|
|
52
|
+
if (n !== null)
|
|
93
53
|
commit(end, n);
|
|
94
54
|
};
|
|
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
55
|
const onThumbKeyDown = (end) => (event) => {
|
|
106
|
-
const
|
|
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];
|
|
56
|
+
const next = thumbTarget(event.key, end === "low" ? low : high, bounds);
|
|
119
57
|
if (next === undefined)
|
|
120
58
|
return;
|
|
121
59
|
event.preventDefault();
|
|
@@ -131,31 +69,30 @@ export function RangeSlider({ label, hideLabel = false, min, max, step = 1, valu
|
|
|
131
69
|
window.addEventListener("pointerup", done);
|
|
132
70
|
window.addEventListener("pointercancel", done);
|
|
133
71
|
};
|
|
134
|
-
//
|
|
135
|
-
//
|
|
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.
|
|
72
|
+
// A drag keeps the end its first move picked until the pointer lifts. Changes without a
|
|
73
|
+
// pointer (assistive tech stepping a slider) always move their own end.
|
|
138
74
|
const onThumbChange = (end) => (event) => {
|
|
139
75
|
const n = Number(event.currentTarget.value);
|
|
140
76
|
const gesture = drag.current;
|
|
141
|
-
if (gesture && gesture.end === null)
|
|
142
|
-
|
|
143
|
-
gesture.end =
|
|
144
|
-
parked && end === "high" && n < low
|
|
145
|
-
? "low"
|
|
146
|
-
: parked && end === "low" && n > high
|
|
147
|
-
? "high"
|
|
148
|
-
: end;
|
|
149
|
-
}
|
|
77
|
+
if (gesture && gesture.end === null)
|
|
78
|
+
gesture.end = dragEnd(end, n, state);
|
|
150
79
|
commit(gesture?.end ?? end, n);
|
|
151
80
|
};
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
81
|
+
const thumb = (end) => ({
|
|
82
|
+
type: "range",
|
|
83
|
+
"aria-label": end === "low" ? minLabel : maxLabel,
|
|
84
|
+
"aria-valuetext": end === "low" ? format(low) : highOpen ? openText : format(high),
|
|
85
|
+
min,
|
|
86
|
+
max,
|
|
87
|
+
step,
|
|
88
|
+
value: end === "low" ? low : high,
|
|
89
|
+
disabled,
|
|
90
|
+
onChange: onThumbChange(end),
|
|
91
|
+
onKeyDown: onThumbKeyDown(end),
|
|
92
|
+
onPointerDown: onThumbPointerDown,
|
|
93
|
+
});
|
|
94
|
+
const box = (end) => (_jsx(RangeBox, { label: end === "low" ? minLabel : maxLabel, text: end === "low" ? format(low) : highOpen ? openText : format(high), inputMode: inputMode, disabled: disabled, onCommit: commitDraft(end) }));
|
|
95
|
+
const lowPercent = percent(low, bounds);
|
|
96
|
+
const highPercent = highOpen ? 100 : percent(high, bounds);
|
|
97
|
+
return (_jsx(GroupFrame, { label: label, disabled: disabled, className: cx("disabled:opacity-50", className), ...props, children: _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", { ...thumb("low"), className: cx(RANGE, lowPercent > 50 && "z-10") }), _jsx("input", { ...thumb("high"), className: RANGE })] }), box("high")] }) }));
|
|
161
98
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/chipStyles.ts
|
|
3
|
+
* @desc The chip look that Chip and ChoiceChips share (the osu! beatmap listing pills): the pill,
|
|
4
|
+
* pink when on, b3 when off. In forced-colors mode an "on" chip takes the system highlight
|
|
5
|
+
* colors, so on and off still look different.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
/** The pill: rounded, px-2.5 py-0.5, bold text-xs. */
|
|
11
|
+
export declare const CHIP = "rounded-full px-2.5 py-0.5 font-bold text-xs transition-colors";
|
|
12
|
+
/** A chip that is on (pressed or checked). */
|
|
13
|
+
export declare const CHIP_ON = "bg-h1 text-b6 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
|
|
14
|
+
/** A chip that is off. */
|
|
15
|
+
export declare const CHIP_OFF = "bg-b3 text-c2";
|
|
16
|
+
/** A chip that can't be used right now: dimmed, with a not-allowed cursor. */
|
|
17
|
+
export declare const CHIP_UNAVAILABLE = "cursor-not-allowed opacity-40";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/chipStyles.ts
|
|
3
|
+
* @desc The chip look that Chip and ChoiceChips share (the osu! beatmap listing pills): the pill,
|
|
4
|
+
* pink when on, b3 when off. In forced-colors mode an "on" chip takes the system highlight
|
|
5
|
+
* colors, so on and off still look different.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
/** The pill: rounded, px-2.5 py-0.5, bold text-xs. */
|
|
11
|
+
export const CHIP = "rounded-full px-2.5 py-0.5 font-bold text-xs transition-colors";
|
|
12
|
+
/** A chip that is on (pressed or checked). */
|
|
13
|
+
export const CHIP_ON = "bg-h1 text-b6 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
|
|
14
|
+
/** A chip that is off. */
|
|
15
|
+
export const CHIP_OFF = "bg-b3 text-c2";
|
|
16
|
+
/** A chip that can't be used right now: dimmed, with a not-allowed cursor. */
|
|
17
|
+
export const CHIP_UNAVAILABLE = "cursor-not-allowed opacity-40";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/rangeMath.ts
|
|
3
|
+
* @desc RangeSlider's math (internal), free of React so it tests on its own: guarded bounds and
|
|
4
|
+
* step, the incoming range normalized, snapping, the thumbs' keyboard moves, which end a
|
|
5
|
+
* drag moves, and what a typed box or a thumb move commits.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
/** A range as `[low, high]`. `high` is `null` for an open top end (no upper limit). */
|
|
11
|
+
export type RangeSliderValue = [number, number | null];
|
|
12
|
+
/** One end of the range. */
|
|
13
|
+
export type End = "low" | "high";
|
|
14
|
+
/** The bounds and step after guarding: `min <= max`, and a step that is finite and above 0. */
|
|
15
|
+
export type RangeBounds = {
|
|
16
|
+
min: number;
|
|
17
|
+
max: number;
|
|
18
|
+
step: number;
|
|
19
|
+
decimals: number;
|
|
20
|
+
};
|
|
21
|
+
/** The range as shown: both ends inside the bounds and in order, and whether the top is open. */
|
|
22
|
+
export type RangeState = {
|
|
23
|
+
low: number;
|
|
24
|
+
high: number;
|
|
25
|
+
highOpen: boolean;
|
|
26
|
+
highOut: number | null;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* @function defaultParse
|
|
30
|
+
* @param text {string} a typed value, without a trailing "+"
|
|
31
|
+
* @returns {number | null} the number, reading a comma as the decimal point (comma-locale keypads
|
|
32
|
+
* type "5,5"), or null when it isn't one
|
|
33
|
+
*/
|
|
34
|
+
export declare const defaultParse: (text: string) => number | null;
|
|
35
|
+
/**
|
|
36
|
+
* @function rangeBounds
|
|
37
|
+
* @param min {number} the low bound as passed
|
|
38
|
+
* @param max {number} the high bound as passed
|
|
39
|
+
* @param step {number} the step as passed
|
|
40
|
+
* @returns {RangeBounds} the bounds in order (swapped when `max < min`) and the step, or 1 when
|
|
41
|
+
* the step is 0, negative or not finite, so snapping never divides by zero
|
|
42
|
+
*/
|
|
43
|
+
export declare const rangeBounds: (min: number, max: number, step: number) => RangeBounds;
|
|
44
|
+
/**
|
|
45
|
+
* @function normalizeRange
|
|
46
|
+
* @param value {Readonly<RangeSliderValue>} the range as it came in (from a URL, say)
|
|
47
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
48
|
+
* @param openEnded {boolean} whether a top end at `max` means no upper limit
|
|
49
|
+
* @returns {RangeState} both ends inside the bounds with low <= high. A NaN or infinite end means
|
|
50
|
+
* no limit on that end, like an empty box.
|
|
51
|
+
*/
|
|
52
|
+
export declare const normalizeRange: (value: Readonly<RangeSliderValue>, { min, max }: RangeBounds, openEnded: boolean) => RangeState;
|
|
53
|
+
/**
|
|
54
|
+
* @function snap
|
|
55
|
+
* @param n {number} a value
|
|
56
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
57
|
+
* @returns {number} the nearest step from `min`, rounded to the step's decimals (no float noise)
|
|
58
|
+
*/
|
|
59
|
+
export declare const snap: (n: number, { min, step, decimals }: RangeBounds) => number;
|
|
60
|
+
/**
|
|
61
|
+
* @function percent
|
|
62
|
+
* @param n {number} a value inside the bounds
|
|
63
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
64
|
+
* @returns {number} where it sits on the track, 0 to 100 (0 when `min` equals `max`)
|
|
65
|
+
*/
|
|
66
|
+
export declare const percent: (n: number, { min, max }: RangeBounds) => number;
|
|
67
|
+
/**
|
|
68
|
+
* @function thumbTarget
|
|
69
|
+
* @param key {string} the key pressed on a thumb
|
|
70
|
+
* @param current {number} that thumb's value
|
|
71
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
72
|
+
* @returns {number | undefined} where the key sends the thumb (arrows one step, Page Up/Down ten
|
|
73
|
+
* steps, Home and End to the bounds), or undefined for any other key
|
|
74
|
+
*/
|
|
75
|
+
export declare const thumbTarget: (key: string, current: number, { min, max, step }: RangeBounds) => number | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* @function dragEnd
|
|
78
|
+
* @param end {End} the thumb the drag started on
|
|
79
|
+
* @param n {number} the drag's first value
|
|
80
|
+
* @param state {RangeState} the range before the drag
|
|
81
|
+
* @returns {End} the end the drag moves. With both thumbs on one value, only the top thumb can be
|
|
82
|
+
* grabbed and it can only go one way, so a first move toward the other side moves the
|
|
83
|
+
* other end.
|
|
84
|
+
*/
|
|
85
|
+
export declare const dragEnd: (end: End, n: number, { low, high }: RangeState) => End;
|
|
86
|
+
/**
|
|
87
|
+
* @function nextRange
|
|
88
|
+
* @param end {End} the end that moves
|
|
89
|
+
* @param n {number} where it moves to, before snapping
|
|
90
|
+
* @param state {RangeState} the range now
|
|
91
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
92
|
+
* @param openEnded {boolean} whether a top end at `max` reports null
|
|
93
|
+
* @returns {RangeSliderValue | null} the new range (snapped, the thumbs uncrossed, an open top
|
|
94
|
+
* end as null), or null when nothing changed
|
|
95
|
+
*/
|
|
96
|
+
export declare const nextRange: (end: End, n: number, state: RangeState, bounds: RangeBounds, openEnded: boolean) => RangeSliderValue | null;
|
|
97
|
+
/**
|
|
98
|
+
* @function readDraft
|
|
99
|
+
* @param draft {string} what was typed in an end's box
|
|
100
|
+
* @param end {End} which box
|
|
101
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
102
|
+
* @param parse {(text: string) => number | null} the caller's reader
|
|
103
|
+
* @returns {number | null} the value to commit: an empty box is no limit on that end (`min` or
|
|
104
|
+
* `max`), a trailing "+" is ignored, and text `parse` can't read gives null
|
|
105
|
+
*/
|
|
106
|
+
export declare const readDraft: (draft: string, end: End, { min, max }: RangeBounds, parse: (text: string) => number | null) => number | null;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/filters/rangeMath.ts
|
|
3
|
+
* @desc RangeSlider's math (internal), free of React so it tests on its own: guarded bounds and
|
|
4
|
+
* step, the incoming range normalized, snapping, the thumbs' keyboard moves, which end a
|
|
5
|
+
* drag moves, and what a typed box or a thumb move commits.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
const decimalsOf = (n) => {
|
|
11
|
+
const text = String(n);
|
|
12
|
+
const dot = text.indexOf(".");
|
|
13
|
+
return dot === -1 ? 0 : text.length - dot - 1;
|
|
14
|
+
};
|
|
15
|
+
const clamp = (n, low, high) => Math.min(Math.max(n, low), high);
|
|
16
|
+
/**
|
|
17
|
+
* @function defaultParse
|
|
18
|
+
* @param text {string} a typed value, without a trailing "+"
|
|
19
|
+
* @returns {number | null} the number, reading a comma as the decimal point (comma-locale keypads
|
|
20
|
+
* type "5,5"), or null when it isn't one
|
|
21
|
+
*/
|
|
22
|
+
export const defaultParse = (text) => {
|
|
23
|
+
const n = Number(text.replace(",", "."));
|
|
24
|
+
return Number.isFinite(n) ? n : null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* @function rangeBounds
|
|
28
|
+
* @param min {number} the low bound as passed
|
|
29
|
+
* @param max {number} the high bound as passed
|
|
30
|
+
* @param step {number} the step as passed
|
|
31
|
+
* @returns {RangeBounds} the bounds in order (swapped when `max < min`) and the step, or 1 when
|
|
32
|
+
* the step is 0, negative or not finite, so snapping never divides by zero
|
|
33
|
+
*/
|
|
34
|
+
export const rangeBounds = (min, max, step) => {
|
|
35
|
+
const [low, high] = min <= max ? [min, max] : [max, min];
|
|
36
|
+
const safeStep = Number.isFinite(step) && step > 0 ? step : 1;
|
|
37
|
+
return {
|
|
38
|
+
min: low,
|
|
39
|
+
max: high,
|
|
40
|
+
step: safeStep,
|
|
41
|
+
decimals: Math.max(decimalsOf(safeStep), decimalsOf(low)),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* @function normalizeRange
|
|
46
|
+
* @param value {Readonly<RangeSliderValue>} the range as it came in (from a URL, say)
|
|
47
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
48
|
+
* @param openEnded {boolean} whether a top end at `max` means no upper limit
|
|
49
|
+
* @returns {RangeState} both ends inside the bounds with low <= high. A NaN or infinite end means
|
|
50
|
+
* no limit on that end, like an empty box.
|
|
51
|
+
*/
|
|
52
|
+
export const normalizeRange = (value, { min, max }, openEnded) => {
|
|
53
|
+
const low = Number.isFinite(value[0]) ? clamp(value[0], min, max) : min;
|
|
54
|
+
const top = value[1] !== null && Number.isFinite(value[1]) ? value[1] : null;
|
|
55
|
+
const highOpen = openEnded && (top === null || top >= max);
|
|
56
|
+
const high = top === null ? max : clamp(top, low, max);
|
|
57
|
+
return { low, high, highOpen, highOut: highOpen ? null : high };
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* @function snap
|
|
61
|
+
* @param n {number} a value
|
|
62
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
63
|
+
* @returns {number} the nearest step from `min`, rounded to the step's decimals (no float noise)
|
|
64
|
+
*/
|
|
65
|
+
export const snap = (n, { min, step, decimals }) => Number((min + Math.round((n - min) / step) * step).toFixed(decimals));
|
|
66
|
+
/**
|
|
67
|
+
* @function percent
|
|
68
|
+
* @param n {number} a value inside the bounds
|
|
69
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
70
|
+
* @returns {number} where it sits on the track, 0 to 100 (0 when `min` equals `max`)
|
|
71
|
+
*/
|
|
72
|
+
export const percent = (n, { min, max }) => max === min ? 0 : ((n - min) / (max - min)) * 100;
|
|
73
|
+
/**
|
|
74
|
+
* @function thumbTarget
|
|
75
|
+
* @param key {string} the key pressed on a thumb
|
|
76
|
+
* @param current {number} that thumb's value
|
|
77
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
78
|
+
* @returns {number | undefined} where the key sends the thumb (arrows one step, Page Up/Down ten
|
|
79
|
+
* steps, Home and End to the bounds), or undefined for any other key
|
|
80
|
+
*/
|
|
81
|
+
export const thumbTarget = (key, current, { min, max, step }) => {
|
|
82
|
+
const moves = {
|
|
83
|
+
ArrowLeft: current - step,
|
|
84
|
+
ArrowDown: current - step,
|
|
85
|
+
ArrowRight: current + step,
|
|
86
|
+
ArrowUp: current + step,
|
|
87
|
+
PageDown: current - step * 10,
|
|
88
|
+
PageUp: current + step * 10,
|
|
89
|
+
Home: min,
|
|
90
|
+
End: max,
|
|
91
|
+
};
|
|
92
|
+
return moves[key];
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* @function dragEnd
|
|
96
|
+
* @param end {End} the thumb the drag started on
|
|
97
|
+
* @param n {number} the drag's first value
|
|
98
|
+
* @param state {RangeState} the range before the drag
|
|
99
|
+
* @returns {End} the end the drag moves. With both thumbs on one value, only the top thumb can be
|
|
100
|
+
* grabbed and it can only go one way, so a first move toward the other side moves the
|
|
101
|
+
* other end.
|
|
102
|
+
*/
|
|
103
|
+
export const dragEnd = (end, n, { low, high }) => {
|
|
104
|
+
if (low !== high)
|
|
105
|
+
return end;
|
|
106
|
+
if (end === "high" && n < low)
|
|
107
|
+
return "low";
|
|
108
|
+
if (end === "low" && n > high)
|
|
109
|
+
return "high";
|
|
110
|
+
return end;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* @function nextRange
|
|
114
|
+
* @param end {End} the end that moves
|
|
115
|
+
* @param n {number} where it moves to, before snapping
|
|
116
|
+
* @param state {RangeState} the range now
|
|
117
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
118
|
+
* @param openEnded {boolean} whether a top end at `max` reports null
|
|
119
|
+
* @returns {RangeSliderValue | null} the new range (snapped, the thumbs uncrossed, an open top
|
|
120
|
+
* end as null), or null when nothing changed
|
|
121
|
+
*/
|
|
122
|
+
export const nextRange = (end, n, state, bounds, openEnded) => {
|
|
123
|
+
if (end === "low") {
|
|
124
|
+
const next = clamp(snap(n, bounds), bounds.min, state.high);
|
|
125
|
+
return next === state.low ? null : [next, state.highOut];
|
|
126
|
+
}
|
|
127
|
+
const snapped = clamp(snap(n, bounds), state.low, bounds.max);
|
|
128
|
+
const next = openEnded && (n >= bounds.max || snapped >= bounds.max) ? null : snapped;
|
|
129
|
+
return next === state.highOut ? null : [state.low, next];
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* @function readDraft
|
|
133
|
+
* @param draft {string} what was typed in an end's box
|
|
134
|
+
* @param end {End} which box
|
|
135
|
+
* @param bounds {RangeBounds} the guarded bounds
|
|
136
|
+
* @param parse {(text: string) => number | null} the caller's reader
|
|
137
|
+
* @returns {number | null} the value to commit: an empty box is no limit on that end (`min` or
|
|
138
|
+
* `max`), a trailing "+" is ignored, and text `parse` can't read gives null
|
|
139
|
+
*/
|
|
140
|
+
export const readDraft = (draft, end, { min, max }, parse) => {
|
|
141
|
+
const trimmed = draft.trim().replace(/\+$/, "").trim();
|
|
142
|
+
if (trimmed === "")
|
|
143
|
+
return end === "low" ? min : max;
|
|
144
|
+
const n = parse(trimmed);
|
|
145
|
+
return n !== null && Number.isFinite(n) ? n : null;
|
|
146
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/forms/CharCounter.tsx
|
|
3
|
+
* @desc How much of a length limit a text uses ("1,234 / 60,000 characters"), turning rose and
|
|
4
|
+
* saying how many to cut once it's over. The caller counts (a BBCode counter, a string's
|
|
5
|
+
* length), so it fits any rule. Server-safe. Moved from bb.haruhime.moe.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentProps } from "react";
|
|
11
|
+
/** Every native `<p>` prop except children, plus the count, the limit and the unit. */
|
|
12
|
+
export type CharCounterProps = Omit<ComponentProps<"p">, "children"> & {
|
|
13
|
+
/** How many are used. */
|
|
14
|
+
count: number;
|
|
15
|
+
/** The most allowed. */
|
|
16
|
+
limit: number;
|
|
17
|
+
/** What is counted (default "characters"). */
|
|
18
|
+
unit?: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* @function CharCounter
|
|
22
|
+
* @param props {CharCounterProps} the count, the limit, the unit, and native `<p>` props
|
|
23
|
+
* @returns {JSX.Element} "count / limit unit" in c3, or in bold rose with ": N over the limit"
|
|
24
|
+
*/
|
|
25
|
+
export declare function CharCounter({ count, limit, unit, className, ...props }: CharCounterProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../utils/cx.js";
|
|
3
|
+
const format = (value) => value.toLocaleString("en-US");
|
|
4
|
+
/**
|
|
5
|
+
* @function CharCounter
|
|
6
|
+
* @param props {CharCounterProps} the count, the limit, the unit, and native `<p>` props
|
|
7
|
+
* @returns {JSX.Element} "count / limit unit" in c3, or in bold rose with ": N over the limit"
|
|
8
|
+
*/
|
|
9
|
+
export function CharCounter({ count, limit, unit = "characters", className, ...props }) {
|
|
10
|
+
const over = count - limit;
|
|
11
|
+
return (_jsxs("p", { className: cx("text-sm tabular-nums", over > 0 ? "font-bold text-rose-300" : "text-c3", className), ...props, children: [`${format(count)} / ${format(limit)} ${unit}`, over > 0 ? `: ${format(over)} over the limit` : ""] }));
|
|
12
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/forms/Checkbox.tsx
|
|
3
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
|
|
5
|
-
* Server-safe: the required id names the label,
|
|
4
|
+
* row toggles it; the label alone is its accessible name (plus any aria-labelledby the
|
|
5
|
+
* caller adds) and the hint its description. Server-safe: the required id names the label,
|
|
6
|
+
* hint and error.
|
|
6
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
7
8
|
* @created Wed Sep 23, 2026
|
|
8
|
-
* @modified
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
9
10
|
*/
|
|
10
11
|
import type { ComponentProps } from "react";
|
|
11
12
|
import { type FieldProps } from "./FieldFrame.js";
|
|
@@ -17,4 +18,4 @@ export type CheckboxProps = Omit<ComponentProps<"input">, "id" | "type"> & Field
|
|
|
17
18
|
* plus id, label, hint, error and wrapperClassName; `className` goes on the `<input>`
|
|
18
19
|
* @returns {JSX.Element} the checkbox row, and the error under it when given
|
|
19
20
|
*/
|
|
20
|
-
export declare function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }: CheckboxProps): import("react").JSX.Element;
|
|
21
|
+
export declare function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, "aria-labelledby": labelledBy, ...props }: CheckboxProps): import("react").JSX.Element;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
-
import { FieldError,
|
|
3
|
+
import { FieldError, fieldControlProps, hintId } from "./FieldFrame.js";
|
|
4
4
|
/**
|
|
5
5
|
* @function Checkbox
|
|
6
6
|
* @param props {CheckboxProps} native input props (`checked`, `defaultChecked`, `onChange`...),
|
|
7
7
|
* plus id, label, hint, error and wrapperClassName; `className` goes on the `<input>`
|
|
8
8
|
* @returns {JSX.Element} the checkbox row, and the error under it when given
|
|
9
9
|
*/
|
|
10
|
-
export function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
|
|
10
|
+
export function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, "aria-labelledby": labelledBy, ...props }) {
|
|
11
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",
|
|
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", ...fieldControlProps({ id, hint, error, describedBy, invalid }), "aria-labelledby": labelledBy ? `${labelId} ${labelledBy}` : labelId, 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
13
|
}
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* @desc Internal layout shared by the form fields: label on top, the control, then the hint and
|
|
4
4
|
* the error. Ids derive from the control's required id, so the fields need no useId and stay
|
|
5
5
|
* server components. The hint and error sit in `<div>`s, so they can hold a list.
|
|
6
|
+
* `fieldControlProps` is the one rule for the control's id, aria-describedby and
|
|
7
|
+
* aria-invalid.
|
|
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
|
-
import type { ReactNode } from "react";
|
|
12
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
11
13
|
/** The label, hint and error every form field takes, keyed on the control's required id. */
|
|
12
14
|
export type FieldProps = {
|
|
13
15
|
/** The control's id. The label points at it; the hint and error get `<id>-hint`, `<id>-error`. */
|
|
@@ -42,6 +44,30 @@ export declare const errorId: (id: string) => string;
|
|
|
42
44
|
* @returns {string | undefined} the aria-describedby value, or undefined when there is nothing
|
|
43
45
|
*/
|
|
44
46
|
export declare const fieldDescribedBy: (id: string, hint: ReactNode, error: ReactNode, extra: string | undefined) => string | undefined;
|
|
47
|
+
/** What a field knows when it wires up its control: ids, texts, and the caller's own ARIA. */
|
|
48
|
+
type FieldControlInput = {
|
|
49
|
+
id: string;
|
|
50
|
+
hint: ReactNode;
|
|
51
|
+
error: ReactNode;
|
|
52
|
+
/** The caller's own aria-describedby, kept after the hint and error ids. */
|
|
53
|
+
describedBy: string | undefined;
|
|
54
|
+
/** The caller's own aria-invalid, used when there is no error. */
|
|
55
|
+
invalid: ComponentProps<"input">["aria-invalid"];
|
|
56
|
+
};
|
|
57
|
+
/** The id and ARIA props a field puts on its control. */
|
|
58
|
+
type FieldControlProps = {
|
|
59
|
+
id: string;
|
|
60
|
+
"aria-describedby": string | undefined;
|
|
61
|
+
"aria-invalid": FieldControlInput["invalid"];
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* @function fieldControlProps
|
|
65
|
+
* @param input {FieldControlInput} the control's id, its hint and error, and the caller's
|
|
66
|
+
* aria-describedby and aria-invalid
|
|
67
|
+
* @returns {FieldControlProps} the props every field puts on its control: an error sets
|
|
68
|
+
* aria-invalid, and the hint and error are linked before the caller's own descriptions
|
|
69
|
+
*/
|
|
70
|
+
export declare const fieldControlProps: ({ id, hint, error, describedBy, invalid, }: FieldControlInput) => FieldControlProps;
|
|
45
71
|
/**
|
|
46
72
|
* @function FieldError
|
|
47
73
|
* @param props {{ id: string; error?: ReactNode }} the control's id and the error, if any
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
+
import { FIELD_LABEL } from "./fieldStyles.js";
|
|
3
4
|
/**
|
|
4
5
|
* @function hintId
|
|
5
6
|
* @param id {string} the control's id
|
|
@@ -24,6 +25,18 @@ export const fieldDescribedBy = (id, hint, error, extra) =>
|
|
|
24
25
|
// A plain join, not cx: these are ids, and cx would treat an id like "text-hint" as a class.
|
|
25
26
|
[hint ? hintId(id) : null, error ? errorId(id) : null, extra].filter(Boolean).join(" ") ||
|
|
26
27
|
undefined;
|
|
28
|
+
/**
|
|
29
|
+
* @function fieldControlProps
|
|
30
|
+
* @param input {FieldControlInput} the control's id, its hint and error, and the caller's
|
|
31
|
+
* aria-describedby and aria-invalid
|
|
32
|
+
* @returns {FieldControlProps} the props every field puts on its control: an error sets
|
|
33
|
+
* aria-invalid, and the hint and error are linked before the caller's own descriptions
|
|
34
|
+
*/
|
|
35
|
+
export const fieldControlProps = ({ id, hint, error, describedBy, invalid, }) => ({
|
|
36
|
+
id,
|
|
37
|
+
"aria-describedby": fieldDescribedBy(id, hint, error, describedBy),
|
|
38
|
+
"aria-invalid": error ? true : invalid,
|
|
39
|
+
});
|
|
27
40
|
/**
|
|
28
41
|
* @function FieldError
|
|
29
42
|
* @param props {{ id: string; error?: ReactNode }} the control's id and the error, if any
|
|
@@ -40,5 +53,5 @@ export function FieldError({ id, error }) {
|
|
|
40
53
|
* @returns {JSX.Element} the label, control, hint and error stacked in a column
|
|
41
54
|
*/
|
|
42
55
|
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:
|
|
56
|
+
return (_jsxs("div", { className: cx("flex flex-col gap-1", className), children: [_jsx("label", { htmlFor: id, className: FIELD_LABEL, children: label }), children, hint ? (_jsx("div", { id: hintId(id), className: "text-c4 text-xs", children: hint })) : null, _jsx(FieldError, { id: id, error: error })] }));
|
|
44
57
|
}
|