feat(space): 나가기 버튼 롱프레스 가속 진행 인터랙션 추가

맥락:

- /space에서 실수 이탈을 줄이면서도 명확한 탈출 동선을 유지하기 위해 나가기 액션을 1초 롱프레스 방식으로 변경

변경사항:

- features/exit-hold 추가(useHoldToConfirm, ExitHoldButton)

- 1초 롱프레스와 가속 진행 규칙(0.05초 -> 20%)을 feature 내부에 구현

- 몰입 OFF에서는 bar(좌->우 fill), 몰입 ON에서는 ring 진행 표시로 분기

- 1초 미만 해제/마우스 leave/touch cancel 시 진행률 즉시 리셋

- 완료 시 나가기(더미) 토스트 + 몰입 모드 OFF 동작 연결

- docs/90_current_state.md, docs/session_brief.md 상태 업데이트

검증:

- npx tsc --noEmit

세션-상태: 상단 나가기 액션은 롱프레스 완료 시에만 트리거됨

세션-다음: 롱프레스 인터랙션 인지성 보완용 힌트 카피 도입 여부 검토

세션-리스크: 터치 환경에서 롱프레스 UI 의도를 즉시 이해하지 못할 가능성 있음
This commit is contained in:
2026-02-27 14:29:46 +09:00
parent e2fb720a55
commit 73e7d5004c
7 changed files with 232 additions and 22 deletions

View File

@@ -0,0 +1,119 @@
'use client';
import type { KeyboardEvent } from 'react';
import { cn } from '@/shared/lib/cn';
import { useHoldToConfirm } from '../model/useHoldToConfirm';
interface ExitHoldButtonProps {
variant: 'bar' | 'ring';
onConfirm: () => void;
className?: string;
}
const RING_RADIUS = 13;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
export const ExitHoldButton = ({
variant,
onConfirm,
className,
}: ExitHoldButtonProps) => {
const { progress, isHolding, start, cancel } = useHoldToConfirm(onConfirm);
const ringOffset = RING_CIRCUMFERENCE * (1 - progress);
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
start();
}
};
const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
cancel();
}
};
if (variant === 'ring') {
return (
<button
type="button"
aria-label="길게 눌러 나가기"
onMouseDown={start}
onMouseUp={cancel}
onMouseLeave={cancel}
onTouchStart={start}
onTouchEnd={cancel}
onTouchCancel={cancel}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onClick={(event) => event.preventDefault()}
className={cn(
'relative inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/8 text-white/74 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
isHolding && 'bg-white/16',
className,
)}
>
<svg
aria-hidden
className="-rotate-90 absolute inset-0 h-9 w-9"
viewBox="0 0 32 32"
>
<circle
cx="16"
cy="16"
r={RING_RADIUS}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="2"
/>
<circle
cx="16"
cy="16"
r={RING_RADIUS}
fill="none"
stroke="rgba(186,230,253,0.92)"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={ringOffset}
/>
</svg>
<span aria-hidden className="relative z-10 text-[12px]">
</span>
</button>
);
}
return (
<button
type="button"
aria-label="길게 눌러 나가기"
onMouseDown={start}
onMouseUp={cancel}
onMouseLeave={cancel}
onTouchStart={start}
onTouchEnd={cancel}
onTouchCancel={cancel}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onClick={(event) => event.preventDefault()}
className={cn(
'group relative overflow-hidden rounded-lg bg-white/8 px-2.5 py-1.5 text-xs text-white/82 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
className,
)}
>
<span
aria-hidden
className="absolute inset-y-0 left-0 rounded-lg bg-sky-200/24 transition-[width] duration-75"
style={{ width: `${progress * 100}%` }}
/>
<span className="relative z-10 inline-flex items-center gap-1">
<span aria-hidden></span>
<span></span>
</span>
</button>
);
};