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:
1
src/features/exit-hold/index.ts
Normal file
1
src/features/exit-hold/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/ExitHoldButton';
|
||||
91
src/features/exit-hold/model/useHoldToConfirm.ts
Normal file
91
src/features/exit-hold/model/useHoldToConfirm.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const HOLD_DURATION_MS = 1000;
|
||||
const BOOST_DURATION_MS = 50;
|
||||
|
||||
const mapProgress = (elapsedMs: number) => {
|
||||
if (elapsedMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (elapsedMs <= BOOST_DURATION_MS) {
|
||||
return 0.2 * (elapsedMs / BOOST_DURATION_MS);
|
||||
}
|
||||
|
||||
const tailElapsedMs = Math.min(elapsedMs - BOOST_DURATION_MS, HOLD_DURATION_MS - BOOST_DURATION_MS);
|
||||
return 0.2 + 0.8 * (tailElapsedMs / (HOLD_DURATION_MS - BOOST_DURATION_MS));
|
||||
};
|
||||
|
||||
export const useHoldToConfirm = (onConfirm: () => void) => {
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number | null>(null);
|
||||
const confirmedRef = useRef(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isHolding, setHolding] = useState(false);
|
||||
|
||||
const clearFrame = () => {
|
||||
if (frameRef.current !== null) {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
clearFrame();
|
||||
startRef.current = null;
|
||||
confirmedRef.current = false;
|
||||
setHolding(false);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
const step = () => {
|
||||
if (startRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = performance.now() - startRef.current;
|
||||
const nextProgress = mapProgress(elapsedMs);
|
||||
const clampedProgress = Math.min(nextProgress, 1);
|
||||
setProgress(clampedProgress);
|
||||
|
||||
if (clampedProgress >= 1 && !confirmedRef.current) {
|
||||
confirmedRef.current = true;
|
||||
onConfirm();
|
||||
window.setTimeout(() => {
|
||||
reset();
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
|
||||
frameRef.current = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (isHolding) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearFrame();
|
||||
confirmedRef.current = false;
|
||||
startRef.current = performance.now();
|
||||
setHolding(true);
|
||||
frameRef.current = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (!isHolding) {
|
||||
return;
|
||||
}
|
||||
|
||||
reset();
|
||||
};
|
||||
|
||||
return {
|
||||
progress,
|
||||
isHolding,
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
119
src/features/exit-hold/ui/ExitHoldButton.tsx
Normal file
119
src/features/exit-hold/ui/ExitHoldButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user