jhxk68cdy: Add the five approved button styles, the glossy icon button and a dev story page
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// Button system (spec "Buttons", ADR 0001). Styles live in src/styles/buttons.css.
|
||||
// Each component renders a native <button>; extra button props pass through.
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type NativeButton = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children">;
|
||||
|
||||
const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(" ");
|
||||
|
||||
const svgProps = {
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
export function SendIcon() {
|
||||
return (
|
||||
<svg {...svgProps} strokeWidth={2}>
|
||||
<path d="M21 3 10.5 13.5" />
|
||||
<path d="M21 3 14.5 21l-4-7.5L3 9.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpIcon() {
|
||||
return (
|
||||
<svg {...svgProps} strokeWidth={2.4}>
|
||||
<path d="M12 19V5M6 11l6-6 6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon() {
|
||||
return (
|
||||
<svg {...svgProps} strokeWidth={2.2}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* 1. Flip reveal ------------------------------------------------------- */
|
||||
|
||||
export type FlipState = "ready" | "disabled" | "posting";
|
||||
|
||||
export interface FlipButtonProps extends NativeButton {
|
||||
state?: FlipState;
|
||||
/** Face label in the ready and disabled states. */
|
||||
label?: string;
|
||||
/** Panel that slides up on hover when ready. */
|
||||
hoverLabel?: string;
|
||||
/** Panel that slides up on hover when there is nothing to post. */
|
||||
disabledLabel?: string;
|
||||
/** Face label while posting. */
|
||||
busyLabel?: string;
|
||||
}
|
||||
|
||||
/** The labels each state shows: face text, hover panel text, and whether the send icon shows. */
|
||||
export function flipLabels(
|
||||
state: FlipState,
|
||||
labels: { label?: string; hoverLabel?: string; disabledLabel?: string; busyLabel?: string } = {},
|
||||
) {
|
||||
const { label = "Post", hoverLabel = "Publish", disabledLabel = "Write first", busyLabel = "Posting" } = labels;
|
||||
if (state === "posting") return { front: busyLabel, back: null, icon: false };
|
||||
if (state === "disabled") return { front: label, back: disabledLabel, icon: false };
|
||||
return { front: label, back: hoverLabel, icon: true };
|
||||
}
|
||||
|
||||
export function FlipButton({
|
||||
state = "ready",
|
||||
label,
|
||||
hoverLabel,
|
||||
disabledLabel,
|
||||
busyLabel,
|
||||
className,
|
||||
type = "submit",
|
||||
disabled,
|
||||
...rest
|
||||
}: FlipButtonProps) {
|
||||
const { front, back, icon } = flipLabels(state, { label, hoverLabel, disabledLabel, busyLabel });
|
||||
const busy = state === "posting";
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type={type}
|
||||
className={cx("btn-flip", busy && "btn-flip-busy", className)}
|
||||
disabled={disabled || state !== "ready"}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
<span className="btn-flip-front">
|
||||
{front}
|
||||
{icon ? <SendIcon /> : null}
|
||||
</span>
|
||||
{back ? (
|
||||
<span className="btn-flip-back" aria-hidden="true">
|
||||
{back}
|
||||
{icon ? <SendIcon /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* 2. Circle expand ----------------------------------------------------- */
|
||||
|
||||
export interface GrowButtonProps extends NativeButton {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function GrowButton({ label, icon, className, type = "button", ...rest }: GrowButtonProps) {
|
||||
return (
|
||||
<button {...rest} type={type} className={cx("btn-grow", className)}>
|
||||
<span className="btn-grow-circle" aria-hidden="true">
|
||||
{icon ?? <ArrowUpIcon />}
|
||||
</span>
|
||||
<span className="btn-grow-text">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* 3. Outline fill sweep ------------------------------------------------ */
|
||||
|
||||
export interface SweepButtonProps extends NativeButton {
|
||||
label: string;
|
||||
/** Selected chip: stays filled. Sets aria-pressed when given. */
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function SweepButton({ label, selected, className, type = "button", ...rest }: SweepButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type={type}
|
||||
className={cx("btn-sweep", selected && "btn-sweep-on", className)}
|
||||
aria-pressed={selected === undefined ? undefined : selected}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* 4. Soft depth, rising liquid ----------------------------------------- */
|
||||
|
||||
export interface DepthButtonProps extends NativeButton {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
icon?: ReactNode;
|
||||
/** Trailing content, for example a chevron or the current theme name. */
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) {
|
||||
return (
|
||||
<button {...rest} type={type} className={cx("btn-depth", className)}>
|
||||
{icon ? (
|
||||
<span className="btn-depth-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="btn-depth-text">
|
||||
<strong className="btn-depth-label">{label}</strong>
|
||||
{sublabel ? <span className="btn-depth-sub">{sublabel}</span> : null}
|
||||
</span>
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* 5. Sparkle glow ------------------------------------------------------ */
|
||||
|
||||
const SPARKS = Array.from({ length: 10 }, (_, i) => i);
|
||||
|
||||
function Sparks() {
|
||||
return (
|
||||
<span className="btn-spark-pts" aria-hidden="true">
|
||||
{SPARKS.map((i) => (
|
||||
<i key={i} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SparkleButtonProps extends NativeButton {
|
||||
/** "signin": folded corner and hexagon icon. "fab": mobile new-post, plus icon only. */
|
||||
variant?: "signin" | "fab";
|
||||
/** Visible text for "signin"; accessible name for "fab". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SparkleButton({ variant = "signin", label, className, type = "button", ...rest }: SparkleButtonProps) {
|
||||
if (variant === "fab") {
|
||||
return (
|
||||
<button {...rest} type={type} aria-label={label} className={cx("btn-spark", "btn-spark-fab", className)}>
|
||||
<Sparks />
|
||||
<span className="btn-spark-plus">
|
||||
<PlusIcon />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button {...rest} type={type} className={cx("btn-spark", className)}>
|
||||
<span className="btn-spark-fold" aria-hidden="true" />
|
||||
<Sparks />
|
||||
<span className="btn-spark-inner">
|
||||
<svg className="btn-spark-hex" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="12 2 20.66 7 20.66 17 12 22 3.34 17 3.34 7" />
|
||||
</svg>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* Glossy crimson icon button ------------------------------------------- */
|
||||
|
||||
export interface IconButtonProps extends NativeButton {
|
||||
/** Accessible name; the button shows only the icon. */
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
shape?: "square" | "round";
|
||||
size?: "md" | "sm";
|
||||
}
|
||||
|
||||
export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={rest.title ?? label}
|
||||
className={cx("btn-gloss", shape === "round" && "btn-gloss-round", size === "sm" && "btn-gloss-sm", className)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user