feat(ui): P4-4 별점 공통화 + 로그인 모달 접근성 (#281+#283)
#281 (리뷰/메모 UI): - Stars 컴포넌트 신규 (lib 분리 가능한 공통 별점) — 0.5 단위 절반 채우기 시각 구분 - ReviewSection/MemoSection의 StarDisplay 제거 → 공통 Stars 사용 (시각 일관성) - StarSelector: role='radiogroup'/role='radio' + aria-checked, 44×44px 터치 영역, 반쪽 별 '⯨' 표시로 시각 차별화 - ReviewSection/MemoSection: API 실패 try/catch + alert 사용자 피드백 - MyReviewsList: Math.round 별점 → Stars 0.5단위 정확 렌더 #283 (로그인 메뉴): - LoginMenu: useEscapeKey + useFocusTrap + useBodyScrollLock 적용 - role='dialog' / aria-modal / aria-labelledby / aria-label='로그인 창 닫기' - onError 콘솔만 → 인라인 role='alert' 메시지로 사용자 피드백 - max-w-xs → max-w-sm (위젯 260px + 패딩 24px = 308px 안전 수용) 후속 분리: - #343 (next/image + ARIA Tabs + Stars 테스트) - #344 (z-index 토큰 + i18n) Refs: #281 #283
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y";
|
||||
|
||||
interface LoginMenuProps {
|
||||
onGoogleSuccess: (credential: string) => void;
|
||||
@@ -10,6 +11,22 @@ interface LoginMenuProps {
|
||||
|
||||
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = "login-dialog-title";
|
||||
|
||||
// #283 — 모달 접근성: ESC / focus trap / body scroll lock
|
||||
useEscapeKey(open, () => setOpen(false));
|
||||
useFocusTrap(open, dialogRef);
|
||||
useBodyScrollLock(open);
|
||||
|
||||
const handleSuccess = (res: { credential?: string }) => {
|
||||
setErrorMsg(null);
|
||||
if (res.credential) {
|
||||
onGoogleSuccess(res.credential);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -26,26 +43,34 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
||||
>
|
||||
<div className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-sm space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||
<h3 id={titleId} className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none"
|
||||
aria-label="로그인 창 닫기"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none p-2 -m-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
||||
{errorMsg && (
|
||||
<p role="alert" className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 rounded p-2">
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<GoogleLogin
|
||||
onSuccess={(res) => {
|
||||
if (res.credential) {
|
||||
onGoogleSuccess(res.credential);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
onSuccess={handleSuccess}
|
||||
onError={() => setErrorMsg("Google 로그인에 실패했습니다. 팝업 차단 또는 네트워크 상태를 확인해주세요.")}
|
||||
size="large"
|
||||
width="260"
|
||||
text="signin_with"
|
||||
|
||||
Reference in New Issue
Block a user