그전에 공부한걸 정리할겸 디자인 시스템 컴포넌트를 만드는데 간단한 개념부터 정리해두고 가자
스타일과 기능을 분리하여 설계하는 방식
import { vars } from "@hojoon/themes";
import * as React from "react";
export type ButtonProps = {
color?: keyof typeof vars.colors.$scale;
isDisabled?: boolean;
isLoading?: boolean;
size?: "xs" | "sm" | "md" | "lg";
variant?: "solid" | "outline" | "ghost";
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export const enableColorVariant = createVar();
export const hoverColorVariant = createVar();
export const activeColorVariant = createVar();
outline: {
border: `1px solid ${enableColorVariant}`,
color: enableColorVariant,
"&:hover:not([disabled])": {
backgroundColor: hoverColorVariant,
},
"&:active:not([disabled])": {
backgroundColor: activeColorVariant,
},
},
동적으로 css 변수를 설정해주기 위함이다.
이렇게 하고 컴포넌트에서 인라인으로 스타일을 줄 수 있다.
style={{
...assignInlineVars({
[enableColorVariant]: enableColor,
[hoverColorVariant]: hoverColor,
[activeColorVariant]: activeColor,
}),
...style,
}}
const Button = (props: ButtonProps, ref: React.Ref<HTMLButtonElement>) => {
const {
children,
variant = "solid",
size = "md",
color = "gray",
isDisabled = false,
style,
leftIcon,
rightIcon,
isLoading,
onKeyDown,
} = props;
const enableColor = vars.colors.$scale[color][500];
const hoverColor =
variant === "solid"
? vars.colors.$scale[color][600]
: vars.colors.$scale[color][50];
const activeColor =
variant === "solid"
? vars.colors.$scale[color][700]
: vars.colors.$scale[color][100];
const disabled = isDisabled || isLoading;
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
onKeyDown?.(e);
if (e.key === "Enter" || e.key === "13") {
e.preventDefault();
e.currentTarget.click();
}
};
return (
<button
// 기능
{...props}
onKeyDown={handleKeyDown}
role="button"
data-loading={isLoading}
disabled={disabled}
ref={ref}
// 디자인
className={clsx([
buttonStyle({
size,
variant,
}),
])}
style={{
...assignInlineVars({
[enableColorVariant]: enableColor,
[hoverColorVariant]: hoverColor,
[activeColorVariant]: activeColor,
}),
...style,
}}
>
{isLoading && <div className={spinnerStyle({ size })}></div>}
{leftIcon && <span>{leftIcon}</span>}
<span>{children}</span>
{rightIcon && <span>{rightIcon}</span>}
</button>
);
};
const _Button = React.forwardRef(Button);
export { _Button as Button };
export type ButtonElementType = "button" | "a" | "div" | "span" | "input";
export type BaseButtonProps<T extends ButtonElementType = "button"> = {
elementType?: T;
role?: string;
type?: "button" | "submit" | "reset";
isDisabled?: boolean;
isLoading?: boolean;
tabIndex?: number;
} & ComponentProps<T>;
export type UseButtonReturn<T> = {
buttonProps: HTMLAttributes<T> & {
role?: string;
type?: "button" | "submit" | "reset";
tabIndex?: number;
disabled?: boolean;
"data-loading": boolean;
};
};
// props로 button의 상태를 받아온다.
// 버튼 타입중 기능
// return으로 button 또는 각 컴포넌트의 속성으로 return => 버튼에서 필요한 속성도 추가되어야 한다
import { BaseButtonProps, OverloadedButtonFunction } from "./types";
export const useButton: OverloadedButtonFunction = (props: any): any => {
const {
elementType = "button",
isDisabled,
isLoading,
tabIndex,
onKeyDown,
type = "button",
} = props;
const disabled = isDisabled || isLoading;
const handleKeyDown = (e: React.KeyboardEvent) => {
onKeyDown?.(e);
if (e.key === " " || e.key === "Spacebar" || e.key === "32") {
if (disabled) return;
if (e.defaultPrevented) return;
if (elementType === "button") return;
e.preventDefault();
(e.currentTarget as HTMLElement).click();
return;
}
if (e.key === "Enter" || e.key === "13") {
if (disabled) return;
if (e.defaultPrevented) return;
if (elementType === "input" && type !== "button") return;
e.preventDefault();
(e.currentTarget as HTMLElement).click();
return;
}
};
const baseProps = {
...props,
"data-loading": isLoading,
tabIndex: disabled ? undefined : tabIndex ?? 0,
onkeydown: handleKeyDown,
};
let additionalProps = {};
switch (elementType) {
case "button": {
additionalProps = {
type: type ?? "button",
disabled,
};
break;
}
case "a": {
const { href, target, rel } = props as BaseButtonProps<"a">;
additionalProps = {
role: "button",
href: disabled ? undefined : href,
target: disabled ? undefined : target,
rel: disabled ? undefined : rel,
"area-disabled": isDisabled,
};
break;
}
case "input": {
additionalProps = {
role: "button",
type: props.type,
disabled,
"area-disabled": undefined,
};
break;
}
default: {
additionalProps = {
role: "button",
type: type ?? "button",
"area-disabled": isDisabled,
};
break;
}
}
const buttonProps = {
...baseProps,
...additionalProps,
};
return {
buttonProps,
};
};
const Button = (props: ButtonProps, ref: React.Ref<HTMLButtonElement>) => {
const { buttonProps } = useButton(props);
const {
children,
variant = "solid",
size = "md",
color = "gray",
style,
leftIcon,
rightIcon,
isLoading,
} = props;
const enableColor = vars.colors.$scale[color][500];
const hoverColor =
variant === "solid"
? vars.colors.$scale[color][600]
: vars.colors.$scale[color][50];
const activeColor =
variant === "solid"
? vars.colors.$scale[color][700]
: vars.colors.$scale[color][100];
return (
<button
// 기능
{...buttonProps}
{...props}
role="button"
data-loading={isLoading}
ref={ref}
// 디자인
className={clsx([
buttonStyle({
size,
variant,
}),
])}
style={{
...assignInlineVars({
[enableColorVariant]: enableColor,
[hoverColorVariant]: hoverColor,
[activeColorVariant]: activeColor,
}),
...style,
}}
>
{isLoading && <div className={spinnerStyle({ size })}></div>}
{leftIcon && <span>{leftIcon}</span>}
<span>{children}</span>
{rightIcon && <span>{rightIcon}</span>}
</button>
);
};
const _Button = React.forwardRef(Button);
export { _Button as Button };
클릭하면 상태가 바뀌는 useButton의 심화버전인 useToggle도 훅패턴으로 headless하게 만들수 있다.
export const useToggle = ({
isSelected = false,
}: ToggleProps): UseToggleReturn => {
const [toggle, setToggle] = useState<boolean>(isSelected);
const handleToggle = useCallback(() => {
setToggle((prev) => !prev);
}, []);
return {
isSelected: toggle,
setSelected: setToggle,
toggle: handleToggle,
};
};
export const ToggleButtonStory = {
render: () => {
const { buttonProps, isSelected } = useToggleButton(
{
elementType: "button",
},
false,
);
return (
<_Button
{...buttonProps}
variant={isSelected ? "solid" : "outline"}
color="green"
>
{isSelected ? "셀렉" : "노셀렉 "}
</_Button>
);
},
};