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:
joungmin
2026-06-15 14:33:15 +09:00
parent ea8db4bef3
commit bff3dcc200
5 changed files with 160 additions and 98 deletions

View File

@@ -5,11 +5,13 @@ import { api } from "@/lib/api";
import type { Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import Icon from "@/components/Icon";
import Stars from "@/components/Stars";
interface MemoSectionProps {
restaurantId: string;
}
// #281 — ReviewSection의 StarSelector와 동일 UX (0.5 단위 + 44px 터치 + ARIA radiogroup)
function StarSelector({
value,
onChange,
@@ -18,38 +20,32 @@ function StarSelector({
onChange: (v: number) => void;
}) {
return (
<div className="flex items-center gap-1">
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
<span className="text-xs text-gray-500 mr-1">:</span>
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
<button
key={v}
type="button"
onClick={() => onChange(v)}
className={`w-6 h-6 text-xs rounded border ${
value === v
? "bg-yellow-500 text-white border-yellow-600"
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
}`}
>
{v}
</button>
))}
{[1, 2, 3, 4, 5].map((v) => {
const nextVal = value === v ? v - 0.5 : v;
return (
<button
key={v}
type="button"
role="radio"
aria-checked={value >= v - 0.5 && value <= v}
aria-label={`${nextVal}`}
onClick={() => onChange(nextVal)}
className="min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
title={`${nextVal}`}
>
<span className={`text-xl ${v <= value ? "text-yellow-500" : v - 0.5 === value ? "text-yellow-400" : "text-gray-300"}`}>
{v <= value ? "★" : v - 0.5 === value ? "⯨" : "☆"}
</span>
</button>
);
})}
{value > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{value}</span>}
</div>
);
}
function StarDisplay({ rating }: { rating: number }) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<span key={i} className={rating >= i - 0.5 ? "text-yellow-500" : "text-gray-300"}>
</span>
);
}
return <span className="text-sm">{stars}</span>;
}
export default function MemoSection({ restaurantId }: MemoSectionProps) {
const { user } = useAuth();
const [memo, setMemo] = useState<Memo | null>(null);
@@ -104,6 +100,9 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
setMemo(saved);
setShowForm(false);
setEditing(false);
} catch (err) {
// #281 — 사용자 피드백
alert(`메모 저장 실패: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setSubmitting(false);
}
@@ -111,8 +110,12 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
const handleDelete = async () => {
if (!confirm("메모를 삭제하시겠습니까?")) return;
await api.deleteMemo(restaurantId);
setMemo(null);
try {
await api.deleteMemo(restaurantId);
setMemo(null);
} catch (err) {
alert(`메모 삭제 실패: ${err instanceof Error ? err.message : String(err)}`);
}
};
return (
@@ -167,7 +170,7 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
) : memo ? (
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
<div className="flex items-center gap-2 mb-1">
{memo.rating && <StarDisplay rating={memo.rating} />}
{memo.rating && <Stars rating={memo.rating} />}
{memo.visited_at && (
<span className="text-xs text-gray-400">: {memo.visited_at}</span>
)}