{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "timer",
"title": "Timer",
"description": "Reusable elapsed-time display. Control with isStarted (start/pause); remount to restart from zero. Supports formats like hh:mm:ss or milliseconds. | 経過時間を表示するタイマー。isStarted で開始・一時停止、remount でゼロから再開。表示形式(hh:mm:ss やミリ秒など)を選択可能。",
"files": [
{
"path": "components/text/timer.tsx",
"content": "\"use client\";\n\nimport { useEffect, useState, useRef, startTransition } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Display format for the timer.\n *\n * @example \"hh:mm:ss\" → \"01:23:45\" (hours:minutes:seconds)\n * @example \"mm:ss\" → \"12:34\" (minutes:seconds)\n * @example \"mm:ss.xx\" → \"12:34.56\" (minutes:seconds.centiseconds, default)\n * @example \"ss.xxx\" → \"012.345\" (seconds.milliseconds)\n * @example \"ms\" → \"12345\" (raw milliseconds)\n *\n * Usage: → \"00:01:23\"\n * Usage: → \"083.250\"\n */\nexport type TimerFormat =\n | \"hh:mm:ss\"\n | \"mm:ss\"\n | \"mm:ss.xx\"\n | \"ss.xxx\"\n | \"ms\";\n\nconst DEFAULT_FORMAT: TimerFormat = \"mm:ss.xx\";\n\nfunction formatElapsed(ms: number, format: TimerFormat): string {\n const totalSeconds = ms / 1000;\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const seconds = totalSeconds % 60;\n\n switch (format) {\n case \"hh:mm:ss\": {\n const s = Math.floor(seconds);\n const h = hours.toString().padStart(2, \"0\");\n const m = minutes.toString().padStart(2, \"0\");\n const sec = s.toString().padStart(2, \"0\");\n return `${h}:${m}:${sec}`;\n }\n case \"mm:ss\": {\n const s = Math.floor(seconds);\n const m = minutes.toString().padStart(2, \"0\");\n const sec = s.toString().padStart(2, \"0\");\n return `${m}:${sec}`;\n }\n case \"mm:ss.xx\": {\n const m = minutes.toString().padStart(2, \"0\");\n const sec = (seconds % 60).toFixed(2);\n const padded = sec.padStart(5, \"0\");\n return `${m}:${padded}`;\n }\n case \"ss.xxx\": {\n const sec = (seconds % 60).toFixed(3);\n return sec.padStart(7, \"0\");\n }\n case \"ms\":\n return Math.floor(ms).toString();\n default:\n return formatElapsed(ms, DEFAULT_FORMAT);\n }\n}\n\n/** Interval (ms) to match format resolution: only setState when display can change. */\nfunction getIntervalForFormat(format: TimerFormat): number {\n switch (format) {\n case \"hh:mm:ss\":\n case \"mm:ss\":\n return 1000;\n case \"mm:ss.xx\":\n case \"ss.xxx\":\n case \"ms\":\n default:\n return 10;\n }\n}\n\nexport default function Timer({\n className,\n isStarted = true,\n format = DEFAULT_FORMAT,\n}: {\n className?: string;\n /** When true, timer runs; when false, paused and current value is preserved. */\n isStarted?: boolean;\n format?: TimerFormat;\n}) {\n const [display, setDisplay] = useState(() =>\n formatElapsed(0, format)\n );\n const intervalRef = useRef | null>(null);\n const startTimeRef = useRef(0);\n const accumulatedRef = useRef(0);\n const lastDisplayRef = useRef(formatElapsed(0, format));\n\n useEffect(() => {\n if (!isStarted) {\n if (intervalRef.current) {\n clearInterval(intervalRef.current);\n intervalRef.current = null;\n accumulatedRef.current += Date.now() - startTimeRef.current;\n }\n return;\n }\n\n startTimeRef.current = Date.now();\n const intervalMs = getIntervalForFormat(format);\n\n intervalRef.current = setInterval(() => {\n const elapsed = accumulatedRef.current + (Date.now() - startTimeRef.current);\n const next = formatElapsed(elapsed, format);\n if (next !== lastDisplayRef.current) {\n lastDisplayRef.current = next;\n startTransition(() => setDisplay(next));\n }\n }, intervalMs);\n\n return () => {\n if (intervalRef.current) {\n clearInterval(intervalRef.current);\n intervalRef.current = null;\n accumulatedRef.current += Date.now() - startTimeRef.current;\n }\n };\n }, [isStarted, format]);\n\n return (\n \n {display}\n
\n );\n}\n",
"type": "registry:ui"
}
],
"type": "registry:ui"
}