Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions src/shared/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,3 @@ button {
button:disabled {
cursor: not-allowed;
}

button[data-disabled='true'] {
cursor: not-allowed;
}

button[data-loading='true'] {
cursor: wait;
}
109 changes: 109 additions & 0 deletions src/shared/ui/button/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

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: () => (
<div className="flex items-center gap-3">
<Button size="sm">적용</Button>
<Button size="md">다음</Button>
<Button size="lg">저장하기</Button>
</div>
),
};

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: () => (
<LinkButton href="/writing" variant="outline">
자기소개서 목록
</LinkButton>
),
};

export const ExternalLink: Story = {
render: () => (
<LinkButton
external
href="https://github.com/Rewrite-Team/Rewrite-FE"
target="_blank"
variant="secondary"
>
GitHub로 이동
</LinkButton>
),
};
69 changes: 69 additions & 0 deletions src/shared/ui/button/Button.tsx
Original file line number Diff line number Diff line change
@@ -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<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label' | 'children' | 'disabled'> &
ButtonStateProps &
ButtonAccessibilityProps;

/**
* ## Button
*
* @description
* 저장, 삭제, 제출처럼 현재 화면에서 동작을 실행할 때 사용하는 공통 버튼 컴포넌트입니다.
* 페이지 이동에는 `Button` 대신 `LinkButton`을 사용합니다.
*
* ### 주요 내용
*
* `variant`와 `size`로 형태를 제어하며, `isLoading` 상태에서는 중복 실행을 막기 위해 버튼을
* 비활성화하고 로딩 표시를 제공합니다. 아이콘만 표시할 때는 `iconOnly`를 사용합니다.
*
* ### 접근성
*
* `iconOnly` 버튼에는 동작을 설명하는 `aria-label`을 반드시 전달해야 합니다.
*
* @param isLoading - 비동기 작업 진행 여부. 활성화하면 버튼이 비활성화됩니다.
* @param iconOnly - 텍스트 없이 아이콘만 표시하는 버튼인지 여부
*
* @example
* ```tsx
* <Button variant="primary" isLoading={isSaving} onClick={handleSave}>
* 저장하기
* </Button>
* ```
*/
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 (
<button
{...buttonProps}
aria-busy={isLoading || undefined}
className={buttonClassName}
data-disabled={isDisabled}
data-loading={isLoading || undefined}
disabled={isDisabled}
type={type}
>
<ButtonContent isLoading={isLoading}>{children}</ButtonContent>
</button>
);
}
19 changes: 19 additions & 0 deletions src/shared/ui/button/Button.types.ts
Original file line number Diff line number Diff line change
@@ -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 };
21 changes: 21 additions & 0 deletions src/shared/ui/button/ButtonContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Spinner />
<span className="sr-only">{children}</span>
</>
);
}

return <>{children}</>;
}
134 changes: 134 additions & 0 deletions src/shared/ui/button/LinkButton.tsx
Original file line number Diff line number Diff line change
@@ -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<LinkProps, 'prefetch' | 'replace' | 'onNavigate'> {
external?: false;
href: string;
}

interface ExternalLinkProps {
external: true;
href: string;
onNavigate?: never;
prefetch?: never;
replace?: never;
}

type LinkButtonProps = ButtonVariantProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, '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
* <LinkButton href="/writing" variant="outline">
* 자기소개서 목록
* </LinkButton>
*
* <LinkButton external href="https://github.com/Rewrite-Team/Rewrite-FE" target="_blank">
* GitHub로 이동
* </LinkButton>
* ```
*/
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<HTMLAnchorElement>) => {
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 = <ButtonContent isLoading={isLoading}>{children}</ButtonContent>;

if (external) {
return (
<a href={href} {...commonProps}>
{content}
</a>
);
}

return (
<Link
href={href}
onNavigate={onNavigate}
prefetch={prefetch}
replace={replace}
{...commonProps}
>
{content}
</Link>
);
}
Loading
Loading