style(space): 리추얼 진입 UX와 포커스 전환 흐름을 고급화
맥락: - /space 진입 경험이 설정 패널처럼 보여 몰입형 라운지 톤이 약했습니다. - 목표 입력 후 시작 전환 동선을 더 빠르고 일관되게 만들 필요가 있었습니다. 변경사항: - 도크 아이콘을 이모지에서 단일 라인 SVG 세트로 통일해 시각 언어 일관성을 맞췄습니다. - Setup Drawer 밀도를 낮추고(타이포/테두리/칩 크기) 3-step 리추얼 흐름을 더 간결하게 정리했습니다. - 목표 입력 필드 자동 포커스를 추가해 진입 즉시 타이핑이 가능하도록 했습니다. - 시작 버튼을 form submit으로 연결해 Enter 입력과 버튼 클릭이 동일하게 동작하도록 변경했습니다. - SpaceSideSheet에 300ms 닫힘 전환(오버레이/시트 opacity+translate) 애니메이션을 적용했습니다. - Focus 진입 토스트 카피를 목표 중심 문구로 바꾸고 Setup 선택지를 최소 개수로 제한했습니다. - 배경에 미세 stage-pan/light-drift 키프레임을 추가해 정적인 평면감을 줄였습니다. 검증: - npx tsc --noEmit - npm run build 세션-상태: /space에서 목표 입력 후 10초 내 Focus 전환 가능한 리추얼 흐름이 정리되었습니다. 세션-다음: 실제 브라우저에서 애니메이션 강도와 드로어 밀도 체감 QA를 진행합니다. 세션-리스크: 저사양 환경에서 배경 미세 모션이 과하게 느껴질 수 있어 추후 reduce-motion 강화를 검토할 수 있습니다.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
const SHEET_TRANSITION_MS = 300;
|
||||
|
||||
interface SpaceSideSheetProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
@@ -24,6 +26,41 @@ export const SpaceSideSheet = ({
|
||||
widthClassName,
|
||||
dismissible = true,
|
||||
}: SpaceSideSheetProps) => {
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [visible, setVisible] = useState(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
setVisible(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
closeTimerRef.current = null;
|
||||
}, SHEET_TRANSITION_MS);
|
||||
|
||||
return () => {
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
@@ -42,7 +79,7 @@ export const SpaceSideSheet = ({
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) {
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -53,39 +90,54 @@ export const SpaceSideSheet = ({
|
||||
type="button"
|
||||
aria-label="시트 닫기"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-40 bg-slate-950/18 backdrop-blur-[1px]"
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-slate-950/14 backdrop-blur-[1px] transition-opacity duration-300',
|
||||
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div aria-hidden className="fixed inset-0 z-40 bg-slate-950/12 backdrop-blur-[1px]" />
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-slate-950/8 backdrop-blur-[1px] transition-opacity duration-300',
|
||||
visible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 right-0 z-50 p-2 sm:p-3',
|
||||
'fixed inset-y-0 right-0 z-50 p-2 transition-opacity duration-300 sm:p-3',
|
||||
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
widthClassName ?? 'w-[min(360px,92vw)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-3xl border border-white/20 bg-slate-950/58 text-white shadow-[0_20px_60px_rgba(2,6,23,0.42)] backdrop-blur-2xl">
|
||||
<header className="flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col overflow-hidden rounded-3xl border border-white/14 bg-[linear-gradient(180deg,rgba(15,23,42,0.74)_0%,rgba(10,15,30,0.62)_100%)] text-white shadow-[0_18px_52px_rgba(2,6,23,0.34)] backdrop-blur-2xl transition-transform duration-300',
|
||||
visible ? 'translate-x-0' : 'translate-x-8',
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-white/7 px-4 py-3 sm:px-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{title}</h2>
|
||||
{subtitle ? <p className="mt-1 text-xs text-white/58">{subtitle}</p> : null}
|
||||
<h2 className="text-[1.05rem] font-semibold tracking-tight text-white">{title}</h2>
|
||||
{subtitle ? <p className="mt-1 text-[11px] text-white/56">{subtitle}</p> : null}
|
||||
</div>
|
||||
{dismissible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/8 text-sm text-white/80 transition-colors hover:bg-white/14 hover:text-white"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{children}</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5 sm:px-5">{children}</div>
|
||||
|
||||
{footer ? <footer className="border-t border-white/10 bg-white/4 px-4 py-3 sm:px-5">{footer}</footer> : null}
|
||||
{footer ? <footer className="border-t border-white/8 bg-white/[0.02] px-4 py-3 sm:px-5">{footer}</footer> : null}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user