diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index b53be8e..d443122 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -9,11 +9,3 @@ button { button:disabled { cursor: not-allowed; } - -button[data-disabled='true'] { - cursor: not-allowed; -} - -button[data-loading='true'] { - cursor: wait; -} diff --git a/src/shared/ui/button/Button.stories.tsx b/src/shared/ui/button/Button.stories.tsx new file mode 100644 index 0000000..41c52b9 --- /dev/null +++ b/src/shared/ui/button/Button.stories.tsx @@ -0,0 +1,109 @@ +import { Button, LinkButton } from './index'; + +import type { Meta, StoryObj } from '@storybook/nextjs'; + +const meta = { + title: 'Shared/Button', + component: Button, + args: { + children: '저장하기', + size: 'md', + variant: 'primary', + }, + argTypes: { + size: { + control: 'inline-radio', + options: ['sm', 'md', 'lg'], + }, + variant: { + control: 'inline-radio', + options: ['primary', 'secondary', 'ghost', 'outline'], + }, + }, + parameters: { + backgrounds: { + default: 'dark', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = {}; + +export const Secondary: Story = { + args: { + children: '임시 저장', + variant: 'secondary', + }, +}; + +export const Ghost: Story = { + args: { + children: 'AI 첨삭 다시 받기', + variant: 'ghost', + }, +}; + +export const Outline: Story = { + args: { + children: '문항 추가', + variant: 'outline', + }, +}; + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const Loading: Story = { + args: { + children: '저장하기', + isLoading: true, + }, +}; + +export const Disabled: Story = { + args: { + children: '다음', + disabled: true, + }, +}; + +export const IconOnly: Story = { + args: { + 'aria-label': '업로드', + children: '+', + iconOnly: true, + variant: 'outline', + }, +}; + +export const InternalLink: Story = { + render: () => ( + + 자기소개서 목록 + + ), +}; + +export const ExternalLink: Story = { + render: () => ( + + GitHub로 이동 + + ), +}; diff --git a/src/shared/ui/button/Button.tsx b/src/shared/ui/button/Button.tsx new file mode 100644 index 0000000..f3d6574 --- /dev/null +++ b/src/shared/ui/button/Button.tsx @@ -0,0 +1,69 @@ +import type { ButtonHTMLAttributes } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; + +import ButtonContent from './ButtonContent'; +import { buttonVariants, type ButtonVariantProps } from './buttonVariants'; + +import type { ButtonAccessibilityProps, ButtonStateProps } from './Button.types'; + +type ButtonProps = ButtonVariantProps & + Omit, 'aria-label' | 'children' | 'disabled'> & + ButtonStateProps & + ButtonAccessibilityProps; + +/** + * ## Button + * + * @description + * 저장, 삭제, 제출처럼 현재 화면에서 동작을 실행할 때 사용하는 공통 버튼 컴포넌트입니다. + * 페이지 이동에는 `Button` 대신 `LinkButton`을 사용합니다. + * + * ### 주요 내용 + * + * `variant`와 `size`로 형태를 제어하며, `isLoading` 상태에서는 중복 실행을 막기 위해 버튼을 + * 비활성화하고 로딩 표시를 제공합니다. 아이콘만 표시할 때는 `iconOnly`를 사용합니다. + * + * ### 접근성 + * + * `iconOnly` 버튼에는 동작을 설명하는 `aria-label`을 반드시 전달해야 합니다. + * + * @param isLoading - 비동기 작업 진행 여부. 활성화하면 버튼이 비활성화됩니다. + * @param iconOnly - 텍스트 없이 아이콘만 표시하는 버튼인지 여부 + * + * @example + * ```tsx + * + * ``` + */ +export default function Button({ + children, + className, + disabled = false, + iconOnly = false, + isLoading = false, + size, + type = 'button', + variant, + ...buttonProps +}: ButtonProps) { + const isDisabled = disabled || isLoading; + const resolvedSize = iconOnly ? 'icon' : size; + const buttonClassName = cn(buttonVariants({ variant, size: resolvedSize }), className); + + return ( + + ); +} diff --git a/src/shared/ui/button/Button.types.ts b/src/shared/ui/button/Button.types.ts new file mode 100644 index 0000000..e7f7488 --- /dev/null +++ b/src/shared/ui/button/Button.types.ts @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +interface ButtonStateProps { + children: ReactNode; + disabled?: boolean; + isLoading?: boolean; +} + +type ButtonAccessibilityProps = + | { + 'aria-label': string; + iconOnly: true; + } + | { + 'aria-label'?: string; + iconOnly?: false; + }; + +export type { ButtonAccessibilityProps, ButtonStateProps }; diff --git a/src/shared/ui/button/ButtonContent.tsx b/src/shared/ui/button/ButtonContent.tsx new file mode 100644 index 0000000..c6a05b5 --- /dev/null +++ b/src/shared/ui/button/ButtonContent.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +import { Spinner } from '@/shared/ui/spinner'; + +interface ButtonContentProps { + children: ReactNode; + isLoading: boolean; +} + +export default function ButtonContent({ children, isLoading }: ButtonContentProps) { + if (isLoading) { + return ( + <> + + {children} + + ); + } + + return <>{children}; +} diff --git a/src/shared/ui/button/LinkButton.tsx b/src/shared/ui/button/LinkButton.tsx new file mode 100644 index 0000000..5f76e24 --- /dev/null +++ b/src/shared/ui/button/LinkButton.tsx @@ -0,0 +1,134 @@ +'use client'; + +import type { AnchorHTMLAttributes, MouseEvent } from 'react'; + +import Link, { type LinkProps } from 'next/link'; + +import { cn } from '@/shared/styles/utils/cn'; + +import ButtonContent from './ButtonContent'; +import { buttonVariants, type ButtonVariantProps } from './buttonVariants'; + +import type { ButtonAccessibilityProps, ButtonStateProps } from './Button.types'; + +interface InternalLinkProps extends Pick { + external?: false; + href: string; +} + +interface ExternalLinkProps { + external: true; + href: string; + onNavigate?: never; + prefetch?: never; + replace?: never; +} + +type LinkButtonProps = ButtonVariantProps & + Omit, 'aria-label' | 'children' | 'href'> & + ButtonStateProps & + ButtonAccessibilityProps & + (InternalLinkProps | ExternalLinkProps); + +const mergeSecurityRel = (rel?: string) => { + const relTokens = rel?.split(/\s+/).filter(Boolean) ?? []; + + return [...new Set([...relTokens, 'noopener', 'noreferrer'])].join(' '); +}; + +/** + * ## LinkButton + * + * @description + * 페이지 이동이 필요하지만 버튼 형태로 표현해야 할 때 사용하는 공통 링크 컴포넌트입니다. + * 내부 경로는 Next.js `Link`, HTTP(S) 외부 URL은 `a` 요소로 렌더링합니다. + * + * ### 주요 내용 + * + * 외부 URL은 `external`을 반드시 전달하여 네이티브 `a` 요소로 렌더링합니다. + * + * ### 주의할 점 + * + * 현재 화면에서 동작을 실행하는 용도에는 `LinkButton` 대신 `Button`을 사용합니다. + * `disabled` 또는 `isLoading` 상태에서는 링크 이동과 키보드 포커스를 차단합니다. + * + * @param href - 이동할 내부 경로 또는 HTTP(S) 외부 URL + * @param external - 외부 링크일 때 반드시 `true`로 지정하는 값 + * @param isLoading - 비동기 작업 진행 여부. 활성화하면 링크 이동이 차단됩니다. + * + * @example + * ```tsx + * + * 자기소개서 목록 + * + * + * + * GitHub로 이동 + * + * ``` + */ +export default function LinkButton({ + children, + className, + disabled = false, + external, + href, + iconOnly = false, + isLoading = false, + onClick, + onNavigate, + prefetch, + rel, + replace, + size, + target, + variant, + ...anchorProps +}: LinkButtonProps) { + const isDisabled = disabled || isLoading; + const resolvedSize = iconOnly ? 'icon' : size; + const classNames = cn(buttonVariants({ variant, size: resolvedSize }), className); + const safeRel = target === '_blank' ? mergeSecurityRel(rel) : rel; + const handleClick = (event: MouseEvent) => { + if (isDisabled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + onClick?.(event); + }; + const commonProps = { + ...anchorProps, + 'aria-busy': isLoading || undefined, + 'aria-disabled': isDisabled || undefined, + className: classNames, + 'data-disabled': isDisabled, + 'data-loading': isLoading || undefined, + onClick: handleClick, + rel: safeRel, + tabIndex: isDisabled ? -1 : anchorProps.tabIndex, + target, + }; + const content = {children}; + + if (external) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} diff --git a/src/shared/ui/button/buttonVariants.ts b/src/shared/ui/button/buttonVariants.ts new file mode 100644 index 0000000..987badc --- /dev/null +++ b/src/shared/ui/button/buttonVariants.ts @@ -0,0 +1,39 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +const buttonVariants = cva( + [ + 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg font-medium', + 'transition-colors duration-150 outline-none select-none whitespace-nowrap', + 'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black', + 'data-[loading=true]:cursor-wait', + ], + { + variants: { + variant: { + primary: + 'bg-primary-500 text-white data-[disabled=false]:hover:bg-primary-600 data-[disabled=false]:active:bg-primary-700 disabled:bg-gray-700 aria-disabled:cursor-not-allowed aria-disabled:bg-gray-700', + secondary: + 'border-2 border-gray-300 bg-black text-white data-[disabled=false]:hover:bg-gray-900 data-[disabled=false]:active:bg-gray-800 disabled:border-gray-600 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:border-gray-600 aria-disabled:text-gray-500', + ghost: + 'border-2 border-transparent bg-transparent text-white data-[disabled=false]:hover:text-primary-500 data-[disabled=false]:active:bg-primary-600 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:text-gray-500', + outline: + 'border-2 border-primary-500 bg-transparent text-primary-500 data-[disabled=false]:hover:bg-primary-300/50 data-[disabled=false]:active:bg-primary-300/60 disabled:border-gray-500 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:border-gray-500 aria-disabled:text-gray-500', + }, + size: { + sm: 'h-7 w-16 body-14', + md: 'h-11 w-88 body-14', + lg: 'h-12 w-133 body-16', + icon: 'size-10 p-0', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + } +); + +type ButtonVariantProps = VariantProps; + +export { buttonVariants }; +export type { ButtonVariantProps }; diff --git a/src/shared/ui/button/index.ts b/src/shared/ui/button/index.ts new file mode 100644 index 0000000..7c00691 --- /dev/null +++ b/src/shared/ui/button/index.ts @@ -0,0 +1,2 @@ +export { default as Button } from './Button'; +export { default as LinkButton } from './LinkButton'; diff --git a/src/shared/ui/spinner/Spinner.tsx b/src/shared/ui/spinner/Spinner.tsx new file mode 100644 index 0000000..573ede8 --- /dev/null +++ b/src/shared/ui/spinner/Spinner.tsx @@ -0,0 +1,39 @@ +import type { HTMLAttributes } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; + +type SpinnerProps = Omit, 'aria-hidden' | 'children'>; + +/** + * ## Spinner + * + * @description + * 비동기 작업이 진행 중임을 시각적으로 표시하는 공통 로딩 컴포넌트입니다. + * 단독 상태 안내가 필요한 경우에는 스크린 리더용 문구를 별도로 제공합니다. + * + * ### 접근성 + * + * Spinner 자체는 장식 요소이므로 스크린 리더에서 제외됩니다. + * + * @param className - 크기와 색상 등 기본 스타일을 확장할 클래스 + * + * @example + * ```tsx + * + * + * 불러오는 중 + * + * ``` + */ +export default function Spinner({ className, ...props }: SpinnerProps) { + return ( +