-
Notifications
You must be signed in to change notification settings - Fork 3
사이드바 컴포넌트 개발 #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
사이드바 컴포넌트 개발 #27
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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="자유게시판" | ||
| /> | ||
| </> | ||
| ), | ||
| }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
|
|
||
| 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> | ||
| </> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | ||
| }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ), | ||
| }, | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재
useEffect훅이onClose함수에 의존하고 있습니다. 만약 부모 컴포넌트에서onCloseprop을useCallback으로 감싸지 않으면, 부모가 리렌더링될 때마다 새로운 함수가 생성되어 이useEffect가 불필요하게 재실행될 수 있습니다. 이는 성능 저하의 원인이 될 수 있습니다.이러한 문제를 방지하고 컴포넌트의 안정성을 높이기 위해,
onClose함수를ref에 저장하여 사용하는 것을 고려해볼 수 있습니다. 이렇게 하면useEffect는isOpen상태가 변경될 때만 실행되면서도 항상 최신의onClose함수를 호출할 수 있습니다.