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
91 changes: 40 additions & 51 deletions app/group/[id]/members/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client"
"use client"

import { useState } from "react"
import { mockGroup } from "@/entities/group/model/mock-data"
import { MemberCard } from "@/entities/member/ui/member-card"
import { Copy, Check, Link2, X } from "lucide-react"
import { Modal } from "@/shared/ui/modal"
import { Copy, Check, Link2 } from "lucide-react"

export default function MembersPage() {
const group = mockGroup
Expand All @@ -12,6 +13,11 @@ export default function MembersPage() {

const inviteLink = `https://triple.app/invite/${group.id}?code=aBcD1234`

const closeModal = () => {
setShowModal(false)
setCopied(false)
}

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteLink)
Expand Down Expand Up @@ -52,56 +58,39 @@ export default function MembersPage() {
</button>
</div>

{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/40 p-4">
<div className="w-full max-w-md rounded-2xl bg-background p-6 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">{"초대 링크"}</h2>
<button
type="button"
onClick={() => {
setShowModal(false)
setCopied(false)
}}
className="text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-5 w-5" />
</button>
</div>

<p className="mt-3 text-sm text-muted-foreground">
{"아래 링크를 공유해 그룹에 멤버를 초대하세요."}
</p>

<div className="mt-5 flex items-center gap-2 rounded-lg bg-muted px-4 py-3">
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm text-foreground">{inviteLink}</span>
</div>

<button
type="button"
onClick={handleCopy}
className={`mt-5 flex w-full items-center justify-center gap-2 rounded-full py-3 text-sm font-bold transition-all ${
copied
? "bg-green-500 text-background"
: "bg-primary text-primary-foreground hover:opacity-90"
}`}
>
{copied ? (
<>
<Check className="h-4 w-4" />
{"복사 완료!"}
</>
) : (
<>
<Copy className="h-4 w-4" />
{"링크 복사하기"}
</>
)}
</button>
</div>
<Modal
open={showModal}
onClose={closeModal}
title={"초대 링크"}
description={"아래 링크를 공유해 그룹에 멤버를 초대하세요."}
>
<div className="mt-5 flex items-center gap-2 rounded-lg bg-muted px-4 py-3">
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm text-foreground">{inviteLink}</span>
</div>
)}

<button
type="button"
onClick={handleCopy}
className={`mt-5 flex w-full items-center justify-center gap-2 rounded-full py-3 text-sm font-bold transition-all ${
copied
? "bg-green-500 text-background"
: "bg-primary text-primary-foreground hover:opacity-90"
}`}
>
{copied ? (
<>
<Check className="h-4 w-4" />
{"복사 완료!"}
</>
) : (
<>
<Copy className="h-4 w-4" />
{"링크 복사하기"}
</>
)}
</button>
</Modal>
</div>
)
}
7 changes: 5 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ export default function RootLayout({
<html lang="ko">
<body className={`${notoSansKR.className} antialiased`}>
<QueryProvider>
{children}
<Toaster />
<div id="app-root" className="isolate">
{children}
<Toaster />
</div>
<div id="modal-root" />
</QueryProvider>
</body>
</html>
Expand Down
154 changes: 154 additions & 0 deletions shared/ui/modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
'use client'

import { useCallback, useEffect, useId, useRef, type KeyboardEvent, type ReactNode } from 'react'
import { X } from 'lucide-react'

import { cn } from '@/shared/lib/utils'
import { Portal } from '@/shared/ui/portal'

interface ModalProps {
open: boolean
onClose: () => void
title: string
description?: string
children: ReactNode
className?: string
closeLabel?: string
}

export function Modal({
open,
onClose,
title,
description,
children,
className,
closeLabel = '모달 닫기',
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null)
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null)
const titleId = useId()
const descriptionId = useId()

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])

if (!open) {
return null;
}

return (
<Portal>
<div
className="fixed inset-0 flex items-center justify-center bg-foreground/40 p-4"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn('w-full max-w-md rounded-2xl bg-background p-6 shadow-xl', className)}
>
<div className="flex items-center justify-between">
<h2 id={titleId} className="text-lg font-bold text-foreground">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={closeLabel}
className="text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-5 w-5" />
</button>
</div>

{description ? (
<p id={descriptionId} className="mt-3 text-sm text-muted-foreground">
{description}
</p>
) : null}

{children}
</div>
</div>
</Portal>
)
}
23 changes: 23 additions & 0 deletions shared/ui/portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client'

import { useEffect, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'

interface PortalProps {
children: ReactNode
containerId?: string
}

export function Portal({ children, containerId = 'modal-root' }: PortalProps) {
const [container, setContainer] = useState<HTMLElement | null>(null)

useEffect(() => {
setContainer(document.getElementById(containerId))
}, [containerId])

if (!container) {
return null
}

return createPortal(children, container)
}