@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.
- package/CHANGELOG.md +50 -1
- package/README.md +265 -29
- 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/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/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/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/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/fieldStyles.d.ts +5 -2
- package/dist/components/forms/fieldStyles.js +5 -2
- package/dist/components/icons/DiscordIcon.d.ts +21 -0
- package/dist/components/icons/DiscordIcon.js +10 -0
- 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 +11 -6
- package/dist/components/shell/SiteFooter.js +18 -7
- 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 +24 -2
- package/dist/index.js +28 -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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/AsyncButton.tsx
|
|
3
|
+
* @desc A button that runs an async action and says how it went in an <output> beside it (a
|
|
4
|
+
* polite live region), like the apps' refresh and retry buttons. While the action runs the
|
|
5
|
+
* button stays focusable but ignores presses (aria-disabled), and can show a pending label.
|
|
6
|
+
* A rejected action shows the failure message in rose.
|
|
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 { useRef, useState } from "react";
|
|
14
|
+
import { cx } from "../../utils/cx.js";
|
|
15
|
+
import { Button } from "../basics/Button.js";
|
|
16
|
+
import { StatusOutput } from "./StatusOutput.js";
|
|
17
|
+
import { useLatestStatus } from "./useLatestStatus.js";
|
|
18
|
+
/**
|
|
19
|
+
* @function AsyncButton
|
|
20
|
+
* @param props {AsyncButtonProps} the action, an optional pending label and failure message, plus
|
|
21
|
+
* Button props (`className` and `ref` go on the button)
|
|
22
|
+
* @returns {JSX.Element} the button and an `<output>` that announces the action's result
|
|
23
|
+
*/
|
|
24
|
+
export function AsyncButton({ action, pendingLabel, failedMessage = "Something went wrong. Try again.", wrapperClassName, className, children, ...props }) {
|
|
25
|
+
const [pending, setPending] = useState(false);
|
|
26
|
+
// `pending` reaches the button only after a render; two presses in one frame must not both run.
|
|
27
|
+
const running = useRef(false);
|
|
28
|
+
const { status, start, settle } = useLatestStatus();
|
|
29
|
+
const run = async () => {
|
|
30
|
+
if (running.current)
|
|
31
|
+
return;
|
|
32
|
+
running.current = true;
|
|
33
|
+
setPending(true);
|
|
34
|
+
const id = start();
|
|
35
|
+
try {
|
|
36
|
+
settle(id, { message: await action(), failed: false });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const message = typeof failedMessage === "function" ? failedMessage(error) : failedMessage;
|
|
40
|
+
settle(id, { message, failed: true });
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
running.current = false;
|
|
44
|
+
setPending(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
return (_jsxs("div", { className: cx("flex flex-wrap items-center gap-3", wrapperClassName), children: [_jsx(Button, { "aria-disabled": pending || undefined, onClick: run, className: cx("aria-disabled:cursor-wait aria-disabled:opacity-70", className), ...props, children: pending && pendingLabel !== undefined ? pendingLabel : children }), _jsx(StatusOutput, { run: status?.run ?? null, children: status?.result.failed ? (_jsx("span", { className: "text-rose-300", children: status.result.message })) : (status?.result.message) })] }));
|
|
48
|
+
}
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* it (a polite live region), like the packs export and doc pages. If the clipboard is
|
|
5
5
|
* missing or refuses, it says so and tells the reader to copy by hand. Each press empties
|
|
6
6
|
* the status first and then writes the result as a new node, so a second copy is announced
|
|
7
|
-
* too.
|
|
7
|
+
* too. Only the latest press reports: a slow earlier copy that settles later is ignored.
|
|
8
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
9
|
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
13
|
import { type ButtonProps } from "../basics/Button.js";
|
|
14
14
|
/** Every Button prop (native button props, variant, size) except children and onClick. */
|
|
15
15
|
export type CopyButtonProps = Omit<ButtonProps, "children" | "onClick"> & {
|
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
* it (a polite live region), like the packs export and doc pages. If the clipboard is
|
|
5
5
|
* missing or refuses, it says so and tells the reader to copy by hand. Each press empties
|
|
6
6
|
* the status first and then writes the result as a new node, so a second copy is announced
|
|
7
|
-
* too.
|
|
7
|
+
* too. Only the latest press reports: a slow earlier copy that settles later is ignored.
|
|
8
8
|
* @author David @dvhsh (https://dvh.sh)
|
|
9
9
|
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified
|
|
10
|
+
* @modified Mon Sep 28, 2026
|
|
11
11
|
*/
|
|
12
12
|
"use client";
|
|
13
13
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
|
-
import { useRef, useState } from "react";
|
|
15
14
|
import { cx } from "../../utils/cx.js";
|
|
16
15
|
import { Button } from "../basics/Button.js";
|
|
16
|
+
import { StatusOutput } from "./StatusOutput.js";
|
|
17
|
+
import { useLatestStatus } from "./useLatestStatus.js";
|
|
17
18
|
/**
|
|
18
19
|
* @function CopyButton
|
|
19
20
|
* @param props {CopyButtonProps} the text to copy, optional label and status messages, plus
|
|
@@ -21,20 +22,16 @@ import { Button } from "../basics/Button.js";
|
|
|
21
22
|
* @returns {JSX.Element} the button and an `<output>` that announces "Copied." or the failure
|
|
22
23
|
*/
|
|
23
24
|
export function CopyButton({ text, label = "Copy", copiedMessage = "Copied.", failedMessage = "Couldn't copy. Select the text and copy it by hand.", wrapperClassName, variant = "secondary", ...props }) {
|
|
24
|
-
const
|
|
25
|
-
const presses = useRef(0);
|
|
25
|
+
const { status, start, settle } = useLatestStatus();
|
|
26
26
|
const copy = async () => {
|
|
27
|
-
|
|
28
|
-
// same "Copied." twice) is not announced again.
|
|
29
|
-
setStatus(null);
|
|
30
|
-
const press = ++presses.current;
|
|
27
|
+
const run = start();
|
|
31
28
|
try {
|
|
32
29
|
await navigator.clipboard.writeText(text);
|
|
33
|
-
|
|
30
|
+
settle(run, "copied");
|
|
34
31
|
}
|
|
35
32
|
catch {
|
|
36
|
-
|
|
33
|
+
settle(run, "failed");
|
|
37
34
|
}
|
|
38
35
|
};
|
|
39
|
-
return (_jsxs("div", { className: cx("flex flex-wrap items-center gap-3", wrapperClassName), children: [_jsx(Button, { variant: variant, onClick: copy, ...props, children: label }), _jsx(
|
|
36
|
+
return (_jsxs("div", { className: cx("flex flex-wrap items-center gap-3", wrapperClassName), children: [_jsx(Button, { variant: variant, onClick: copy, ...props, children: label }), _jsx(StatusOutput, { run: status?.run ?? null, children: status?.result === "copied" ? copiedMessage : failedMessage })] }));
|
|
40
37
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/InlineConfirm.tsx
|
|
3
|
+
* @desc A two-step confirm in the page, no dialog: a trigger button, then the question with a
|
|
4
|
+
* cancel and a confirm button in its place. Focus never falls to the page body: opening
|
|
5
|
+
* moves it to cancel (the safe choice), and cancelling or Escape puts it back on the
|
|
6
|
+
* trigger. The open confirm is a fieldset named by the question, so screen readers hear it.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
12
|
+
import { type ButtonProps } from "../basics/Button.js";
|
|
13
|
+
import type { ButtonVariant } from "../basics/buttonStyles.js";
|
|
14
|
+
/**
|
|
15
|
+
* Every native `<div>` prop except children and ref (the wrapper is a `<div>` closed and a
|
|
16
|
+
* `<fieldset>` open), plus the two steps' text and the action.
|
|
17
|
+
*/
|
|
18
|
+
export type InlineConfirmProps = Omit<ComponentProps<"div">, "children" | "ref"> & {
|
|
19
|
+
/** The first button's text, e.g. "Delete". */
|
|
20
|
+
trigger: ReactNode;
|
|
21
|
+
/** Shown once the trigger is pressed, e.g. "Delete this pack for good?". Names the group. */
|
|
22
|
+
question: ReactNode;
|
|
23
|
+
/** The confirm button's text (default "Confirm"). */
|
|
24
|
+
confirmLabel?: ReactNode;
|
|
25
|
+
/** The cancel button's text (default "Cancel"). */
|
|
26
|
+
cancelLabel?: ReactNode;
|
|
27
|
+
/** The confirm button's text while `onConfirm` runs. Defaults to `confirmLabel`. */
|
|
28
|
+
pendingLabel?: ReactNode;
|
|
29
|
+
/** Runs on confirm. If it throws or rejects, the confirm stays open: show the error yourself. */
|
|
30
|
+
onConfirm: () => void | Promise<void>;
|
|
31
|
+
/** Called when the confirm closes without confirming (Cancel or Escape). */
|
|
32
|
+
onCancel?: (() => void) | undefined;
|
|
33
|
+
/** Props for the trigger button (`aria-label`, `variant`, `disabled`...). Variant "secondary". */
|
|
34
|
+
triggerProps?: Omit<ButtonProps, "onClick" | "children" | "ref"> | undefined;
|
|
35
|
+
/** The confirm button's variant (default "secondary"). Cancel is always "ghost". */
|
|
36
|
+
confirmVariant?: ButtonVariant | undefined;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* @function InlineConfirm
|
|
40
|
+
* @param props {InlineConfirmProps} the trigger, question and button texts, the confirm action,
|
|
41
|
+
* plus native div props for the wrapper
|
|
42
|
+
* @returns {JSX.Element} the trigger, or the question with cancel and confirm buttons. After a
|
|
43
|
+
* confirm that resolves, the trigger comes back and takes focus.
|
|
44
|
+
*/
|
|
45
|
+
export declare function InlineConfirm({ trigger, question, confirmLabel, cancelLabel, pendingLabel, onConfirm, onCancel, triggerProps, confirmVariant, className, ...props }: InlineConfirmProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/InlineConfirm.tsx
|
|
3
|
+
* @desc A two-step confirm in the page, no dialog: a trigger button, then the question with a
|
|
4
|
+
* cancel and a confirm button in its place. Focus never falls to the page body: opening
|
|
5
|
+
* moves it to cancel (the safe choice), and cancelling or Escape puts it back on the
|
|
6
|
+
* trigger. The open confirm is a fieldset named by the question, so screen readers hear it.
|
|
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 { useEffect, useId, useRef, useState, } from "react";
|
|
14
|
+
import { cx } from "../../utils/cx.js";
|
|
15
|
+
import { Button } from "../basics/Button.js";
|
|
16
|
+
/**
|
|
17
|
+
* @function InlineConfirm
|
|
18
|
+
* @param props {InlineConfirmProps} the trigger, question and button texts, the confirm action,
|
|
19
|
+
* plus native div props for the wrapper
|
|
20
|
+
* @returns {JSX.Element} the trigger, or the question with cancel and confirm buttons. After a
|
|
21
|
+
* confirm that resolves, the trigger comes back and takes focus.
|
|
22
|
+
*/
|
|
23
|
+
export function InlineConfirm({ trigger, question, confirmLabel = "Confirm", cancelLabel = "Cancel", pendingLabel, onConfirm, onCancel, triggerProps, confirmVariant = "secondary", className, ...props }) {
|
|
24
|
+
const questionId = useId();
|
|
25
|
+
const [open, setOpen] = useState(false);
|
|
26
|
+
const [pending, setPending] = useState(false);
|
|
27
|
+
const running = useRef(false);
|
|
28
|
+
const triggerRef = useRef(null);
|
|
29
|
+
const cancelRef = useRef(null);
|
|
30
|
+
// Whether the next render should move focus: set only by the person's own open or close.
|
|
31
|
+
const moveFocus = useRef(false);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!moveFocus.current)
|
|
34
|
+
return;
|
|
35
|
+
moveFocus.current = false;
|
|
36
|
+
(open ? cancelRef : triggerRef).current?.focus();
|
|
37
|
+
}, [open]);
|
|
38
|
+
const toggle = (next) => {
|
|
39
|
+
moveFocus.current = true;
|
|
40
|
+
setOpen(next);
|
|
41
|
+
};
|
|
42
|
+
const cancel = () => {
|
|
43
|
+
if (running.current)
|
|
44
|
+
return;
|
|
45
|
+
toggle(false);
|
|
46
|
+
onCancel?.();
|
|
47
|
+
};
|
|
48
|
+
const confirm = async () => {
|
|
49
|
+
// `pending` reaches the buttons only after a render; two presses must not both run.
|
|
50
|
+
if (running.current)
|
|
51
|
+
return;
|
|
52
|
+
running.current = true;
|
|
53
|
+
setPending(true);
|
|
54
|
+
try {
|
|
55
|
+
await onConfirm();
|
|
56
|
+
toggle(false);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Stay open, with focus where it is, so the person can try again or cancel.
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
running.current = false;
|
|
63
|
+
setPending(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const wrapper = cx("flex flex-wrap items-center gap-2", className);
|
|
67
|
+
if (!open) {
|
|
68
|
+
return (_jsx("div", { className: wrapper, ...props, children: _jsx(Button, { variant: "secondary", ...triggerProps, ref: triggerRef, onClick: () => toggle(true), children: trigger }) }));
|
|
69
|
+
}
|
|
70
|
+
// While the action runs the buttons ignore presses but keep focus (aria-disabled, not
|
|
71
|
+
// disabled, which would drop focus to the body).
|
|
72
|
+
const busy = pending || undefined;
|
|
73
|
+
const onEscape = (event) => {
|
|
74
|
+
if (event.key !== "Escape")
|
|
75
|
+
return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
cancel();
|
|
78
|
+
};
|
|
79
|
+
return (_jsxs("fieldset", { "aria-labelledby": questionId, className: wrapper, ...props, children: [_jsx("span", { id: questionId, className: "text-c3 text-sm", children: question }), _jsx(Button, { ref: cancelRef, variant: "ghost", "aria-disabled": busy, onClick: cancel, onKeyDown: onEscape, children: cancelLabel }), _jsx(Button, { variant: confirmVariant, "aria-disabled": busy, onClick: confirm, onKeyDown: onEscape, children: pending && pendingLabel !== undefined ? pendingLabel : confirmLabel })] }));
|
|
80
|
+
}
|
|
@@ -1,33 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/components/actions/Pagination.tsx
|
|
3
|
-
* @desc Previous / next
|
|
4
|
-
* caller builds each page's URL, so it works with any query string or route
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* @desc Previous / next around "Page X of Y". Renders nothing for a single page. Two modes: links
|
|
4
|
+
* (`hrefFor`, the caller builds each page's URL, so it works with any query string or route
|
|
5
|
+
* shape) or buttons (`onPageChange`, for results fetched in place, where the page count may
|
|
6
|
+
* be unknown). When a link a keyboard user pressed goes away (Next on the last page), focus
|
|
7
|
+
* moves to the status text instead of the page body; buttons stay and ignore presses. A page
|
|
8
|
+
* or count from a URL is normalized first: NaN reads as page 1, and a page past either end
|
|
9
|
+
* is pulled back inside. A server component; button mode needs client code for its handler.
|
|
7
10
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
11
|
* @created Wed Sep 23, 2026
|
|
9
|
-
* @modified
|
|
12
|
+
* @modified Mon Sep 28, 2026
|
|
10
13
|
*/
|
|
11
14
|
import type { ComponentProps, ReactNode } from "react";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** The current page, 1-based. */
|
|
15
|
+
type PaginationBase = Omit<ComponentProps<"nav">, "children"> & {
|
|
16
|
+
/** The current page, 1-based. NaN reads as 1; a page past either end is pulled back inside. */
|
|
15
17
|
page: number;
|
|
16
|
-
/**
|
|
18
|
+
/** Text of the control for the page before. Defaults to "Previous". */
|
|
19
|
+
previousLabel?: ReactNode | undefined;
|
|
20
|
+
/** Text of the control for the page after. Defaults to "Next". */
|
|
21
|
+
nextLabel?: ReactNode | undefined;
|
|
22
|
+
};
|
|
23
|
+
/** Link mode: each page has a URL. */
|
|
24
|
+
type PaginationLinkProps = PaginationBase & {
|
|
25
|
+
/** How many pages there are. At 1 or fewer (or NaN), nothing renders. */
|
|
17
26
|
pageCount: number;
|
|
18
27
|
/** Builds the URL for a page number, e.g. `(p) => \`/packs?page=${p}\``. */
|
|
19
28
|
hrefFor: (page: number) => string;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/** Text of the link to the page after. Defaults to "Next". */
|
|
23
|
-
nextLabel?: ReactNode | undefined;
|
|
29
|
+
onPageChange?: undefined;
|
|
30
|
+
hasNext?: undefined;
|
|
24
31
|
/** The middle text. Defaults to "Page X of Y". */
|
|
25
32
|
formatStatus?: ((page: number, pageCount: number) => ReactNode) | undefined;
|
|
26
33
|
};
|
|
34
|
+
/** Button mode: the caller changes the page in place. */
|
|
35
|
+
type PaginationButtonModeProps = PaginationBase & {
|
|
36
|
+
/** How many pages there are, or null when that isn't known (then `hasNext` says if there's more). */
|
|
37
|
+
pageCount: number | null;
|
|
38
|
+
/** Called with the page to show (from client code). */
|
|
39
|
+
onPageChange: (page: number) => void;
|
|
40
|
+
hrefFor?: undefined;
|
|
41
|
+
/** With a null `pageCount`: whether there is a page after this one. Default false. */
|
|
42
|
+
hasNext?: boolean | undefined;
|
|
43
|
+
/** The middle text. Defaults to "Page X of Y", or "Page X" with a null `pageCount`. */
|
|
44
|
+
formatStatus?: ((page: number, pageCount: number | null) => ReactNode) | undefined;
|
|
45
|
+
};
|
|
46
|
+
/** Every native `<nav>` prop except children, the page state, and `hrefFor` or `onPageChange`. */
|
|
47
|
+
export type PaginationProps = PaginationLinkProps | PaginationButtonModeProps;
|
|
27
48
|
/**
|
|
28
49
|
* @function Pagination
|
|
29
|
-
* @param props {PaginationProps} page, pageCount and hrefFor
|
|
30
|
-
* props (`aria-label` defaults to "Pages")
|
|
31
|
-
* @returns {JSX.Element | null} a `<nav>` with previous / next
|
|
50
|
+
* @param props {PaginationProps} page, pageCount, and hrefFor (links) or onPageChange (buttons),
|
|
51
|
+
* optional labels, plus native nav props (`aria-label` defaults to "Pages")
|
|
52
|
+
* @returns {JSX.Element | null} a `<nav>` with previous / next pills around the status, or null
|
|
53
|
+
* when there is only one page
|
|
32
54
|
*/
|
|
33
|
-
export declare function Pagination(
|
|
55
|
+
export declare function Pagination(props: PaginationProps): import("react").JSX.Element | null;
|
|
56
|
+
export {};
|
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* @file src/components/actions/Pagination.tsx
|
|
4
|
-
* @desc Previous / next links around "Page X of Y". Renders nothing for a single page. The
|
|
5
|
-
* caller builds each page's URL, so it works with any query string or route shape. When
|
|
6
|
-
* the link a keyboard user pressed goes away (Next on the last page), focus moves to the
|
|
7
|
-
* status text instead of the page body.
|
|
8
|
-
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
-
* @created Wed Sep 23, 2026
|
|
10
|
-
* @modified Wed Sep 23, 2026
|
|
11
|
-
*/
|
|
12
|
-
import Link from "next/link.js";
|
|
13
2
|
import { cx } from "../../utils/cx.js";
|
|
3
|
+
import { ButtonLink } from "../basics/ButtonLink.js";
|
|
14
4
|
import { buttonClasses } from "../basics/buttonStyles.js";
|
|
5
|
+
import { PaginationButton } from "./PaginationButton.js";
|
|
15
6
|
import { PaginationStatus } from "./PaginationStatus.js";
|
|
16
|
-
|
|
7
|
+
import { normalizePages } from "./pages.js";
|
|
8
|
+
const defaultStatus = (page, pageCount) => pageCount === null ? `Page ${page}` : `Page ${page} of ${pageCount}`;
|
|
9
|
+
// Button mode's ends stay in place, dimmed, and keep focus.
|
|
10
|
+
const BUTTON = buttonClasses({
|
|
11
|
+
variant: "secondary",
|
|
12
|
+
className: "aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
|
|
13
|
+
});
|
|
17
14
|
/**
|
|
18
15
|
* @function Pagination
|
|
19
|
-
* @param props {PaginationProps} page, pageCount and hrefFor
|
|
20
|
-
* props (`aria-label` defaults to "Pages")
|
|
21
|
-
* @returns {JSX.Element | null} a `<nav>` with previous / next
|
|
16
|
+
* @param props {PaginationProps} page, pageCount, and hrefFor (links) or onPageChange (buttons),
|
|
17
|
+
* optional labels, plus native nav props (`aria-label` defaults to "Pages")
|
|
18
|
+
* @returns {JSX.Element | null} a `<nav>` with previous / next pills around the status, or null
|
|
19
|
+
* when there is only one page
|
|
22
20
|
*/
|
|
23
|
-
export function Pagination(
|
|
24
|
-
|
|
21
|
+
export function Pagination(props) {
|
|
22
|
+
const { page: rawPage, pageCount: rawPageCount, hrefFor, onPageChange, hasNext = false, previousLabel = "Previous", nextLabel = "Next", formatStatus = defaultStatus, className, ...rest } = props;
|
|
23
|
+
const { page, pageCount } = normalizePages(rawPage, rawPageCount);
|
|
24
|
+
const more = pageCount === null ? hasNext : page < pageCount;
|
|
25
|
+
if (page <= 1 && !more)
|
|
25
26
|
return null;
|
|
26
|
-
|
|
27
|
+
// Link mode never has a null count, so both status formatters take this call.
|
|
28
|
+
const status = formatStatus(page, pageCount);
|
|
29
|
+
const nav = cx("flex items-center justify-between gap-3 text-sm", className);
|
|
30
|
+
if (onPageChange) {
|
|
31
|
+
return (_jsxs("nav", { "aria-label": "Pages", className: nav, ...rest, children: [_jsx(PaginationButton, { page: page - 1, off: page <= 1, onPageChange: onPageChange, className: BUTTON, children: previousLabel }), _jsx(PaginationStatus, { live: true, children: status }), _jsx(PaginationButton, { page: page + 1, off: !more, onPageChange: onPageChange, className: BUTTON, children: nextLabel })] }));
|
|
32
|
+
}
|
|
33
|
+
return (_jsxs("nav", { "aria-label": "Pages", className: nav, ...rest, children: [page > 1 && hrefFor ? (_jsx(ButtonLink, { href: hrefFor(page - 1), rel: "prev", variant: "secondary", children: previousLabel })) : (_jsx("span", {})), _jsx(PaginationStatus, { children: status }), more && hrefFor ? (_jsx(ButtonLink, { href: hrefFor(page + 1), rel: "next", variant: "secondary", children: nextLabel })) : (_jsx("span", {}))] }));
|
|
27
34
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/PaginationButton.tsx
|
|
3
|
+
* @desc Pagination's Previous or Next button in button mode (internal). At the first or last page
|
|
4
|
+
* it stays in place and keeps focus, but ignores presses (aria-disabled). Its classes arrive
|
|
5
|
+
* finished from Pagination, so it imports no class merging.
|
|
6
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
7
|
+
* @created Mon Sep 28, 2026
|
|
8
|
+
* @modified Mon Sep 28, 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
/** The page it goes to, whether it's off, the handler, finished classes and its text. */
|
|
12
|
+
export type PaginationButtonProps = {
|
|
13
|
+
page: number;
|
|
14
|
+
off: boolean;
|
|
15
|
+
onPageChange: (page: number) => void;
|
|
16
|
+
className: string;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* @function PaginationButton
|
|
21
|
+
* @param props {PaginationButtonProps} the target page, whether there is none, the page handler,
|
|
22
|
+
* classes and text
|
|
23
|
+
* @returns {JSX.Element} a `<button type="button">` that calls `onPageChange(page)` unless it's off
|
|
24
|
+
*/
|
|
25
|
+
export declare function PaginationButton({ page, off, onPageChange, className, children, }: PaginationButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/PaginationButton.tsx
|
|
3
|
+
* @desc Pagination's Previous or Next button in button mode (internal). At the first or last page
|
|
4
|
+
* it stays in place and keeps focus, but ignores presses (aria-disabled). Its classes arrive
|
|
5
|
+
* finished from Pagination, so it imports no class merging.
|
|
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
|
+
/**
|
|
13
|
+
* @function PaginationButton
|
|
14
|
+
* @param props {PaginationButtonProps} the target page, whether there is none, the page handler,
|
|
15
|
+
* classes and text
|
|
16
|
+
* @returns {JSX.Element} a `<button type="button">` that calls `onPageChange(page)` unless it's off
|
|
17
|
+
*/
|
|
18
|
+
export function PaginationButton({ page, off, onPageChange, className, children, }) {
|
|
19
|
+
return (_jsx("button", { type: "button", "aria-disabled": off || undefined, className: className, onClick: () => {
|
|
20
|
+
if (!off)
|
|
21
|
+
onPageChange(page);
|
|
22
|
+
}, children: children }));
|
|
23
|
+
}
|
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
* @file src/components/actions/PaginationStatus.tsx
|
|
3
3
|
* @desc Pagination's "Page X of Y" text (internal). It also keeps keyboard focus in place: when
|
|
4
4
|
* the link that had focus goes away (Next on the last page, Previous on the first), focus
|
|
5
|
-
* moves here instead of falling back to the page body.
|
|
5
|
+
* moves here instead of falling back to the page body. In button mode it is a polite live
|
|
6
|
+
* region, since the page changes without a navigation.
|
|
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 ReactNode } from "react";
|
|
11
12
|
/**
|
|
12
13
|
* @function PaginationStatus
|
|
13
|
-
* @param props {{ children: ReactNode }} the status text
|
|
14
|
+
* @param props {{ children: ReactNode; live?: boolean }} the status text, and whether to announce
|
|
15
|
+
* its changes
|
|
14
16
|
* @returns {JSX.Element} a `<span aria-current="page">` that script can focus (tabIndex -1), so
|
|
15
17
|
* it stays out of the tab order
|
|
16
18
|
*/
|
|
17
|
-
export declare function PaginationStatus({ children }: {
|
|
19
|
+
export declare function PaginationStatus({ children, live, }: {
|
|
18
20
|
children: ReactNode;
|
|
21
|
+
live?: boolean;
|
|
19
22
|
}): import("react").JSX.Element;
|
|
@@ -2,21 +2,23 @@
|
|
|
2
2
|
* @file src/components/actions/PaginationStatus.tsx
|
|
3
3
|
* @desc Pagination's "Page X of Y" text (internal). It also keeps keyboard focus in place: when
|
|
4
4
|
* the link that had focus goes away (Next on the last page, Previous on the first), focus
|
|
5
|
-
* moves here instead of falling back to the page body.
|
|
5
|
+
* moves here instead of falling back to the page body. In button mode it is a polite live
|
|
6
|
+
* region, since the page changes without a navigation.
|
|
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
|
"use client";
|
|
11
12
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
12
13
|
import { useEffect, useRef } from "react";
|
|
13
14
|
/**
|
|
14
15
|
* @function PaginationStatus
|
|
15
|
-
* @param props {{ children: ReactNode }} the status text
|
|
16
|
+
* @param props {{ children: ReactNode; live?: boolean }} the status text, and whether to announce
|
|
17
|
+
* its changes
|
|
16
18
|
* @returns {JSX.Element} a `<span aria-current="page">` that script can focus (tabIndex -1), so
|
|
17
19
|
* it stays out of the tab order
|
|
18
20
|
*/
|
|
19
|
-
export function PaginationStatus({ children }) {
|
|
21
|
+
export function PaginationStatus({ children, live = false, }) {
|
|
20
22
|
const ref = useRef(null);
|
|
21
23
|
const lastFocused = useRef(null);
|
|
22
24
|
// Remember what last had focus inside the nav.
|
|
@@ -40,5 +42,5 @@ export function PaginationStatus({ children }) {
|
|
|
40
42
|
if (active === null || active === document.body)
|
|
41
43
|
ref.current?.focus();
|
|
42
44
|
});
|
|
43
|
-
return (_jsx("span", { ref: ref, tabIndex: -1, "aria-current": "page", className: "text-c4", children: children }));
|
|
45
|
+
return (_jsx("span", { ref: ref, tabIndex: -1, "aria-current": "page", "aria-live": live ? "polite" : undefined, className: "text-c4", children: children }));
|
|
44
46
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/StatusOutput.tsx
|
|
3
|
+
* @desc The `<output>` beside CopyButton and AsyncButton (internal): a polite live region whose
|
|
4
|
+
* message mounts as a new node on every run, so the same text twice is announced twice.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
/** The run the message belongs to (null for none), and the message. */
|
|
11
|
+
export type StatusOutputProps = {
|
|
12
|
+
run: number | null;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* @function StatusOutput
|
|
17
|
+
* @param props {StatusOutputProps} the run's number and its message
|
|
18
|
+
* @returns {JSX.Element} an `<output>` in c3 at text-sm, holding the message keyed by its run
|
|
19
|
+
*/
|
|
20
|
+
export declare function StatusOutput({ run, children }: StatusOutputProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @function StatusOutput
|
|
4
|
+
* @param props {StatusOutputProps} the run's number and its message
|
|
5
|
+
* @returns {JSX.Element} an `<output>` in c3 at text-sm, holding the message keyed by its run
|
|
6
|
+
*/
|
|
7
|
+
export function StatusOutput({ run, children }) {
|
|
8
|
+
return (_jsx("output", { className: "text-c3 text-sm", children: run === null ? null : _jsx("span", { children: children }, run) }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/pages.ts
|
|
3
|
+
* @desc Pagination's page math (internal): what to do with a page and page count that came from
|
|
4
|
+
* a URL or an API, where they can be NaN, fractional or out of range.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
/** A page and a page count after normalizing. `pageCount` is null when it isn't known. */
|
|
10
|
+
export type PageState = {
|
|
11
|
+
page: number;
|
|
12
|
+
pageCount: number | null;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* @function normalizePages
|
|
16
|
+
* @param page {number} the current page, 1-based, as the caller has it
|
|
17
|
+
* @param pageCount {number | null} how many pages there are, or null when it isn't known
|
|
18
|
+
* @returns {PageState} whole numbers: a non-finite count is 1 and a negative one 0, and the page
|
|
19
|
+
* sits between 1 and the count (at least 1), with a non-finite page read as 1
|
|
20
|
+
*/
|
|
21
|
+
export declare const normalizePages: (page: number, pageCount: number | null) => PageState;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/pages.ts
|
|
3
|
+
* @desc Pagination's page math (internal): what to do with a page and page count that came from
|
|
4
|
+
* a URL or an API, where they can be NaN, fractional or out of range.
|
|
5
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
6
|
+
* @created Mon Sep 28, 2026
|
|
7
|
+
* @modified Mon Sep 28, 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @function normalizePages
|
|
11
|
+
* @param page {number} the current page, 1-based, as the caller has it
|
|
12
|
+
* @param pageCount {number | null} how many pages there are, or null when it isn't known
|
|
13
|
+
* @returns {PageState} whole numbers: a non-finite count is 1 and a negative one 0, and the page
|
|
14
|
+
* sits between 1 and the count (at least 1), with a non-finite page read as 1
|
|
15
|
+
*/
|
|
16
|
+
export const normalizePages = (page, pageCount) => {
|
|
17
|
+
const count = pageCount === null ? null : Number.isFinite(pageCount) ? Math.max(Math.floor(pageCount), 0) : 1;
|
|
18
|
+
const last = count === null ? Number.POSITIVE_INFINITY : Math.max(count, 1);
|
|
19
|
+
const current = Number.isFinite(page) ? Math.min(Math.max(Math.floor(page), 1), last) : 1;
|
|
20
|
+
return { page: current, pageCount: count };
|
|
21
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/useLatestStatus.ts
|
|
3
|
+
* @desc The status that CopyButton and AsyncButton report (internal): each run empties it first,
|
|
4
|
+
* and only the latest run may fill it, so a slow earlier run can't overwrite a newer result.
|
|
5
|
+
* Each result carries its run's number, so StatusOutput mounts it as a fresh node and a
|
|
6
|
+
* repeated message is announced again.
|
|
7
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
8
|
+
* @created Mon Sep 28, 2026
|
|
9
|
+
* @modified Mon Sep 28, 2026
|
|
10
|
+
*/
|
|
11
|
+
/** The latest run's result and its number, or null while nothing is to be said. */
|
|
12
|
+
export type LatestStatus<T> = {
|
|
13
|
+
result: T;
|
|
14
|
+
run: number;
|
|
15
|
+
} | null;
|
|
16
|
+
/**
|
|
17
|
+
* @function useLatestStatus
|
|
18
|
+
* @returns {{ status: LatestStatus<T>; start: () => number; settle: (run: number, result: T) =>
|
|
19
|
+
* void }} the status, `start` (empties it and returns the new run's number) and `settle`
|
|
20
|
+
* (sets a run's result, unless a later run has started since)
|
|
21
|
+
*/
|
|
22
|
+
export declare function useLatestStatus<T>(): {
|
|
23
|
+
status: LatestStatus<T>;
|
|
24
|
+
start: () => number;
|
|
25
|
+
settle: (run: number, result: T) => void;
|
|
26
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/components/actions/useLatestStatus.ts
|
|
3
|
+
* @desc The status that CopyButton and AsyncButton report (internal): each run empties it first,
|
|
4
|
+
* and only the latest run may fill it, so a slow earlier run can't overwrite a newer result.
|
|
5
|
+
* Each result carries its run's number, so StatusOutput mounts it as a fresh node and a
|
|
6
|
+
* repeated message is announced again.
|
|
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 { useRef, useState } from "react";
|
|
13
|
+
/**
|
|
14
|
+
* @function useLatestStatus
|
|
15
|
+
* @returns {{ status: LatestStatus<T>; start: () => number; settle: (run: number, result: T) =>
|
|
16
|
+
* void }} the status, `start` (empties it and returns the new run's number) and `settle`
|
|
17
|
+
* (sets a run's result, unless a later run has started since)
|
|
18
|
+
*/
|
|
19
|
+
export function useLatestStatus() {
|
|
20
|
+
const [status, setStatus] = useState(null);
|
|
21
|
+
const runs = useRef(0);
|
|
22
|
+
const start = () => {
|
|
23
|
+
setStatus(null);
|
|
24
|
+
runs.current += 1;
|
|
25
|
+
return runs.current;
|
|
26
|
+
};
|
|
27
|
+
const settle = (run, result) => {
|
|
28
|
+
if (run === runs.current)
|
|
29
|
+
setStatus({ result, run });
|
|
30
|
+
};
|
|
31
|
+
return { status, start, settle };
|
|
32
|
+
}
|