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 {
/** 시각 계열. 디자인은 Figma 시안(node 390-439) 기준으로 추후 적용한다. */
type?: FoxButtonType;
size: FoxButtonSize;
/** 네이티브 `type` 속성. prop 이름의 `type`이 시각 계열을 쓰므로 분리했다. */
htmlType?: "button" | "submit" | "reset";
/** 배치(마진·그리드) 조정용. 디자인 값 주입은 금지 — 모양이 달라야 하면 `type`을 추가한다. */
className?: string;
/**
* 아이콘 엘리먼트. 계열별 색은 슬롯이 `color`로 내려주므로 **`currentColor`로 그린
* SVG**를 넣어야 시안대로 물든다(primary는 흰색, secondary는 파랑 …).
* 크기는 슬롯이 `form-icon-*` 토큰으로 정하므로 아이콘 자체에 크기를 두지 않아도 된다.
*/
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>;
}
/**
* 클래스명은 `@fox/styles/_fox-button.scss`의 BEM 이름을 그대로 쓴다 — CSS Modules로
* 감싸지 않는 이유는 이 스타일이 React 밖에서도 쓰여야 하기 때문이다. 이름이 `fox-`
* 접두사로 전역 유일해서 스코프가 필요 없다.
*
* variant마다 `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 공용 버튼.
*
* 상태를 갖지 않는 프레젠테이션 컴포넌트다 — `loading`·`disabled`는 호출부가 소유하고
* 버튼은 그에 따라 상호작용을 막기만 한다. 버튼이 스스로 로딩을 켜면 호출부의 상태와
* 어긋날 수 있어 그 책임을 지지 않는다.
*
* 모양은 Figma 시안(관리자페이지 node 390:439)을 토큰으로만 옮긴 것이며 규칙은
* `@fox/styles/_fox-button.scss`에 있다. `error` 계열만 시안 미정이라 비어 있다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 `@use "@fox/styles/fox-button"`)로 한 번 불러와야 한다.
*/
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>
);
}