2026-08-09 11:35:42 -07:00
|
|
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
2026-09-02 09:08:50 -07:00
|
|
|
import { useUnavailable } from "./unavailable";
|
2026-08-09 11:35:42 -07:00
|
|
|
|
|
|
|
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
|
|
|
export type ButtonSize = "sm" | "md";
|
|
|
|
|
|
|
|
|
|
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
|
|
variant?: ButtonVariant;
|
|
|
|
|
size?: ButtonSize;
|
|
|
|
|
children: ReactNode;
|
2026-09-02 09:08:50 -07:00
|
|
|
/**
|
|
|
|
|
* Unavailable, but still announced. Renders `aria-disabled` and wires
|
|
|
|
|
* `unavailableReason` to `aria-describedby` instead of using the native
|
|
|
|
|
* `disabled` attribute, which would take the button out of the tab order and
|
|
|
|
|
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
|
|
|
|
|
* guarded for you. Prefer this over `disabled` whenever there is a reason
|
|
|
|
|
* worth telling the user.
|
|
|
|
|
*/
|
|
|
|
|
unavailable?: boolean;
|
|
|
|
|
/** Why the button cannot be used. Required for `unavailable` to say anything. */
|
|
|
|
|
unavailableReason?: string;
|
2026-08-09 11:35:42 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Real buttons with visible bounds and a ≥24px hit target.
|
|
|
|
|
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
|
|
|
|
* `--accent` stays reserved for foreground/link use.
|
2026-09-02 09:08:50 -07:00
|
|
|
*
|
|
|
|
|
* The `aria-disabled:` class mirrors below exist because Tailwind's
|
|
|
|
|
* `disabled:` variant only matches the native attribute, which `unavailable`
|
|
|
|
|
* deliberately does not set. Keep the two lists in step.
|
2026-08-09 11:35:42 -07:00
|
|
|
*/
|
|
|
|
|
const VARIANTS: Record<ButtonVariant, string> = {
|
|
|
|
|
primary:
|
2026-09-02 09:08:50 -07:00
|
|
|
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
2026-08-09 11:35:42 -07:00
|
|
|
secondary:
|
2026-09-02 09:08:50 -07:00
|
|
|
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
2026-08-09 11:35:42 -07:00
|
|
|
danger:
|
2026-09-02 09:08:50 -07:00
|
|
|
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
|
2026-08-09 11:35:42 -07:00
|
|
|
ghost:
|
2026-09-02 09:08:50 -07:00
|
|
|
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
|
2026-08-09 11:35:42 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SIZES: Record<ButtonSize, string> = {
|
|
|
|
|
sm: "h-6 px-2 text-xs gap-1",
|
|
|
|
|
md: "h-8 px-3 text-[13px] gap-1.5",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function Button({
|
|
|
|
|
variant = "secondary",
|
|
|
|
|
size = "sm",
|
|
|
|
|
className = "",
|
|
|
|
|
type = "button",
|
2026-09-02 09:08:50 -07:00
|
|
|
unavailable = false,
|
|
|
|
|
unavailableReason = "",
|
2026-08-09 11:35:42 -07:00
|
|
|
children,
|
|
|
|
|
...rest
|
|
|
|
|
}: Props) {
|
2026-09-02 09:08:50 -07:00
|
|
|
const { controlProps, reasonNode } = useUnavailable({
|
|
|
|
|
unavailable,
|
|
|
|
|
reason: unavailableReason,
|
|
|
|
|
onClick: rest.onClick,
|
|
|
|
|
onKeyDown: rest.onKeyDown,
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
return (
|
2026-09-02 09:08:50 -07:00
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type={type}
|
|
|
|
|
{...rest}
|
|
|
|
|
{...controlProps}
|
|
|
|
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
{/* Outside the button: inside, the reason would join its accessible name. */}
|
|
|
|
|
{reasonNode}
|
|
|
|
|
</>
|
2026-08-09 11:35:42 -07:00
|
|
|
);
|
|
|
|
|
}
|