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
3 changes: 3 additions & 0 deletions src/assets/logos/logoIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions src/components/sidebar/MobileDrawer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

import { fn } from 'storybook/test';
import Image from 'next/image';

import MobileDrawer from './MobileDrawer';
import SidebarButton from './SidebarButton';
import SidebarAddButton from './SidebarAddButton';
import chessSmall from '@/assets/icons/chess/chessSmall.svg';
import boardSmall from '@/assets/icons/board/boardSmall.svg';

const TEAMS = ['경영관리팀', '프로덕트팀', '마케팅팀', '콘텐츠팀'];

const meta = {
title: 'Components/MobileDrawer',
component: MobileDrawer,
parameters: {
layout: 'fullscreen',
viewport: { defaultViewport: 'mobile1' },
},
tags: ['autodocs'],
} satisfies Meta<typeof MobileDrawer>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Open: Story = {
args: {
isOpen: true,
onClose: fn(),
children: (
<>
{TEAMS.map((team) => (
<SidebarButton
key={team}
icon={<Image src={chessSmall} alt="" width={20} height={20} />}
label={team}
isActive={team === '경영관리팀'}
/>
))}
<SidebarAddButton label="팀 추가하기" />
<hr style={{ border: 'none', borderTop: '1px solid #e2e8f0', margin: '8px 0' }} />
<SidebarButton
icon={<Image src={boardSmall} alt="" width={20} height={20} />}
label="자유게시판"
/>
</>
),
},
};
96 changes: 96 additions & 0 deletions src/components/sidebar/MobileDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client';

import { useEffect, useRef, type ReactNode } from 'react';
import Image from 'next/image';

import styles from './styles/MobileDrawer.module.css';
import xMarkBig from '@/assets/icons/xMark/xMarkBig.svg';

type MobileDrawerProps = {
/** 드로어 열림/닫힘 상태 */
isOpen: boolean;
/** 드로어 내부에 표시할 콘텐츠 */
children: ReactNode;
/** 닫기 시 호출되는 콜백 (오버레이 클릭, X 버튼, ESC 키) */
onClose: () => void;
};

/**
* 모바일 사이드 드로어 (슬라이드 패널).
* 오버레이 클릭, X 버튼, ESC 키로 닫을 수 있으며 포커스 트랩이 적용됩니다.
* children에 사이드바 메뉴 등 원하는 콘텐츠를 주입할 수 있습니다.
*/
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

export default function MobileDrawer({ isOpen, children, onClose }: MobileDrawerProps) {
const drawerRef = useRef<HTMLElement>(null);
const focusableRef = useRef<HTMLElement[]>([]);

useEffect(() => {
if (!isOpen || !drawerRef.current) return;

focusableRef.current = Array.from(
drawerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
);

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}

if (e.key !== 'Tab') return;

const items = focusableRef.current;
if (items.length === 0) return;

const first = items[0];
const last = items[items.length - 1];

if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
Comment on lines +30 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 useEffect 훅이 onClose 함수에 의존하고 있습니다. 만약 부모 컴포넌트에서 onClose prop을 useCallback으로 감싸지 않으면, 부모가 리렌더링될 때마다 새로운 함수가 생성되어 이 useEffect가 불필요하게 재실행될 수 있습니다. 이는 성능 저하의 원인이 될 수 있습니다.

이러한 문제를 방지하고 컴포넌트의 안정성을 높이기 위해, onClose 함수를 ref에 저장하여 사용하는 것을 고려해볼 수 있습니다. 이렇게 하면 useEffectisOpen 상태가 변경될 때만 실행되면서도 항상 최신의 onClose 함수를 호출할 수 있습니다.

export default function MobileDrawer({ isOpen, children, onClose }: MobileDrawerProps) {
  const drawerRef = useRef<HTMLElement>(null);
  const focusableRef = useRef<HTMLElement[]>([]);
  const onCloseRef = useRef(onClose);

  useEffect(() => {
    onCloseRef.current = onClose;
  }, [onClose]);

  useEffect(() => {
    if (!isOpen || !drawerRef.current) return;

    focusableRef.current = Array.from(
      drawerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
    );

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onCloseRef.current();
        return;
      }

      if (e.key !== 'Tab') return;

      const items = focusableRef.current;
      if (items.length === 0) return;

      const first = items[0];
      const last = items[items.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen]); // onClose 제거

  // ... 나머지 코드
}


useEffect(() => {
if (isOpen) {
drawerRef.current?.querySelector<HTMLElement>('button')?.focus();
}
}, [isOpen]);

if (!isOpen) return null;

return (
<>
<div className={styles.overlay} onClick={onClose} aria-hidden="true" />
<aside
ref={drawerRef}
className={styles.drawer}
role="dialog"
aria-modal="true"
aria-label="메뉴"
>
<div className={styles.header}>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="메뉴 닫기"
>
<Image src={xMarkBig} alt="" width={18} height={18} />
</button>
</div>
<div className={styles.content}>{children}</div>
</aside>
</>
);
}
38 changes: 38 additions & 0 deletions src/components/sidebar/MobileHeader.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

import { fn } from 'storybook/test';

import MobileHeader from './MobileHeader';

const meta = {
title: 'Components/MobileHeader',
component: MobileHeader,
parameters: {
layout: 'fullscreen',
viewport: { defaultViewport: 'mobile1' },
},
tags: ['autodocs'],
decorators: [
(Story) => (
<div style={{ maxWidth: 375 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof MobileHeader>;

export default meta;
type Story = StoryObj<typeof meta>;

export const LoggedOut: Story = {};

export const LoggedIn: Story = {
args: {
isLoggedIn: true,
profileImage: (
<div style={{ width: 32, height: 32, borderRadius: 12, background: '#cbd5e1' }} />
),
onMenuClick: fn(),
onProfileClick: fn(),
},
};
68 changes: 68 additions & 0 deletions src/components/sidebar/MobileHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import Image from 'next/image';
import clsx from 'clsx';

import styles from './styles/MobileHeader.module.css';
import logoSmall from '@/assets/logos/logoSmall.svg';
import logoIcon from '@/assets/logos/logoIcon.svg';
import hamburger from '@/assets/buttons/hamburger/hamburger.svg';
import humanBig from '@/assets/buttons/human/humanBig.svg';

type MobileHeaderProps = {
/** 로그인 여부 (false면 로고만 표시) */
isLoggedIn?: boolean;
/** 프로필 이미지 (ReactNode로 자유롭게 주입, 미전달 시 기본 아이콘) */
profileImage?: ReactNode;
/** 햄버거 메뉴 버튼 클릭 시 호출되는 콜백 */
onMenuClick?: () => void;
/** 프로필 버튼 클릭 시 호출되는 콜백 */
onProfileClick?: () => void;
};

/**
* 모바일 상단 헤더 컴포넌트.
* 비로그인 시 로고만 표시하고, 로그인 시 햄버거 메뉴 + 프로필 버튼을 함께 표시합니다.
* profileImage 슬롯에 커스텀 프로필 이미지를 주입할 수 있습니다.
*/
export default function MobileHeader({
isLoggedIn,
profileImage,
onMenuClick,
onProfileClick,
}: MobileHeaderProps) {
if (!isLoggedIn) {
return (
<header className={styles.header}>
<div className={styles.logo}>
<Image src={logoSmall} alt="COWORKERS" width={102} height={20} />
</div>
</header>
);
}

return (
<header className={clsx(styles.header, styles.loggedIn)}>
<div className={styles.left}>
<button
type="button"
className={styles.menuButton}
onClick={onMenuClick}
aria-label="메뉴 열기"
>
<Image src={hamburger} alt="" width={24} height={24} />
</button>
<div className={styles.logo}>
<Image src={logoIcon} alt="COWORKERS" width={24} height={24} />
</div>
</div>
<button
type="button"
className={styles.profileButton}
onClick={onProfileClick}
aria-label="프로필"
>
{profileImage ?? <Image src={humanBig} alt="" width={32} height={32} />}
</button>
</header>
);
}
104 changes: 104 additions & 0 deletions src/components/sidebar/Sidebar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

import Image from 'next/image';
import Sidebar from './Sidebar';
import SidebarButton from './SidebarButton';
import SidebarTeamSelect from './SidebarTeamSelect';
import SidebarAddButton from './SidebarAddButton';
import chessSmall from '@/assets/icons/chess/chessSmall.svg';
import chessBig from '@/assets/icons/chess/chessBig.svg';
import boardSmall from '@/assets/icons/board/boardSmall.svg';
import boardLarge from '@/assets/icons/board/boardLarge.svg';

const TEAMS = ['경영관리팀', '프로덕트팀', '마케팅팀', '콘텐츠팀'];

const meta = {
title: 'Components/Sidebar',
component: Sidebar,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
} satisfies Meta<typeof Sidebar>;

export default meta;
type Story = StoryObj<typeof meta>;

export const LoggedIn: Story = {
args: {
profileImage: (
<div style={{ width: 40, height: 40, borderRadius: 12, background: '#cbd5e1' }} />
),
profileName: '안해나',
profileTeam: '경영관리팀',
teamSelect: (isCollapsed: boolean) =>
!isCollapsed && (
<SidebarTeamSelect
icon={<Image src={chessSmall} alt="" width={20} height={20} />}
label="경영관리팀"
isSelected
/>
),
addButton: (isCollapsed: boolean) => (
<>
{!isCollapsed && <SidebarAddButton label="팀 추가하기" />}
<hr style={{ border: 'none', borderTop: '1px solid #e2e8f0', margin: '8px 0' }} />
<SidebarButton
icon={
<Image
src={isCollapsed ? boardLarge : boardSmall}
alt=""
width={isCollapsed ? 24 : 20}
height={isCollapsed ? 24 : 20}
/>
}
label="자유게시판"
iconOnly={isCollapsed}
/>
</>
),
children: (isCollapsed: boolean) => (
<>
{!isCollapsed &&
TEAMS.map((team) => (
<SidebarButton
key={team}
icon={<Image src={chessSmall} alt="" width={20} height={20} />}
label={team}
isActive={team === '경영관리팀'}
/>
))}
{isCollapsed && (
<SidebarButton
icon={<Image src={chessBig} alt="" width={24} height={24} />}
label="경영관리팀"
isActive
iconOnly
/>
)}
</>
),
},
};

export const LoggedOut: Story = {
args: {
footer: (isCollapsed: boolean) =>
isCollapsed ? (
<span style={{ fontSize: 12, fontWeight: 600, color: '#0f172a' }}>로그인</span>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 12,
background: '#e2e8f0',
flexShrink: 0,
}}
/>
<span style={{ fontSize: 14, fontWeight: 600, color: '#0f172a' }}>로그인</span>
</div>
),
},
};
Loading