@haruhimemoe/ui 0.2.0 → 0.4.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 (122) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +265 -29
  3. package/dist/components/actions/AsyncButton.d.ts +30 -0
  4. package/dist/components/actions/AsyncButton.js +48 -0
  5. package/dist/components/actions/CopyButton.d.ts +3 -3
  6. package/dist/components/actions/CopyButton.js +9 -12
  7. package/dist/components/actions/InlineConfirm.d.ts +45 -0
  8. package/dist/components/actions/InlineConfirm.js +80 -0
  9. package/dist/components/actions/Pagination.d.ts +40 -17
  10. package/dist/components/actions/Pagination.js +25 -18
  11. package/dist/components/actions/PaginationButton.d.ts +25 -0
  12. package/dist/components/actions/PaginationButton.js +23 -0
  13. package/dist/components/actions/PaginationStatus.d.ts +7 -4
  14. package/dist/components/actions/PaginationStatus.js +7 -5
  15. package/dist/components/actions/StatusOutput.d.ts +20 -0
  16. package/dist/components/actions/StatusOutput.js +9 -0
  17. package/dist/components/actions/pages.d.ts +21 -0
  18. package/dist/components/actions/pages.js +21 -0
  19. package/dist/components/actions/useLatestStatus.d.ts +26 -0
  20. package/dist/components/actions/useLatestStatus.js +32 -0
  21. package/dist/components/basics/AutoLink.d.ts +21 -0
  22. package/dist/components/basics/AutoLink.js +26 -0
  23. package/dist/components/basics/Badge.d.ts +22 -0
  24. package/dist/components/basics/Badge.js +18 -0
  25. package/dist/components/basics/ButtonLink.d.ts +5 -6
  26. package/dist/components/basics/ButtonLink.js +5 -11
  27. package/dist/components/basics/Card.d.ts +4 -3
  28. package/dist/components/basics/Card.js +3 -2
  29. package/dist/components/basics/Disclosure.d.ts +34 -0
  30. package/dist/components/basics/Disclosure.js +30 -0
  31. package/dist/components/basics/Notice.d.ts +2 -1
  32. package/dist/components/basics/TextLink.d.ts +21 -0
  33. package/dist/components/basics/TextLink.js +19 -0
  34. package/dist/components/basics/buttonStyles.d.ts +3 -1
  35. package/dist/components/basics/buttonStyles.js +1 -1
  36. package/dist/components/basics/cardStyles.d.ts +14 -0
  37. package/dist/components/basics/cardStyles.js +12 -0
  38. package/dist/components/basics/linkStyles.d.ts +23 -0
  39. package/dist/components/basics/linkStyles.js +23 -0
  40. package/dist/components/filters/Chip.d.ts +13 -5
  41. package/dist/components/filters/Chip.js +20 -11
  42. package/dist/components/filters/ChipGroup.d.ts +11 -12
  43. package/dist/components/filters/ChipGroup.js +5 -10
  44. package/dist/components/filters/ChoiceChips.d.ts +38 -0
  45. package/dist/components/filters/ChoiceChips.js +31 -0
  46. package/dist/components/filters/FilterPanel.d.ts +5 -4
  47. package/dist/components/filters/FilterPanel.js +5 -4
  48. package/dist/components/filters/FilterRow.d.ts +1 -1
  49. package/dist/components/filters/FilterRow.js +3 -2
  50. package/dist/components/filters/GroupFrame.d.ts +27 -0
  51. package/dist/components/filters/GroupFrame.js +27 -0
  52. package/dist/components/filters/RangeBox.d.ts +28 -0
  53. package/dist/components/filters/RangeBox.js +41 -0
  54. package/dist/components/filters/RangeSlider.d.ts +13 -14
  55. package/dist/components/filters/RangeSlider.js +40 -103
  56. package/dist/components/filters/chipStyles.d.ts +17 -0
  57. package/dist/components/filters/chipStyles.js +17 -0
  58. package/dist/components/filters/rangeMath.d.ts +106 -0
  59. package/dist/components/filters/rangeMath.js +146 -0
  60. package/dist/components/forms/Checkbox.d.ts +5 -4
  61. package/dist/components/forms/Checkbox.js +3 -3
  62. package/dist/components/forms/FieldFrame.d.ts +28 -2
  63. package/dist/components/forms/FieldFrame.js +14 -1
  64. package/dist/components/forms/RadioGroup.d.ts +46 -0
  65. package/dist/components/forms/RadioGroup.js +32 -0
  66. package/dist/components/forms/Select.d.ts +1 -1
  67. package/dist/components/forms/Select.js +2 -2
  68. package/dist/components/forms/TextInput.d.ts +1 -1
  69. package/dist/components/forms/TextInput.js +2 -2
  70. package/dist/components/forms/Textarea.d.ts +1 -1
  71. package/dist/components/forms/Textarea.js +2 -2
  72. package/dist/components/forms/TypeToConfirm.d.ts +40 -0
  73. package/dist/components/forms/TypeToConfirm.js +46 -0
  74. package/dist/components/forms/fieldStyles.d.ts +5 -2
  75. package/dist/components/forms/fieldStyles.js +5 -2
  76. package/dist/components/icons/DiscordIcon.d.ts +21 -0
  77. package/dist/components/icons/DiscordIcon.js +10 -0
  78. package/dist/components/{actions → meta}/JsonLd.d.ts +2 -2
  79. package/dist/components/osu/BeatmapStats.d.ts +36 -0
  80. package/dist/components/osu/BeatmapStats.js +44 -0
  81. package/dist/components/osu/ModBadge.d.ts +23 -0
  82. package/dist/components/osu/ModBadge.js +22 -0
  83. package/dist/components/osu/StarRating.d.ts +26 -0
  84. package/dist/components/osu/StarRating.js +17 -0
  85. package/dist/components/osu/starColors.d.ts +23 -0
  86. package/dist/components/osu/starColors.js +48 -0
  87. package/dist/components/shell/HeaderMenu.d.ts +39 -0
  88. package/dist/components/shell/HeaderMenu.js +58 -0
  89. package/dist/components/shell/LinkNote.d.ts +16 -0
  90. package/dist/components/shell/LinkNote.js +17 -0
  91. package/dist/components/shell/LinkTabs.d.ts +29 -0
  92. package/dist/components/shell/LinkTabs.js +15 -0
  93. package/dist/components/shell/NavItem.d.ts +1 -7
  94. package/dist/components/shell/NavItem.js +5 -10
  95. package/dist/components/shell/NavLinks.d.ts +1 -1
  96. package/dist/components/shell/NavLinks.js +3 -3
  97. package/dist/components/shell/NavListClient.d.ts +1 -1
  98. package/dist/components/shell/NavListClient.js +4 -4
  99. package/dist/components/shell/SiteFooter.d.ts +11 -6
  100. package/dist/components/shell/SiteFooter.js +18 -7
  101. package/dist/components/shell/links.d.ts +8 -1
  102. package/dist/components/shell/links.js +8 -1
  103. package/dist/components/tables/TBody.d.ts +16 -0
  104. package/dist/components/tables/TBody.js +10 -0
  105. package/dist/components/tables/THead.d.ts +16 -0
  106. package/dist/components/tables/THead.js +10 -0
  107. package/dist/components/tables/Table.d.ts +26 -0
  108. package/dist/components/tables/Table.js +12 -0
  109. package/dist/components/tables/Td.d.ts +19 -0
  110. package/dist/components/tables/Td.js +11 -0
  111. package/dist/components/tables/Th.d.ts +20 -0
  112. package/dist/components/tables/Th.js +11 -0
  113. package/dist/components/tables/tableStyles.d.ts +12 -0
  114. package/dist/components/tables/tableStyles.js +12 -0
  115. package/dist/index.d.ts +24 -2
  116. package/dist/index.js +28 -2
  117. package/dist/utils/href.d.ts +7 -4
  118. package/dist/utils/href.js +23 -7
  119. package/package.json +3 -3
  120. package/dist/components/shell/AutoLink.d.ts +0 -19
  121. package/dist/components/shell/AutoLink.js +0 -23
  122. /package/dist/components/{actions → meta}/JsonLd.js +0 -0
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @file src/components/filters/ChoiceChips.tsx
3
+ * @desc One choice from a few, as a real radio group drawn as chips (the pools status and type
4
+ * filters): native radio inputs under one name, so Tab reaches the checked chip, the arrow
5
+ * keys move and pick, and screen readers hear "radio, 1 of 3, checked". Same look as Chip,
6
+ * with the focus ring on the chip, not the hidden input. A labelled fieldset like ChipGroup.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ "use client";
12
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
+ import { useId } from "react";
14
+ import { cx } from "../../utils/cx.js";
15
+ import { CHIP, CHIP_OFF, CHIP_ON, CHIP_UNAVAILABLE } from "./chipStyles.js";
16
+ import { GroupFrame } from "./GroupFrame.js";
17
+ // A label around a hidden radio: it shows the input's focus and disabled state itself.
18
+ const LABEL = "cursor-pointer has-focus-visible:outline-2 has-focus-visible:outline-h1 has-focus-visible:outline-offset-2 has-disabled:cursor-not-allowed has-disabled:opacity-40";
19
+ /**
20
+ * @function ChoiceChips
21
+ * @param props {ChoiceChipsProps} label, options, the picked value and a change handler, plus
22
+ * native fieldset props (`disabled` turns off every chip). Render it from client code.
23
+ * @returns {JSX.Element} a `<fieldset>` named by its label, with one radio chip per option
24
+ */
25
+ export function ChoiceChips({ options, value, onChange, name, ...props }) {
26
+ const generated = useId();
27
+ return (_jsx(GroupFrame, { ...props, children: _jsx("div", { className: "flex flex-wrap items-center gap-1", children: options.map((option) => {
28
+ const on = option.value === value;
29
+ return (_jsxs("label", { className: cx(CHIP, LABEL, on ? CHIP_ON : CHIP_OFF, !on && !option.disabled && "hover:bg-b2", option.disabled && CHIP_UNAVAILABLE), children: [_jsx("input", { type: "radio", name: name ?? generated, value: option.value, checked: on, disabled: option.disabled, onChange: () => onChange(option.value), className: "sr-only" }), option.label] }, option.value));
30
+ }) }) }));
31
+ }
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * @file src/components/filters/FilterPanel.tsx
3
3
  * @desc Filter panel (osu! beatmap listing style): a titled card of FilterRows with a live result
4
- * count and a "Clear filters" link. On phones the rows fold away behind a disclosure button;
5
- * from `sm` up they are always shown.
4
+ * count and a "Clear filters" button. On phones the rows fold away behind a disclosure
5
+ * button; from `sm` up they are always shown.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
10
  import { type ComponentProps, type ReactNode } from "react";
11
+ import { type HeadingLevel } from "../basics/cardStyles.js";
11
12
  /** Every native `<section>` prop except `title`, plus the panel's title, count and clear action. */
12
13
  export type FilterPanelProps = Omit<ComponentProps<"section">, "title"> & {
13
14
  /** The panel heading; it also names the panel and its phone disclosure button. */
14
15
  title: ReactNode;
15
16
  /** Heading level for the title (default 2). */
16
- headingLevel?: 2 | 3 | 4 | 5 | 6 | undefined;
17
+ headingLevel?: HeadingLevel | undefined;
17
18
  /** Shown in an `<output aria-live="polite">`, so screen readers hear each new count. */
18
19
  resultCount?: ReactNode;
19
20
  /** True while any filter is set; shows the clear button. */
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * @file src/components/filters/FilterPanel.tsx
3
3
  * @desc Filter panel (osu! beatmap listing style): a titled card of FilterRows with a live result
4
- * count and a "Clear filters" link. On phones the rows fold away behind a disclosure button;
5
- * from `sm` up they are always shown.
4
+ * count and a "Clear filters" button. On phones the rows fold away behind a disclosure
5
+ * button; from `sm` up they are always shown.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
10
  "use client";
11
11
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  import { useEffect, useId, useRef, useState } from "react";
13
13
  import { cx } from "../../utils/cx.js";
14
+ import { CARD, CARD_HEADING } from "../basics/cardStyles.js";
14
15
  /**
15
16
  * @function FilterPanel
16
17
  * @param props {FilterPanelProps} title, FilterRows as children, an optional result count and
@@ -38,7 +39,7 @@ export function FilterPanel({ title, headingLevel = 2, resultCount, active = fal
38
39
  cleared.current = false;
39
40
  }, [showClear]);
40
41
  const Heading = `h${headingLevel}`;
41
- return (_jsxs("section", { "aria-labelledby": titleId, className: cx("flex flex-col gap-4 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
+ return (_jsxs("section", { "aria-labelledby": titleId, className: cx("flex flex-col gap-4", CARD, className), ...props, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Heading, { id: titleId, ref: headingRef, tabIndex: -1, className: CARD_HEADING, children: title }), _jsx("button", { type: "button", "aria-labelledby": titleId, "aria-expanded": open, "aria-controls": bodyId, onClick: () => setOpen(!open), className: "inline-flex size-8 items-center justify-center rounded-full text-c2 transition-colors hover:bg-b3 hover:text-c1 sm:hidden", children: _jsx("span", { "aria-hidden": "true", children: open ? "▴" : "▾" }) })] }), _jsx("output", { "aria-live": "polite", className: "text-c3 text-sm", children: resultCount }), showClear ? (_jsx("button", { type: "button", onClick: () => {
42
43
  cleared.current = true;
43
44
  onClear();
44
45
  }, className: "ml-auto font-bold text-h1 text-sm transition-colors hover:text-c1", children: clearLabel })) : null] }), _jsx("div", { id: bodyId, className: cx("flex-col gap-3", open ? "flex" : "hidden sm:flex"), children: children })] }));
@@ -4,7 +4,7 @@
4
4
  * the left and the controls on the right from `sm` up, stacked on phones.
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
7
+ * @modified Mon Sep 28, 2026
8
8
  */
9
9
  import { type ComponentProps, type ReactNode } from "react";
10
10
  /** Every native `<fieldset>` prop (including `ref`), plus the row's label. */
@@ -5,10 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  * the left and the controls on the right from `sm` up, stacked on phones.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
8
+ * @modified Mon Sep 28, 2026
9
9
  */
10
10
  import { useId } from "react";
11
11
  import { cx } from "../../utils/cx.js";
12
+ import { FIELD_LABEL } from "../forms/fieldStyles.js";
12
13
  /**
13
14
  * @function FilterRow
14
15
  * @param props {FilterRowProps} the label, the row's controls as children, and native fieldset
@@ -18,5 +19,5 @@ import { cx } from "../../utils/cx.js";
18
19
  */
19
20
  export function FilterRow({ label, className, children, ...props }) {
20
21
  const labelId = useId();
21
- return (_jsxs("fieldset", { "aria-labelledby": labelId, className: cx("flex flex-col gap-2 sm:flex-row sm:items-baseline sm:gap-4", className), ...props, children: [_jsx("span", { id: labelId, className: "shrink-0 font-bold text-c3 text-sm sm:w-28", children: label }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
22
+ return (_jsxs("fieldset", { "aria-labelledby": labelId, className: cx("flex flex-col gap-2 sm:flex-row sm:items-baseline sm:gap-4", className), ...props, children: [_jsx("span", { id: labelId, className: `shrink-0 sm:w-28 ${FIELD_LABEL}`, children: label }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
22
23
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file src/components/filters/GroupFrame.tsx
3
+ * @desc The labelled fieldset that ChipGroup, ChoiceChips and RangeSlider share (internal): a
4
+ * `<fieldset>` named by a bold label, or, with `hideLabel`, a bare fieldset with role none
5
+ * that leaves the name to a surrounding FilterRow. Server-safe.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { type ComponentProps, type ReactNode } from "react";
11
+ /** Every native `<fieldset>` prop, plus the group's label and whether a FilterRow names it. */
12
+ export type GroupFrameProps = ComponentProps<"fieldset"> & {
13
+ /** Names the group (`aria-labelledby`), shown above the controls. */
14
+ label: ReactNode;
15
+ /**
16
+ * Leave the name to a surrounding FilterRow: no label shows, and the fieldset is not a group
17
+ * of its own (role none), so screen readers hear the row's name once.
18
+ */
19
+ hideLabel?: boolean | undefined;
20
+ };
21
+ /**
22
+ * @function GroupFrame
23
+ * @param props {GroupFrameProps} the label, hideLabel, the controls as children, and native
24
+ * fieldset props (`className` merges last)
25
+ * @returns {JSX.Element} a `<fieldset>` labelled by its label, or with role none and no label
26
+ */
27
+ export declare function GroupFrame({ label, hideLabel, className, children, ...props }: GroupFrameProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @file src/components/filters/GroupFrame.tsx
4
+ * @desc The labelled fieldset that ChipGroup, ChoiceChips and RangeSlider share (internal): a
5
+ * `<fieldset>` named by a bold label, or, with `hideLabel`, a bare fieldset with role none
6
+ * that leaves the name to a surrounding FilterRow. Server-safe.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import { useId } from "react";
12
+ import { cx } from "../../utils/cx.js";
13
+ import { FIELD_LABEL } from "../forms/fieldStyles.js";
14
+ /**
15
+ * @function GroupFrame
16
+ * @param props {GroupFrameProps} the label, hideLabel, the controls as children, and native
17
+ * fieldset props (`className` merges last)
18
+ * @returns {JSX.Element} a `<fieldset>` labelled by its label, or with role none and no label
19
+ */
20
+ export function GroupFrame({ label, hideLabel = false, className, children, ...props }) {
21
+ const labelId = useId();
22
+ return (_jsxs("fieldset", {
23
+ // Inside a FilterRow (hideLabel), the row's fieldset is the group. A second group with the
24
+ // same name would be read twice. The fieldset stays, so `disabled` still reaches every
25
+ // control inside it.
26
+ role: hideLabel ? "none" : undefined, "aria-labelledby": hideLabel ? undefined : labelId, className: cx("flex flex-col gap-2", className), ...props, children: [hideLabel ? null : (_jsx("span", { id: labelId, className: FIELD_LABEL, children: label })), children] }));
27
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file src/components/filters/RangeBox.tsx
3
+ * @desc One of RangeSlider's two edit boxes (internal): a short text field on the shared field
4
+ * look that holds what is being typed, commits it on blur or Enter, and puts the value
5
+ * back on Escape.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { type HTMLAttributes } from "react";
11
+ /** The box's name, the value it shows, its keyboard and state, and what to do with typing. */
12
+ export type RangeBoxProps = {
13
+ /** The accessible name ("Minimum Star rating"). */
14
+ label: string;
15
+ /** The current value as display text, shown while nothing is being typed. */
16
+ text: string;
17
+ inputMode: HTMLAttributes<HTMLInputElement>["inputMode"];
18
+ disabled: boolean;
19
+ /** Gets the typed text on blur or Enter, when there is any. */
20
+ onCommit: (draft: string) => void;
21
+ };
22
+ /**
23
+ * @function RangeBox
24
+ * @param props {RangeBoxProps} the name, the shown text, the input mode, disabled, and the
25
+ * commit handler
26
+ * @returns {JSX.Element} a text `<input>` that shows the value, or the typing in progress
27
+ */
28
+ export declare function RangeBox({ label, text, inputMode, disabled, onCommit }: RangeBoxProps): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @file src/components/filters/RangeBox.tsx
3
+ * @desc One of RangeSlider's two edit boxes (internal): a short text field on the shared field
4
+ * look that holds what is being typed, commits it on blur or Enter, and puts the value
5
+ * back on Escape.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ "use client";
11
+ import { jsx as _jsx } from "react/jsx-runtime";
12
+ import { useState } from "react";
13
+ import { fieldClasses } from "../forms/fieldStyles.js";
14
+ // The field look, sized for a short value. The slider's group dims itself when disabled, so the
15
+ // box keeps full opacity instead of dimming twice.
16
+ const BOX = fieldClasses("w-18 shrink-0 px-2 py-1 text-center tabular-nums disabled:cursor-not-allowed disabled:opacity-100");
17
+ /**
18
+ * @function RangeBox
19
+ * @param props {RangeBoxProps} the name, the shown text, the input mode, disabled, and the
20
+ * commit handler
21
+ * @returns {JSX.Element} a text `<input>` that shows the value, or the typing in progress
22
+ */
23
+ export function RangeBox({ label, text, inputMode, disabled, onCommit }) {
24
+ const [draft, setDraft] = useState(null);
25
+ const commit = () => {
26
+ if (draft === null)
27
+ return;
28
+ setDraft(null);
29
+ onCommit(draft);
30
+ };
31
+ return (_jsx("input", { type: "text", inputMode: inputMode, autoComplete: "off", "aria-label": label, disabled: disabled, value: draft ?? text, onChange: (event) => setDraft(event.currentTarget.value), onBlur: commit, onKeyDown: (event) => {
32
+ if (event.key === "Enter") {
33
+ event.preventDefault();
34
+ commit();
35
+ }
36
+ else if (event.key === "Escape" && draft !== null) {
37
+ event.preventDefault();
38
+ setDraft(null);
39
+ }
40
+ }, className: BOX }));
41
+ }
@@ -3,26 +3,25 @@
3
3
  * @desc Two-thumb range slider with an editable box at each end (star rating, length, BPM). Two
4
4
  * native range inputs share one track; the thumbs can't cross. With `openEnded`, the top
5
5
  * end at `max` means "no upper limit": it shows "max+" and reports `null`. When both thumbs
6
- * sit on one value, a drag moves whichever end can go the way the pointer goes.
6
+ * sit on one value, a drag moves whichever end can go the way the pointer goes. The math
7
+ * lives in rangeMath.ts and the boxes in RangeBox.tsx.
7
8
  * @author David @dvhsh (https://dvh.sh)
8
9
  * @created Wed Sep 23, 2026
9
- * @modified Wed Sep 23, 2026
10
+ * @modified Mon Sep 28, 2026
10
11
  */
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"> & {
12
+ import type { HTMLAttributes } from "react";
13
+ import { type GroupFrameProps } from "./GroupFrame.js";
14
+ import { type RangeSliderValue } from "./rangeMath.js";
15
+ export type { RangeSliderValue };
16
+ /** Every native `<fieldset>` prop except `onChange`, `children` and `inputMode`, plus the range. */
17
+ export type RangeSliderProps = Omit<GroupFrameProps, "onChange" | "children" | "inputMode"> & {
16
18
  /** Names the group, and the ends as "Minimum <label>" and "Maximum <label>". */
17
19
  label: string;
18
- /**
19
- * Leave the group name to a surrounding FilterRow: no label shows, and the fieldset is not a
20
- * group of its own (role none). The ends are still named from `label`.
21
- */
22
- hideLabel?: boolean | undefined;
20
+ /** The low bound. If it is above `max`, the two swap. */
23
21
  min: number;
22
+ /** The high bound. */
24
23
  max: number;
25
- /** Step between values (default 1). Typed values snap to it. */
24
+ /** Step between values (default 1; 0, negative or NaN also mean 1). Typed values snap to it. */
26
25
  step?: number | undefined;
27
26
  /** The current range. A `null` top end means open (shown at `max`). */
28
27
  value: Readonly<RangeSliderValue>;
@@ -59,4 +58,4 @@ export type RangeSliderProps = Omit<ComponentProps<"fieldset">, "onChange" | "ch
59
58
  * Page Up/Down ten steps, Home/End as far as it can go. The boxes commit on blur or
60
59
  * Enter; Escape puts the value back.
61
60
  */
62
- export declare function RangeSlider({ label, hideLabel, min, max, step, value, onChange, format, parse, inputMode, openEnded, minLabel, maxLabel, disabled, className, ...props }: RangeSliderProps): import("react").JSX.Element;
61
+ export declare function RangeSlider({ label, min: rawMin, max: rawMax, step: rawStep, value, onChange, format, parse, inputMode, openEnded, minLabel, maxLabel, disabled, className, ...props }: RangeSliderProps): import("react").JSX.Element;
@@ -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 Wed Sep 23, 2026
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 { useId, useRef, useState, } from "react";
14
+ import { useRef } from "react";
14
15
  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
- };
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, 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 });
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
- // 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);
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
- 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]);
46
+ const next = nextRange(end, n, state, bounds, openEnded);
47
+ if (next)
48
+ onChange(next);
78
49
  };
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))
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 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];
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
- // 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.
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
- 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
- }
77
+ if (gesture && gesture.end === null)
78
+ gesture.end = dragEnd(end, n, state);
150
79
  commit(gesture?.end ?? end, n);
151
80
  };
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")] })] }));
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;