Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
23e949e
[Feat] 폼 및 파일 업로더용 아이콘 추가
kimsman06 Mar 21, 2026
8f19e81
[Feat] 공통 Form 기초 컴포넌트 세트 구현
kimsman06 Mar 21, 2026
7210d85
[Feat] 디자인 가이드 기반 공통 Checkbox 컴포넌트 구현
kimsman06 Mar 21, 2026
1b1f2e9
[Feat] 포트폴리오용 파일 업로더 및 리스트 컴포넌트 구현
kimsman06 Mar 21, 2026
6885721
[Refactor] shared/ui 폼 관련 컴포넌트 내보내기 추가
kimsman06 Mar 21, 2026
49f73a2
[Fix] 헤더 위젯 내보내기 경로 오류 수정
kimsman06 Mar 21, 2026
a1f3727
Merge branch 'dev' into feat/form
kimsman06 Mar 21, 2026
ea784e3
Merge branch 'dev' into feat/form
kimsman06 Mar 28, 2026
6246a5f
[Feat] 공통 모달(Modal) 기초 컴포넌트 시스템 구현
kimsman06 Mar 28, 2026
6fbbf2c
[Feat] 모달 활용 예제(필터, 프로필 편집) 및 스토리북 추가
kimsman06 Mar 28, 2026
7c9f23c
[Refactor] shared/ui 모달 컴포넌트 내보내기 추가
kimsman06 Mar 28, 2026
7023e07
[Refactor] Checkbox 컴포넌트 개선 및 UI 엔트리포인트 충돌 해결
kimsman06 Mar 28, 2026
93a890a
[Feat] 모달 활용 예제(필터, 프로필 편집) 고도화
kimsman06 Mar 28, 2026
1534fc4
[Fix] import React 오류 수정
kimsman06 Mar 28, 2026
49ce40b
[Refactor] Checkbox 접근성 개선 및 네이티브 상태 연동 최적화
kimsman06 Mar 28, 2026
e605948
[Feat] 파일 업로더 DND 지원 및 Form 시맨틱 마크업 최적화
kimsman06 Mar 28, 2026
ba03d47
[Feat] 폼 관련 스토리북 인터랙션 보강 및 타입 수정
kimsman06 Mar 28, 2026
db3a535
[Chore] Checkbox 충돌 해결 및 최신 접근성 코드 반영
kimsman06 Mar 28, 2026
6204420
[Chore]: dev 브랜치 merge
kimsman06 Apr 1, 2026
48e33cb
[Fix] 모달 스토리북 필수 Args(children) 누락으로 인한 빌드 에러 수정
kimsman06 Apr 1, 2026
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
5 changes: 4 additions & 1 deletion src/shared/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { Button } from "./button";
export { Input } from "./input";
export { Input, Textarea, Select } from "./inputBox/inputBox";
export { DropdownMenu, SelectDropdown } from "./dropdown";
export { Pagination } from "./pagination/pagination";
export type { PaginationProps } from "./pagination/pagination";
Expand All @@ -10,5 +10,8 @@ export type { NavigationProps, NavItem, NavUser } from "./navigation/navigation"
export * from "./form";
export * from "./checkbox";
export * from "./file-uploader";

// Modal components
export * from "./modal";
export { Tabs } from "./tabs/tabs";
export type { TabsProps, TabItem, TabVariant } from "./tabs/tabs";
3 changes: 1 addition & 2 deletions src/shared/ui/lists/lists.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,7 @@ const InteractiveTable = (): React.ReactElement => {
const [data, setData] = useState<TableRowData[]>(initialTableData);

const selectableRows = data.filter((r) => !r.isDisabled);
const isAllChecked =
selectableRows.length > 0 && selectableRows.every((r) => r.isSelected);
const isAllChecked = selectableRows.length > 0 && selectableRows.every((r) => r.isSelected);

const handleRowCheck = (id: string, isChecked: boolean) => {
setData((prev) => prev.map((row) => (row.id === id ? { ...row, isSelected: isChecked } : row)));
Expand Down
1 change: 1 addition & 0 deletions src/shared/ui/modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./modal";
165 changes: 165 additions & 0 deletions src/shared/ui/modal/modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Modal, ModalHeader, ModalContent, ModalFooter } from "./modal";
import { Button } from "@/shared/ui/button/button";
import { Checkbox } from "@/shared/ui/checkbox/checkbox";
import { FormField, FormLabel } from "@/shared/ui/form";
import { Input, Textarea, Select } from "@/shared/ui/inputBox/inputBox";

const meta = {
title: "Shared/UI/Modal",
component: Modal,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof Modal>;

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

// --- 1. Portfolio Filter Modal Example ---
const FilterModalExample = ({
isOpen: propsIsOpen,
onClose: propsOnClose,
}: {
isOpen?: boolean;
onClose?: () => void;
}) => {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = propsIsOpen ?? internalIsOpen;
const onClose = propsOnClose ?? (() => setInternalIsOpen(false));

const categories = ["웹 개발", "모바일 앱", "UI/UX 디자인", "데이터 분석", "AI/ML", "게임 개발"];
const departments = ["소프트웨어학과", "미디어학과", "산업공학과", "경영학과"];

return (
<>
{!propsIsOpen && <Button onClick={() => setInternalIsOpen(true)}>필터 모달 열기</Button>}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalHeader title="필터 선택" onClose={onClose} />
<ModalContent className="space-y-8">
<div className="space-y-4">
<h3 className="text-[16px] font-semibold text-[var(--color-gray-800,#333)]">
카테고리
</h3>
<div className="grid grid-cols-2 gap-3">
<Checkbox label="전체" className="col-span-2" defaultChecked />
{categories.map((cat) => (
<Checkbox key={cat} label={cat} />
))}
</div>
</div>

<div className="h-px bg-[var(--color-gray-100,#f2f2f2)]" />

<div className="space-y-4">
<h3 className="text-[16px] font-semibold text-[var(--color-gray-800,#333)]">학과</h3>
<div className="grid grid-cols-2 gap-3">
<Checkbox label="전체" className="col-span-2" defaultChecked />
{departments.map((dept) => (
<Checkbox key={dept} label={dept} />
))}
</div>
</div>
</ModalContent>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>
초기화
</Button>
<Button variant="secondary" onClick={onClose}>
취소
</Button>
<Button variant="primary" onClick={onClose} className="px-8">
적용
</Button>
</ModalFooter>
</Modal>
</>
);
};

export const FilterModal: Story = {
render: (args) => <FilterModalExample {...args} />,
args: {
isOpen: false,
onClose: () => {},
children: null, // 필수 속성 추가
},
};

// --- 2. Profile Edit Modal Example ---
const ProfileEditModalExample = ({
isOpen: propsIsOpen,
onClose: propsOnClose,
}: {
isOpen?: boolean;
onClose?: () => void;
}) => {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = propsIsOpen ?? internalIsOpen;
const onClose = propsOnClose ?? (() => setInternalIsOpen(false));

return (
<>
{!propsIsOpen && (
<Button variant="secondary" onClick={() => setInternalIsOpen(true)}>
프로필 편집 열기
</Button>
)}
<Modal isOpen={isOpen} onClose={onClose} className="max-w-[500px]">
<ModalHeader title="프로필 편집" onClose={onClose} />
<ModalContent className="space-y-5">
<FormField>
<FormLabel>이름</FormLabel>
<Input defaultValue="김철수" />
</FormField>

<FormField>
<FormLabel>이메일</FormLabel>
<Input type="email" defaultValue="chulsoo.kim@ajou.ac.kr" />
</FormField>

<FormField>
<FormLabel>소속</FormLabel>
<Input defaultValue="아주대학교 소프트웨어학과" />
</FormField>

<FormField>
<FormLabel>회원 종류</FormLabel>
<Select
options={[
{ label: "학생", value: "학생" },
{ label: "교수", value: "교수" },
{ label: "기업", value: "기업" },
]}
defaultValue="학생"
/>
</FormField>

<FormField>
<FormLabel>자기소개</FormLabel>
<Textarea defaultValue="웹 개발과 UI/UX 디자인에 관심이 많은 학생입니다." rows={4} />
</FormField>
</ModalContent>
<ModalFooter>
<Button variant="secondary" onClick={onClose}>
취소
</Button>
<Button variant="primary" onClick={onClose} className="px-8">
저장
</Button>
</ModalFooter>
</Modal>
</>
);
};

export const ProfileEditModal: Story = {
render: (args) => <ProfileEditModalExample {...args} />,
args: {
isOpen: false,
onClose: () => {},
children: null, // 필수 속성 추가
},
};
135 changes: 135 additions & 0 deletions src/shared/ui/modal/modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React, { useEffect, useId } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@/shared/ui/icons";

// --- Global State for Nested Modals ---
let modalOpenCount = 0;
let originalOverflow = "";

// --- Types ---
export type ModalProps = {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
titleId?: string; // 접근성을 위한 ID
};

export type ModalHeaderProps = {
title?: string;
onClose?: () => void;
children?: React.ReactNode;
className?: string;
id?: string; // h2에 부여될 ID
};

export type ModalContentProps = {
children: React.ReactNode;
className?: string;
};

export type ModalFooterProps = {
children: React.ReactNode;
className?: string;
};

// --- Styles ---

const overlayBaseClasses =
"fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm transition-opacity duration-300 flex items-center justify-center p-4";

const modalBaseClasses =
"bg-white rounded-xl shadow-xl w-full max-w-[600px] max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200";

const headerBaseClasses =
"flex items-center justify-between px-6 py-4 border-b border-[var(--color-gray-100,#f2f2f2)]";
const contentBaseClasses = "flex-1 overflow-y-auto px-6 py-6";
const footerBaseClasses =
"px-6 py-4 border-t border-[var(--color-gray-100,#f2f2f2)] flex justify-end gap-2";

const getClasses = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(" ");

// --- Components ---

export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => {
const generatedId = useId();
const titleId = `modal-title-${generatedId}`;

useEffect(() => {
if (!isOpen) return;

// ESC 키 대응
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};

// 스크롤 잠금 (중첩 모달 대응)
if (modalOpenCount === 0) {
originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
modalOpenCount++;

window.addEventListener("keydown", handleEsc);

return () => {
modalOpenCount--;
if (modalOpenCount === 0) {
document.body.style.overflow = originalOverflow;
}
window.removeEventListener("keydown", handleEsc);
};
}, [isOpen, onClose]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!isOpen) return null;

// 자식 요소들에 titleId를 주입하기 위해 React.Children.map 사용 (ModalHeader 탐색)
const childrenWithA11y = React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === ModalHeader) {
return React.cloneElement(child as React.ReactElement<ModalHeaderProps>, { id: titleId });
}
return child;
});

return createPortal(
<div className={overlayBaseClasses} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={getClasses(modalBaseClasses, className)}
>
{childrenWithA11y}
</div>
</div>,
document.body,
);
};

export const ModalHeader = ({ title, onClose, children, className, id }: ModalHeaderProps) => (
<div className={getClasses(headerBaseClasses, className)}>
{title && (
<h2 id={id} className="text-[20px] font-bold text-[var(--color-gray-900,#111)]">
{title}
</h2>
)}
{children}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-md text-[var(--color-gray-400,#999)] hover:text-[var(--color-gray-600,#666)] hover:bg-[var(--color-gray-100,#f2f2f2)] transition-colors"
aria-label="Close modal"
>
<XIcon size={24} />
</button>
)}
</div>
);
Comment on lines +109 to +127
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.

⚠️ Potential issue | 🟡 Minor

aria-labelledby may reference non-existent element when title is omitted.

The id prop is only applied to the <h2> which renders conditionally when title is provided. If ModalHeader is used with only children (no title), the aria-labelledby on the modal dialog will point to a non-existent element.

Consider applying the id to the wrapper <div> or to an element that always renders.

🛡️ Proposed fix: apply id to wrapper
 export const ModalHeader = ({ title, onClose, children, className, id }: ModalHeaderProps) => (
-  <div className={getClasses(headerBaseClasses, className)}>
+  <div id={id} className={getClasses(headerBaseClasses, className)}>
     {title && (
-      <h2 id={id} className="text-[20px] font-bold text-[var(--color-gray-900,`#111`)]">
+      <h2 className="text-[20px] font-bold text-[var(--color-gray-900,`#111`)]">
         {title}
       </h2>
     )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const ModalHeader = ({ title, onClose, children, className, id }: ModalHeaderProps) => (
<div className={getClasses(headerBaseClasses, className)}>
{title && (
<h2 id={id} className="text-[20px] font-bold text-[var(--color-gray-900,#111)]">
{title}
</h2>
)}
{children}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-md text-[var(--color-gray-400,#999)] hover:text-[var(--color-gray-600,#666)] hover:bg-[var(--color-gray-100,#f2f2f2)] transition-colors"
aria-label="Close modal"
>
<XIcon size={24} />
</button>
)}
</div>
);
export const ModalHeader = ({ title, onClose, children, className, id }: ModalHeaderProps) => (
<div id={id} className={getClasses(headerBaseClasses, className)}>
{title && (
<h2 className="text-[20px] font-bold text-[var(--color-gray-900,`#111`)]">
{title}
</h2>
)}
{children}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-md text-[var(--color-gray-400,`#999`)] hover:text-[var(--color-gray-600,`#666`)] hover:bg-[var(--color-gray-100,`#f2f2f2`)] transition-colors"
aria-label="Close modal"
>
<XIcon size={24} />
</button>
)}
</div>
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/modal/modal.tsx` around lines 109 - 127, The ModalHeader
currently only applies the id prop to the conditional <h2>, causing
aria-labelledby on the dialog to point to a missing element when title is
omitted; update ModalHeader so the id is applied to the wrapper <div> (the
element using getClasses(headerBaseClasses, className)) so an element with that
id always exists, and remove or avoid relying on the conditional h2 for the id
(keep the h2 as-is for semantics). This ensures aria-labelledby targets a
consistently rendered element while preserving title rendering and styling.


export const ModalContent = ({ children, className }: ModalContentProps) => (
<div className={getClasses(contentBaseClasses, className)}>{children}</div>
);

export const ModalFooter = ({ children, className }: ModalFooterProps) => (
<div className={getClasses(footerBaseClasses, className)}>{children}</div>
);