diff --git a/src/components/progressbar/ProgressBar.module.css b/src/components/progressbar/ProgressBar.module.css new file mode 100644 index 0000000..f17d3a5 --- /dev/null +++ b/src/components/progressbar/ProgressBar.module.css @@ -0,0 +1,42 @@ +.track { + width: 100%; + max-width: 322px; + height: 20px; + + border-radius: 9999px; + overflow: hidden; + position: relative; + + background: + repeating-linear-gradient( + -45deg, + rgba(0, 0, 0, 0.04) 0px, + rgba(0, 0, 0, 0.04) 40px, + rgba(255, 255, 255, 0.7) 40px, + rgba(255, 255, 255, 0.7) 80px + ), + var(--color-brand-secondary); +} + +.fill { + height: 100%; + border-radius: inherit; + background: var(--color-brand-primary); + + transition: width 800ms ease; + will-change: width; +} + +@media (min-width: 744px) { + .track { + max-width: 566px; + height: 27px; + } +} + +@media (min-width: 1024px) { + .track { + max-width: 1010px; + height: 27px; + } +} diff --git a/src/components/progressbar/ProgressBar.tsx b/src/components/progressbar/ProgressBar.tsx new file mode 100644 index 0000000..684ed5d --- /dev/null +++ b/src/components/progressbar/ProgressBar.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useEffect, useMemo, useRef } from 'react'; +import styles from './ProgressBar.module.css'; + +export type ProgressBarProps = { + /** 0~1 (0.3 = 30%) */ + value?: number; + /** done/total로도 계산 가능 */ + done?: number; + total?: number; + + /** 애니메이션 (default: true) */ + animate?: boolean; + + replayOnMount?: boolean; + + durationMs?: number; + + ariaLabel?: string; + className?: string; +}; + +function clamp01(n: number): number { + if (Number.isNaN(n)) return 0; + return Math.max(0, Math.min(1, n)); +} + +export default function ProgressBar({ + value, + done, + total, + animate = true, + replayOnMount = true, + durationMs = 800, + ariaLabel = 'progress', + className, +}: ProgressBarProps) { + const ratio = useMemo(() => { + if (typeof value === 'number') return clamp01(value); + if (typeof done === 'number' && typeof total === 'number' && total > 0) { + return clamp01(done / total); + } + return 0; + }, [value, done, total]); + + const targetPercent = useMemo(() => ratio * 100, [ratio]); + + const fillRef = useRef(null); + const didMountRef = useRef(false); + const rafRef = useRef(null); + + useEffect(() => { + const el = fillRef.current; + if (!el) return; + + el.style.transition = animate ? `width ${durationMs}ms ease` : 'none'; + + if (!animate) { + el.style.width = `${targetPercent}%`; + didMountRef.current = true; + return; + } + + const isFirstMount = !didMountRef.current; + didMountRef.current = true; + + if (replayOnMount && isFirstMount) { + el.style.width = '0%'; + + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + el.style.width = `${targetPercent}%`; + }); + + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + } + + el.style.width = `${targetPercent}%`; + }, [animate, durationMs, replayOnMount, targetPercent]); + + return ( +
+
+
+ ); +} diff --git a/src/components/progressbar/index.ts b/src/components/progressbar/index.ts new file mode 100644 index 0000000..26b78a6 --- /dev/null +++ b/src/components/progressbar/index.ts @@ -0,0 +1,2 @@ +export { default } from './ProgressBar'; +export type { ProgressBarProps } from './ProgressBar';