Skip to content

[Feat] #70 - 공통 모달(Modal/Dialog) 컴포넌트 시스템 구현 및 예제 추가#83

Merged
sebeeeen merged 20 commits into
devfrom
feat/modal
Apr 1, 2026
Merged

[Feat] #70 - 공통 모달(Modal/Dialog) 컴포넌트 시스템 구현 및 예제 추가#83
sebeeeen merged 20 commits into
devfrom
feat/modal

Conversation

@kimsman06
Copy link
Copy Markdown
Collaborator

@kimsman06 kimsman06 commented Apr 1, 2026

[Feat] #70 - 공통 모달(Modal/Dialog) 컴포넌트 시스템 구현 및 예제 추가

🔎 What is this PR?

서비스 전반에서 사용될 레이어 팝업 시스템인 공통 모달 컴포넌트를 shared/ui 레이어에 구현했습니다. 복합 컴포넌트 패턴을
적용하여 다양한 콘텐츠 구성을 유연하게 지원하며, 웹 접근성 및 사용자 경험을 고려한 필수 인터랙션(ESC 닫기, 외부 클릭 닫기
등)을 포함합니다.


📝 Changes

  • 공통 모달 기초 시스템 구현 (src/shared/ui/modal)
    • createPortal을 활용하여 DOM 최상단에 렌더링되는 Portal 기반 모달 아키텍처 구축
    • Modal, ModalHeader, ModalContent, ModalFooter 등 조립 가능한 복합 컴포넌트 구조 설계
  • 인터랙션 및 사용자 경험 고도화
    • 모달 활성 시 배경 스크롤 잠금 및 backdrop-blur 시각 효과 적용
    • ESC 키 입력 및 오버레이 클릭 시 모달 닫기 로직 구현
    • animate-in 계열의 부드러운 확대/페이드 애니메이션 적용
  • 웹 접근성(A11y) 강화
    • role="dialog", aria-modal="true", aria-label 등 시맨틱 속성 적용
    • 닫기 버튼에 키보드 접근성 및 적절한 접근성 레이블 부여
  • 실제 활용 예제 구축 및 스토리북 등록
    • FilterModal: 이전에 구현한 Checkbox 컴포넌트를 활용한 포트폴리오 필터 UI 재현
    • ProfileEditModal: FormField, Input, Textarea 등 폼 요소와 결합한 프로필 편집 UI 구현
  • 기존 컴포넌트(Checkbox) 연동 최적화
    • 모달 내 비제어 모드 사용 시에도 즉각적인 반응을 보장하도록 peer 패턴 기반의 스타일 연동 개선

📸 Screenshots

  • Storybook 내 다음 항목 확인 가능:
    • Shared/UI/Modal/FilterModal: 필터 선택 시나리오
    • Shared/UI/Modal/ProfileEditModal: 복합 폼 입력 시나리오

📚 Background / Context

  • 디자인 가이드에 명시된 필터 모달, 프로필 편집 등 다양한 팝업 UI의 일관성을 유지하고, 상태 관리 및 접근성 코드를
    공통화하여 개발 생산성을 높이기 위함.
  • FSD 아키텍처의 shared 레이어 원칙에 따라 순수 UI 인터페이스로 설계되어 타 레이어와의 의존성을 최소화함.

✔ Checklist

  • 코드는 로컬에서 정상적으로 빌드됩니다 (pnpm build)
  • ESLint / Prettier 통과 (pnpm lint)
  • 네이밍/레이어 컨벤션 준수 (camelCase/PascalCase, is·has 불린 접두사, alias 계층 규칙)
  • 관련 문서/주석 반영 (필요 시)
  • 주요 로직에 테스트 또는 검증 완료 (Storybook 인터랙션 확인)

Summary by CodeRabbit

  • New Features

    • Added a reusable Modal (with header, content, footer), accessible close behavior, Escape-to-close, and background scroll locking.
    • Consolidated form inputs in the UI: Input plus Textarea and Select are now available from the shared input entry.
  • Documentation

    • Added Storybook examples demonstrating filter and profile-edit modal patterns.

@kimsman06 kimsman06 self-assigned this Apr 1, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 1, 2026

📝 Walkthrough

Walkthrough

Added a new Modal component system (Modal, ModalHeader, ModalContent, ModalFooter) with portal, Escape/overlay close, and scroll locking; exported modal and inputBox components from the UI barrel; added modal Storybook examples; simplified an isAllChecked boolean in an existing lists story.

Changes

Cohort / File(s) Summary
Modal Components
src/shared/ui/modal/modal.tsx, src/shared/ui/modal/index.ts, src/shared/ui/modal/modal.stories.tsx
Added Modal, ModalHeader, ModalContent, ModalFooter with portal rendering, overlay click and Escape handling, body scroll lock, a modal barrel export, and Storybook examples (FilterModal, ProfileEditModal).
UI Barrel Exports
src/shared/ui/index.ts
Replaced single Input export with consolidated Input, Textarea, Select re-exports from ./inputBox/inputBox and added export * from "./modal".
Story Cleanup
src/shared/ui/lists/lists.stories.tsx
Refactored InteractiveTable’s isAllChecked boolean expression into a single-line equivalent (no behavior change).

Sequence Diagram(s)

sequenceDiagram
  participant Story as Story / Caller
  participant Modal as Modal Component
  participant Body as document.body (Portal)
  participant User as User

  rect rgba(200,200,255,0.5)
  Story->>Modal: render with isOpen=true
  Modal->>Body: create portal, insert overlay + dialog
  end

  rect rgba(200,255,200,0.5)
  User->>Modal: click overlay (backdrop)
  Modal->>Modal: if target === backdrop -> call onClose()
  Modal->>Body: remove portal, restore body overflow
  Story->>Modal: receive onClose, update state
  end

  rect rgba(255,200,200,0.5)
  User->>Modal: press Escape
  Modal->>Modal: keydown handler detects Escape -> call onClose()
  Modal->>Body: remove portal, restore body overflow
  Story->>Modal: receive onClose, update state
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • sebeeeen

Poem

🐰 I hopped in with a tiny surprise,
A portal of light before your eyes.
Click the backdrop, press Escape—then cheer,
The rabbit closed gently, no scroll left here.
✨ Modal magic, delivered with a twitch!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: implementing a shared modal component system with examples, directly matching the PR's primary objective.
Description check ✅ Passed The description comprehensively covers all required template sections: PR purpose, detailed change list, screenshots, background/context, and completed checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/modal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/shared/ui/modal/modal.stories.tsx (2)

139-146: Same issue with unused args.

Same as FilterModal — the args are not wired to ProfileEditModalExample.

Remove unused args
 export const ProfileEditModal: Story = {
   render: () => <ProfileEditModalExample />,
-  args: {
-    isOpen: true,
-    onClose: () => {},
-    children: null,
-  },
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/modal/modal.stories.tsx` around lines 139 - 146, The story
ProfileEditModal defines args but does not pass them into
ProfileEditModalExample, so the props (isOpen, onClose, children) are unused;
update the story's render to accept args and forward them to
ProfileEditModalExample (e.g., change render to (args) =>
<ProfileEditModalExample {...args} />) or otherwise wire the args into the
component, mirroring the fix used for FilterModal so the story actually uses the
provided props.

73-80: Story args are not used and may confuse Storybook controls.

The args object specifies isOpen: true, but FilterModalExample initializes with useState(false). Since the render function ignores these args entirely, they serve no purpose and may mislead users expecting Storybook controls to work.

Consider either removing the unused args or passing them to the example component.

Option 1: Remove unused args
 export const FilterModal: Story = {
   render: () => <FilterModalExample />,
-  args: {
-    isOpen: true,
-    onClose: () => {},
-    children: null,
-  },
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/modal/modal.stories.tsx` around lines 73 - 80, The Storybook
story FilterModal declares args (isOpen, onClose, children) but the render uses
FilterModalExample which ignores them (its internal useState(false)); either
remove the unused args from the FilterModal story OR wire the args through:
change the render to accept args and pass them into FilterModalExample (and
update FilterModalExample to accept props like isOpen and onClose instead of
hardcoded useState), ensuring the story's args control the modal state and
Storybook controls behave as expected.
src/shared/ui/modal/modal.tsx (2)

72-79: Consider adding aria-labelledby for better accessibility.

The dialog has role="dialog" and aria-modal="true", but lacks aria-labelledby to associate it with the title. This helps screen readers announce the dialog purpose.

♿ Proposed enhancement

Generate a stable ID (e.g., via useId()) and wire it between Modal and ModalHeader:

+import React, { useEffect, useId } from "react";
 ...
 export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => {
+  const titleId = useId();
   ...
   return createPortal(
     <div className={overlayBaseClasses} onClick={(e) => e.target === e.currentTarget && onClose()}>
-      <div role="dialog" aria-modal="true" className={getModalClasses(className)}>
-        {children}
+      <div role="dialog" aria-modal="true" aria-labelledby={titleId} className={getModalClasses(className)}>
+        {React.Children.map(children, child =>
+          React.isValidElement(child) && child.type === ModalHeader
+            ? React.cloneElement(child, { titleId })
+            : child
+        )}
       </div>
     </div>,
     document.body,
   );
 };

Then in ModalHeader, apply id={titleId} to the <h2>.

🤖 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 72 - 79, The dialog lacks an
aria-labelledby so screen readers can't associate the title; generate a stable
id in the Modal component (useId or a stable id provider) and pass it down as a
prop (e.g., titleId) to the ModalHeader, then add aria-labelledby={titleId} to
the div with role="dialog" in Modal and ensure ModalHeader sets id={titleId} on
its <h2>; update the createPortal return (the div with role="dialog" using
getModalClasses) to include aria-labelledby and thread the titleId prop through
any wrapper components that render ModalHeader.

82-96: Class joining can produce extra spaces when className is undefined.

Using [headerBaseClasses, className].join(" ") produces a trailing space when className is undefined (e.g., "flex items-center ... "). This is cosmetic but inconsistent with getModalClasses which filters properly.

♻️ Consistent class joining
 export const ModalHeader = ({ title, onClose, children, className }: ModalHeaderProps) => (
-  <div className={[headerBaseClasses, className].join(" ")}>
+  <div className={[headerBaseClasses, className].filter(Boolean).join(" ")}>
     ...
   </div>
 );

 export const ModalContent = ({ children, className }: ModalContentProps) => (
-  <div className={[contentBaseClasses, className].join(" ")}>{children}</div>
+  <div className={[contentBaseClasses, className].filter(Boolean).join(" ")}>{children}</div>
 );

 export const ModalFooter = ({ children, className }: ModalFooterProps) => (
-  <div className={[footerBaseClasses, className].join(" ")}>{children}</div>
+  <div className={[footerBaseClasses, className].filter(Boolean).join(" ")}>{children}</div>
 );

Alternatively, use the existing cn utility from @/shared/lib/utils for consistency with other components.

🤖 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 82 - 96, The ModalHeader
component builds its class string using [headerBaseClasses, className].join("
"), which leaves an extra space when className is undefined; update ModalHeader
to build classes consistently by using the existing cn utility (or by filtering
falsy values) so that headerBaseClasses and the optional className are combined
without producing trailing spaces — locate ModalHeader and replace the join
logic that references headerBaseClasses and className with a call to
cn(headerBaseClasses, className) (or equivalent filtering).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/shared/ui/modal/modal.tsx`:
- Around line 54-68: The effect in modal.tsx (the useEffect that watches
isOpen/onClose and uses handleEsc) clobbers any pre-existing
document.body.style.overflow and breaks nested modals; change it to preserve and
restore the original overflow and be stack-safe by: capture and store the
original overflow value when the first modal opens, use a module-level counter
(e.g., modalOpenCount) that increments when a modal opens and decrements on
close, only set document.body.style.overflow = "hidden" when the counter
transitions 0→1, and only restore the saved original overflow when the counter
transitions 1→0; keep the existing keydown handling (handleEsc) but ensure
addEventListener/removeEventListener remain tied to the individual modal's
lifecycle.

---

Nitpick comments:
In `@src/shared/ui/modal/modal.stories.tsx`:
- Around line 139-146: The story ProfileEditModal defines args but does not pass
them into ProfileEditModalExample, so the props (isOpen, onClose, children) are
unused; update the story's render to accept args and forward them to
ProfileEditModalExample (e.g., change render to (args) =>
<ProfileEditModalExample {...args} />) or otherwise wire the args into the
component, mirroring the fix used for FilterModal so the story actually uses the
provided props.
- Around line 73-80: The Storybook story FilterModal declares args (isOpen,
onClose, children) but the render uses FilterModalExample which ignores them
(its internal useState(false)); either remove the unused args from the
FilterModal story OR wire the args through: change the render to accept args and
pass them into FilterModalExample (and update FilterModalExample to accept props
like isOpen and onClose instead of hardcoded useState), ensuring the story's
args control the modal state and Storybook controls behave as expected.

In `@src/shared/ui/modal/modal.tsx`:
- Around line 72-79: The dialog lacks an aria-labelledby so screen readers can't
associate the title; generate a stable id in the Modal component (useId or a
stable id provider) and pass it down as a prop (e.g., titleId) to the
ModalHeader, then add aria-labelledby={titleId} to the div with role="dialog" in
Modal and ensure ModalHeader sets id={titleId} on its <h2>; update the
createPortal return (the div with role="dialog" using getModalClasses) to
include aria-labelledby and thread the titleId prop through any wrapper
components that render ModalHeader.
- Around line 82-96: The ModalHeader component builds its class string using
[headerBaseClasses, className].join(" "), which leaves an extra space when
className is undefined; update ModalHeader to build classes consistently by
using the existing cn utility (or by filtering falsy values) so that
headerBaseClasses and the optional className are combined without producing
trailing spaces — locate ModalHeader and replace the join logic that references
headerBaseClasses and className with a call to cn(headerBaseClasses, className)
(or equivalent filtering).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 55e41a96-5f9e-4690-b04a-526e97ae2913

📥 Commits

Reviewing files that changed from the base of the PR and between cd90aa8 and 6204420.

📒 Files selected for processing (5)
  • src/shared/ui/index.ts
  • src/shared/ui/lists/lists.stories.tsx
  • src/shared/ui/modal/index.ts
  • src/shared/ui/modal/modal.stories.tsx
  • src/shared/ui/modal/modal.tsx

Comment thread src/shared/ui/modal/modal.tsx
@kimsman06 kimsman06 changed the title Feat/modal [Feat] #70 - 공통 모달(Modal/Dialog) 컴포넌트 시스템 구현 및 예제 추가 Apr 1, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/shared/ui/modal/modal.tsx (3)

10-16: Unused titleId prop in ModalProps.

The titleId prop is defined but never used in the Modal component—it always generates its own ID via useId() on line 56. Either remove the prop or use it to allow consumers to override the generated ID.

♻️ Option 1: Remove unused prop
 export type ModalProps = {
   isOpen: boolean;
   onClose: () => void;
   children: React.ReactNode;
   className?: string;
-  titleId?: string; // 접근성을 위한 ID
 };
♻️ Option 2: Use the prop as an override
-export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => {
+export const Modal = ({ isOpen, onClose, children, className, titleId: titleIdProp }: ModalProps) => {
   const generatedId = useId();
-  const titleId = `modal-title-${generatedId}`;
+  const titleId = titleIdProp ?? `modal-title-${generatedId}`;
🤖 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 10 - 16, The ModalProps includes
a titleId that's never used; update the Modal component to accept and use the
consumer-provided titleId as an override of the internally generated id from
useId() (i.e., compute const titleId = props.titleId || generatedId) and apply
that titleId to the modal title element and to aria-labelledby on the dialog, or
alternatively remove titleId from ModalProps and all references if you prefer to
disallow overrides; adjust the ModalProps type, the Modal component (where
useId() is called) and the title/aria attributes accordingly to ensure
consistency.

58-82: Consider using a ref for onClose to avoid effect churn.

The onClose dependency causes the effect to re-run whenever the callback reference changes. If the parent doesn't memoize onClose with useCallback, this will repeatedly remove/add the event listener and briefly toggle scroll lock on every render while the modal is open.

A common pattern is to store onClose in a ref that the effect reads without depending on it.

♻️ Proposed fix using ref
+import React, { useEffect, useId, useRef } from "react";
...
 export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => {
   const generatedId = useId();
   const titleId = `modal-title-${generatedId}`;
+  const onCloseRef = useRef(onClose);
+  onCloseRef.current = onClose;

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

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

     // 스크롤 잠금 (중첩 모달 대응)
     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]);
+  }, [isOpen]);
🤖 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 58 - 82, The effect in the Modal
component re-runs whenever the onClose callback identity changes; change this by
storing onClose in a ref (e.g., onCloseRef) and always calling
onCloseRef.current() from the handleEsc handler so the effect no longer depends
on onClose; update onCloseRef.current whenever props change (use useEffect or
useLayoutEffect) and keep the main modal opening/closing effect for ESC
handling, scroll lock, modalOpenCount, originalOverflow and
window.addEventListener only dependent on isOpen (remove onClose from that
dependency array) so event listener and scroll-lock behavior stop churning when
parent re-renders.

86-92: Type check child.type === ModalHeader may be fragile.

Direct component reference comparisons can fail when components are wrapped (HOCs, forwardRef, memo) or in certain bundler/minification scenarios. This is a common React pattern but worth being aware of for future maintenance.

🤖 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 86 - 92, The equality check
child.type === ModalHeader is fragile for wrapped components; update the
childrenWithA11y mapping to detect ModalHeader more robustly by checking
multiple identifiers: keep React.isValidElement(child) and React.cloneElement
as-is, but replace the strict equality with a helper condition that compares
child.type === ModalHeader || (child.type as any).displayName === (ModalHeader
as any).displayName || (child.type as any).displayName === 'ModalHeader' ||
(child.type as any).name === 'ModalHeader'; apply this check where
ModalHeaderProps and titleId are injected so wrapped/forwardRef/memo variants
are handled too.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/shared/ui/modal/modal.tsx`:
- Around line 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.

---

Nitpick comments:
In `@src/shared/ui/modal/modal.tsx`:
- Around line 10-16: The ModalProps includes a titleId that's never used; update
the Modal component to accept and use the consumer-provided titleId as an
override of the internally generated id from useId() (i.e., compute const
titleId = props.titleId || generatedId) and apply that titleId to the modal
title element and to aria-labelledby on the dialog, or alternatively remove
titleId from ModalProps and all references if you prefer to disallow overrides;
adjust the ModalProps type, the Modal component (where useId() is called) and
the title/aria attributes accordingly to ensure consistency.
- Around line 58-82: The effect in the Modal component re-runs whenever the
onClose callback identity changes; change this by storing onClose in a ref
(e.g., onCloseRef) and always calling onCloseRef.current() from the handleEsc
handler so the effect no longer depends on onClose; update onCloseRef.current
whenever props change (use useEffect or useLayoutEffect) and keep the main modal
opening/closing effect for ESC handling, scroll lock, modalOpenCount,
originalOverflow and window.addEventListener only dependent on isOpen (remove
onClose from that dependency array) so event listener and scroll-lock behavior
stop churning when parent re-renders.
- Around line 86-92: The equality check child.type === ModalHeader is fragile
for wrapped components; update the childrenWithA11y mapping to detect
ModalHeader more robustly by checking multiple identifiers: keep
React.isValidElement(child) and React.cloneElement as-is, but replace the strict
equality with a helper condition that compares child.type === ModalHeader ||
(child.type as any).displayName === (ModalHeader as any).displayName ||
(child.type as any).displayName === 'ModalHeader' || (child.type as any).name
=== 'ModalHeader'; apply this check where ModalHeaderProps and titleId are
injected so wrapped/forwardRef/memo variants are handled too.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61585652-281e-4d5b-8809-38e79975cb30

📥 Commits

Reviewing files that changed from the base of the PR and between 6204420 and 48e33cb.

📒 Files selected for processing (2)
  • src/shared/ui/modal/modal.stories.tsx
  • src/shared/ui/modal/modal.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/shared/ui/modal/modal.stories.tsx

Comment on lines +109 to +127
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>
);
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.

@sebeeeen sebeeeen linked an issue Apr 1, 2026 that may be closed by this pull request
6 tasks
@sebeeeen sebeeeen merged commit 37630fd into dev Apr 1, 2026
1 check passed
@sebeeeen sebeeeen deleted the feat/modal branch April 1, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 공통 모달(Modal/Dialog) 컴포넌트 시스템 구현

2 participants