Skip to content

modal은 modal-root에서 렌더링하도록 Portal 생성#16

Merged
SongMinQQ merged 2 commits into
developfrom
feat/create-portal
Feb 27, 2026
Merged

modal은 modal-root에서 렌더링하도록 Portal 생성#16
SongMinQQ merged 2 commits into
developfrom
feat/create-portal

Conversation

@SongMinQQ

Copy link
Copy Markdown
Contributor

#️⃣연관된 이슈

#15

📝작업 내용

목적

  • 페이지에 있던 모달 인프라 로직을 공통 컴포넌트로 분리
  • header z-50 충돌을 z-index 의존 없이 해결
  • WAI-ARIA 모달 관행에 맞춘 접근성 강화

변경 파일

  • app/layout.tsx
  • shared/ui/portal.tsx
  • shared/ui/modal.tsx
  • app/group/[id]/members/page.tsx

핵심 변경

  1. app/layout.tsx
  • #app-root, #modal-root 분리
  • #app-rootisolate 적용
  1. shared/ui/portal.tsx
  • Portal 대상 컨테이너를 기본 modal-root로 변경
  1. shared/ui/modal.tsx (신규)
  • role="dialog", aria-modal, aria-labelledby, aria-describedby
  • ESC 닫기
  • Tab/Shift+Tab 포커스 트랩
  • 오픈 시 초기 포커스 이동, 닫힐 때 포커스 복귀
  • 배경 aria-hidden + inert 처리
  • body scroll lock/restore
  1. app/group/[id]/members/page.tsx
  • 모달 인프라 코드 제거
  • Modal 컴포넌트 소비 형태로 단순화

이슈/해결

  • 이슈: headerz-50이 모달 위에 표시됨
  • 해결: app-rootisolate로 분리하고, 모달은 modal-root에 렌더

스크린샷 (선택)

image

@SongMinQQ SongMinQQ self-assigned this Feb 27, 2026
@SongMinQQ SongMinQQ added the enhancement New feature or request label Feb 27, 2026
@SongMinQQ SongMinQQ linked an issue Feb 27, 2026 that may be closed by this pull request
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 애플리케이션의 모달 관리 방식을 전반적으로 개선합니다. 기존에 페이지별로 분산되어 있던 모달 로직을 재사용 가능한 공통 컴포넌트로 통합하여 코드의 응집도를 높이고 유지보수를 용이하게 합니다. 특히, z-index 충돌 문제를 해결하고 WAI-ARIA 표준을 준수하여 사용자 접근성을 크게 향상시키는 데 중점을 두었습니다.

Highlights

  • 모달 로직 분리 및 재사용성 향상: 기존에 페이지별로 분산되어 있던 모달 인프라 로직을 공통 컴포넌트로 분리하여 재사용성을 높였습니다.
  • Z-index 충돌 해결: 헤더의 z-50과 모달 간의 z-index 충돌 문제를 z-index 의존 없이 해결했습니다. 이는 #app-root#modal-root를 분리하고 #app-rootisolate를 적용함으로써 가능해졌습니다.
  • 접근성 강화: WAI-ARIA 모달 관행에 맞춰 role="dialog", aria-modal, aria-labelledby, aria-describedby 등의 속성을 적용하고, ESC 키 닫기, Tab/Shift+Tab 포커스 트랩, 오픈 시 초기 포커스 이동 및 닫힐 때 포커스 복귀, 배경 aria-hiddeninert 처리, body 스크롤 잠금/해제 기능을 구현하여 접근성을 강화했습니다.
  • Portal 컴포넌트 도입: shared/ui/portal.tsx 파일을 새로 추가하여 Portal 대상 컨테이너를 기본 modal-root로 변경했습니다.
  • 페이지 코드 단순화: app/group/[id]/members/page.tsx에서 기존 모달 인프라 코드를 제거하고 새로 생성된 Modal 컴포넌트를 소비하는 형태로 코드를 단순화했습니다.
Changelog
  • app/group/[id]/members/page.tsx
    • Modal 컴포넌트를 임포트했습니다.
    • 모달 닫기 로직을 캡슐화하는 closeModal 함수를 추가했습니다.
    • 인라인으로 구현되어 있던 모달 UI 및 로직을 새로 생성된 Modal 컴포넌트 사용으로 대체했습니다.
  • app/layout.tsx
    • DOM 구조를 #app-root#modal-root로 분리했습니다.
    • #app-rootisolate 클래스를 적용하여 렌더링 계층을 명확히 했습니다.
  • shared/ui/modal.tsx
    • 새로운 파일로, 재사용 가능한 Modal 컴포넌트를 추가했습니다.
    • WAI-ARIA 속성, 키보드 접근성(ESC 닫기, Tab 포커스 트랩), 포커스 관리, 배경 요소 비활성화(aria-hidden, inert), body 스크롤 잠금 기능을 포함합니다.
  • shared/ui/portal.tsx
    • 새로운 파일로, React createPortal을 활용하여 자식 요소를 지정된 DOM 노드(기본값 modal-root)에 렌더링하는 Portal 컴포넌트를 추가했습니다.
Activity
  • 현재까지 이 PR에 대한 활동은 없습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

이 PR은 재사용 가능하고 접근성이 뛰어난 Modal 컴포넌트를 Portal을 사용하여 구현한 훌륭한 개선입니다. 이를 통해 members 페이지의 코드가 훨씬 깔끔해졌고, z-index 충돌 문제를 isolate 속성을 사용하여 효과적으로 해결한 점도 인상적입니다. 전반적으로 좋은 변경이며, 새로 추가된 Modal 컴포넌트의 안정성을 더욱 높이기 위한 한 가지 제안 사항을 리뷰에 남겼습니다.

Comment thread shared/ui/modal.tsx Outdated
Comment on lines +33 to +100
const getFocusableElements = () => {
if (!dialogRef.current) return []

const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
)

return Array.from(focusables)
}

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}

if (event.key !== 'Tab') return

const focusables = getFocusableElements()
if (focusables.length === 0) {
event.preventDefault()
dialogRef.current?.focus()
return
}

const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement

if (event.shiftKey && active === first) {
event.preventDefault()
last.focus()
return
}

if (!event.shiftKey && active === last) {
event.preventDefault()
first.focus()
}
}

useEffect(() => {
if (!open) return

const appRoot = document.getElementById('app-root')
const previousOverflow = document.body.style.overflow
previouslyFocusedElementRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null

appRoot?.setAttribute('aria-hidden', 'true')
appRoot?.setAttribute('inert', '')
document.body.style.overflow = 'hidden'

const focusables = getFocusableElements()
if (focusables.length > 0) {
focusables[0].focus()
} else {
dialogRef.current?.focus()
}

return () => {
appRoot?.removeAttribute('aria-hidden')
appRoot?.removeAttribute('inert')
document.body.style.overflow = previousOverflow
previouslyFocusedElementRef.current?.focus()
}
}, [open])

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

getFocusableElementshandleKeyDown 함수가 컴포넌트 렌더링 시마다 재생성되고 있습니다. 이는 성능에 영향을 줄 수 있으며, useEffect와 같은 React Hook에서 의존성 문제를 야기할 수 있습니다.

또한, useEffect Hook이 외부 스코프의 getFocusableElements 함수를 사용하고 있지만 의존성 배열에 포함하지 않아, 잠재적인 버그의 원인이 될 수 있습니다. (stale closure)

이 문제들을 해결하기 위해 useCallback을 사용하여 함수들을 메모이제이션하고, useEffect의 의존성 배열을 올바르게 수정하는 것을 제안합니다. useCallbackreact에서 import하는 것을 잊지 마세요.

  const getFocusableElements = useCallback(() => {
    if (!dialogRef.current) return []

    const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
      'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
    )

    return Array.from(focusables)
  }, [])

  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.key === 'Escape') {
        event.preventDefault()
        onClose()
        return
      }

      if (event.key !== 'Tab') return

      const focusables = getFocusableElements()
      if (focusables.length === 0) {
        event.preventDefault()
        dialogRef.current?.focus()
        return
      }

      const first = focusables[0]
      const last = focusables[focusables.length - 1]
      const active = document.activeElement

      if (event.shiftKey && active === first) {
        event.preventDefault()
        last.focus()
        return
      }

      if (!event.shiftKey && active === last) {
        event.preventDefault()
        first.focus()
      }
    },
    [onClose, getFocusableElements],
  )

  useEffect(() => {
    if (!open) return

    const appRoot = document.getElementById('app-root')
    const previousOverflow = document.body.style.overflow
    previouslyFocusedElementRef.current =
      document.activeElement instanceof HTMLElement ? document.activeElement : null

    appRoot?.setAttribute('aria-hidden', 'true')
    appRoot?.setAttribute('inert', '')
    document.body.style.overflow = 'hidden'

    const focusables = getFocusableElements()
    if (focusables.length > 0) {
      focusables[0].focus()
    } else {
      dialogRef.current?.focus()
    }

    return () => {
      appRoot?.removeAttribute('aria-hidden')
      appRoot?.removeAttribute('inert')
      document.body.style.overflow = previousOverflow
      previouslyFocusedElementRef.current?.focus()
    }
  }, [open, getFocusableElements])

@SongMinQQ SongMinQQ merged commit 9be75bb into develop Feb 27, 2026
@SongMinQQ SongMinQQ deleted the feat/create-portal branch February 27, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Portal 적용

1 participant