File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import type { ReactNode, Ref } from "react";
import { cx } from "../../utils";
export type FoxButtonType = "primary" | "secondary" | "default" | "error";
export type FoxButtonSize = "lg" | "md" | "sm" | "xsm";
export interface FoxButtonProps {
type?: FoxButtonType;
size: FoxButtonSize;
/** 네이티브 `type` — prop의 `type`이 시각 계열을 쓰므로 분리했다. */
htmlType?: "button" | "submit" | "reset";
/** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 추가한다. */
className?: string;
/** `currentColor`로 그린 SVG여야 계열별 색이 적용된다. 크기는 슬롯이 정한다. */
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
label: string;
disabled?: boolean;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 호출부가 소유한다 — 버튼이 스스로 켜지 않는다. */
loading?: boolean;
fullWidth?: boolean;
onAction?: () => void;
/** 포인터가 올라오면 `true`, 벗어나면 `false`. */
onHoverChange?: (hovered: boolean) => void;
ref?: Ref<HTMLButtonElement>;
}
/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */
const TYPE_CLASS: Record<FoxButtonType, string> = {
primary: "fox-button--primary",
secondary: "fox-button--secondary",
default: "fox-button--default",
error: "fox-button--error",
};
const SIZE_CLASS: Record<FoxButtonSize, string> = {
lg: "fox-button--lg",
md: "fox-button--md",
sm: "fox-button--sm",
xsm: "fox-button--xsm",
};
/**
* @fox 공용 버튼. 상태를 갖지 않는다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxButton({
type = "default",
size,
htmlType = "button",
className,
leadingIcon,
trailingIcon,
label,
disabled = false,
hidden = false,
loading = false,
fullWidth = false,
onAction,
onHoverChange,
ref,
}: FoxButtonProps) {
if (hidden) {
return null;
}
// 같은 요청이 두 번 나가지 않게 로딩 중에도 막는다.
const inactive = disabled || loading;
return (
<button
ref={ref}
type={htmlType}
className={cx(
"fox-button",
TYPE_CLASS[type],
SIZE_CLASS[size],
fullWidth && "fox-button--full-width",
className
)}
disabled={inactive}
aria-busy={loading || undefined}
onClick={inactive ? undefined : onAction}
onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
>
{loading ? (
// 스피너 비주얼은 시안 미정. 아이콘과 같은 크기라 전환 시 폭이 흔들리지 않는다.
<span className="fox-button__spinner" aria-hidden="true" />
) : (
leadingIcon && (
<span className="fox-button__icon" aria-hidden="true">
{leadingIcon}
</span>
)
)}
<span className="fox-button__label">{label}</span>
{trailingIcon && (
<span className="fox-button__icon" aria-hidden="true">
{trailingIcon}
</span>
)}
</button>
);
}