Keep progress inside the initiating Button/IconButton or centered within a LoadingRegion without changing layout or unmounting live content. Document ownership and completion/error behavior in the registry and living catalog. Validation: qualified NET02 DG build/typecheck/registry/loading tests; existing browser geometry, theme and error lifecycle acceptance. All eleven committed files match the qualified source artifact byte for byte.
99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
|
import { cn } from "./cn.js";
|
|
import { ActivityIndicator } from "./ActivityIndicator.js";
|
|
|
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
|
export type ButtonSize = "default" | "compact" | "dense";
|
|
export type ButtonShape = "default" | "pill" | "rounded";
|
|
|
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: ButtonVariant;
|
|
/** Neutral primary actions remain white/gray independently of the product accent. */
|
|
tone?: "theme" | "neutral";
|
|
size?: ButtonSize;
|
|
width?: "auto" | "full";
|
|
shape?: ButtonShape;
|
|
accent?: RgbTuple;
|
|
icon?: ReactNode;
|
|
/** Pending state belongs to this action; dimensions and accessible name stay unchanged. */
|
|
loading?: boolean;
|
|
}
|
|
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
|
|
variant = "secondary",
|
|
tone = "theme",
|
|
size = "default",
|
|
width = "auto",
|
|
shape = "default",
|
|
accent,
|
|
icon,
|
|
className,
|
|
children,
|
|
style,
|
|
type = "button",
|
|
loading = false,
|
|
disabled,
|
|
...props
|
|
}, ref) {
|
|
const accentStyle = accent ? createAccentVariables(accent) : undefined;
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type={type}
|
|
className={cn("nodedc-button", className)}
|
|
data-variant={variant}
|
|
data-tone={tone === "theme" ? undefined : tone}
|
|
data-size={size === "default" ? undefined : size}
|
|
data-width={width === "auto" ? undefined : width}
|
|
data-shape={shape === "default" ? undefined : shape}
|
|
style={accentStyle ? { ...accentStyle, ...style } : style}
|
|
{...props}
|
|
disabled={disabled || loading}
|
|
aria-busy={loading || props["aria-busy"]}
|
|
data-loading={loading || undefined}
|
|
>
|
|
<span className="nodedc-button__content">
|
|
{icon ? <span className="nodedc-button__icon" aria-hidden="true">{icon}</span> : null}
|
|
{children}
|
|
</span>
|
|
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
label: string;
|
|
shape?: "circle" | "rounded";
|
|
loading?: boolean;
|
|
}
|
|
|
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
|
|
label,
|
|
shape = "circle",
|
|
className,
|
|
children,
|
|
type = "button",
|
|
loading = false,
|
|
disabled,
|
|
...props
|
|
}, ref) {
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type={type}
|
|
className={cn("nodedc-icon-button", className)}
|
|
data-shape={shape === "circle" ? undefined : shape}
|
|
aria-label={label}
|
|
title={label}
|
|
{...props}
|
|
disabled={disabled || loading}
|
|
aria-busy={loading || props["aria-busy"]}
|
|
data-loading={loading || undefined}
|
|
>
|
|
{children}
|
|
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
|
</button>
|
|
);
|
|
});
|