fix(exit-hold): bar 진행 표시 즉시 반영 및 리셋 애니메이션 수정
맥락: - 몰입 OFF의 나가기 롱프레스 bar에서 진행 표시가 즉시 보이지 않고 완료 시 0으로 역방향 축소되는 문제를 해결하기 위해 변경사항: - bar 진행 표시를 JS width 갱신에서 CSS keyframes 기반으로 교체 - 키프레임에 가속 규칙 반영(0.05초 20%, 1.0초 100%) - 완료 후 100% 상태를 짧게 유지한 뒤 언마운트하도록 훅 상태(isCompleted) 보강 - progress fill의 rounded 캡을 제거해 끝단 직선화 - docs/90_current_state.md, docs/session_brief.md 최신 상태 반영 검증: - npx tsc --noEmit 세션-상태: bar 롱프레스 진행은 눌렀을 때 즉시 보이고 완료 리셋 시 역방향 축소가 사라짐 세션-다음: 롱프레스 인지성 보조 카피 도입 여부 검토 세션-리스크: bar(CSS)와 ring(JS) 진행 로직이 분리되어 향후 규칙 변경 시 동시 점검 필요
This commit is contained in:
@@ -37,3 +37,15 @@ body {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exit-hold-bar-fill {
|
||||
0% {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
5% {
|
||||
transform: scaleX(0.2);
|
||||
}
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const HOLD_DURATION_MS = 1000;
|
||||
const BOOST_DURATION_MS = 50;
|
||||
const COMPLETE_HOLD_MS = 160;
|
||||
|
||||
const mapProgress = (elapsedMs: number) => {
|
||||
if (elapsedMs <= 0) {
|
||||
@@ -20,10 +21,13 @@ const mapProgress = (elapsedMs: number) => {
|
||||
|
||||
export const useHoldToConfirm = (onConfirm: () => void) => {
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const confirmTimeoutRef = useRef<number | null>(null);
|
||||
const completeTimeoutRef = 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 [isCompleted, setCompleted] = useState(false);
|
||||
|
||||
const clearFrame = () => {
|
||||
if (frameRef.current !== null) {
|
||||
@@ -32,14 +36,35 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
const clearTimers = () => {
|
||||
if (confirmTimeoutRef.current !== null) {
|
||||
window.clearTimeout(confirmTimeoutRef.current);
|
||||
confirmTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (completeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(completeTimeoutRef.current);
|
||||
completeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = (withCompleted = false) => {
|
||||
clearFrame();
|
||||
clearTimers();
|
||||
startRef.current = null;
|
||||
confirmedRef.current = false;
|
||||
setHolding(false);
|
||||
setCompleted(withCompleted);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearFrame();
|
||||
clearTimers();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const step = () => {
|
||||
if (startRef.current === null) {
|
||||
return;
|
||||
@@ -52,10 +77,17 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
|
||||
|
||||
if (clampedProgress >= 1 && !confirmedRef.current) {
|
||||
confirmedRef.current = true;
|
||||
if (confirmTimeoutRef.current !== null) {
|
||||
window.clearTimeout(confirmTimeoutRef.current);
|
||||
confirmTimeoutRef.current = null;
|
||||
}
|
||||
setHolding(false);
|
||||
setCompleted(true);
|
||||
onConfirm();
|
||||
window.setTimeout(() => {
|
||||
reset();
|
||||
}, 120);
|
||||
|
||||
completeTimeoutRef.current = window.setTimeout(() => {
|
||||
reset(false);
|
||||
}, COMPLETE_HOLD_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,15 +95,32 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (isHolding) {
|
||||
if (isHolding || isCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
clearFrame();
|
||||
confirmedRef.current = false;
|
||||
startRef.current = performance.now();
|
||||
setCompleted(false);
|
||||
setProgress(0);
|
||||
setHolding(true);
|
||||
startRef.current = performance.now();
|
||||
frameRef.current = window.requestAnimationFrame(step);
|
||||
confirmTimeoutRef.current = window.setTimeout(() => {
|
||||
if (!confirmedRef.current) {
|
||||
confirmedRef.current = true;
|
||||
confirmTimeoutRef.current = null;
|
||||
setProgress(1);
|
||||
setHolding(false);
|
||||
setCompleted(true);
|
||||
onConfirm();
|
||||
|
||||
completeTimeoutRef.current = window.setTimeout(() => {
|
||||
reset(false);
|
||||
}, COMPLETE_HOLD_MS);
|
||||
}
|
||||
}, HOLD_DURATION_MS);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
@@ -85,6 +134,7 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
|
||||
return {
|
||||
progress,
|
||||
isHolding,
|
||||
isCompleted,
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ExitHoldButton = ({
|
||||
onConfirm,
|
||||
className,
|
||||
}: ExitHoldButtonProps) => {
|
||||
const { progress, isHolding, start, cancel } = useHoldToConfirm(onConfirm);
|
||||
const { progress, isHolding, isCompleted, start, cancel } = useHoldToConfirm(onConfirm);
|
||||
const ringOffset = RING_CIRCUMFERENCE * (1 - progress);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
@@ -101,15 +101,20 @@ export const ExitHoldButton = ({
|
||||
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',
|
||||
'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}%` }}
|
||||
/>
|
||||
{isHolding || isCompleted ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute inset-0 z-0 origin-left transform-gpu bg-sky-200/24 rounded-none',
|
||||
isHolding && 'animate-[exit-hold-bar-fill_1000ms_linear_forwards]',
|
||||
)}
|
||||
style={isCompleted ? { transform: 'scaleX(1)' } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 inline-flex items-center gap-1">
|
||||
<span aria-hidden>⤫</span>
|
||||
<span>나가기</span>
|
||||
|
||||
Reference in New Issue
Block a user