{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "time-select-input", "title": "Time Select Input", "description": "入力可能な時間選択セレクト(00:00形式、15分刻み)", "dependencies": [ "lucide-react" ], "registryDependencies": [ "input", "scroll-area", "popover" ], "files": [ { "path": "registry/br-hr/blocks/time-select-input.tsx", "content": "\"use client\";\n\nimport { Timer, X } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPopover,\n\tPopoverAnchor,\n\tPopoverContent,\n} from \"@/registry/br-hr/ui/popover\";\nimport { ScrollArea } from \"@/registry/br-hr/ui/scroll-area\";\nimport { Input } from \"@/registry/new-york/ui/input\";\n\nconst PLACEHOLDER = \"--:--\";\nconst MAX_LENGTH = 5;\n\n// 00:15 から 23:45 まで 15分刻み(00:00 は含めない)\nfunction getTimeOptions(): string[] {\n\tconst opts: string[] = [];\n\tfor (let h = 0; h < 24; h++) {\n\t\tfor (const m of [0, 15, 30, 45]) {\n\t\t\tif (h === 0 && m === 0) continue;\n\t\t\topts.push(`${String(h).padStart(2, \"0\")}:${String(m).padStart(2, \"0\")}`);\n\t\t\tif (h === 23 && m === 45) return opts;\n\t\t}\n\t}\n\treturn opts;\n}\n\nconst SELECT_OPTIONS = getTimeOptions();\n\nfunction normalizeTime(s: string): string {\n\tconst trimmed = s.trim();\n\tif (trimmed.length === 0) return \"00:00\";\n\tif (/[^0-9:]/.test(trimmed)) return \"00:00\";\n\n\t// すでに HH:mm 形式ならそのまま検証する\n\tif (/^\\d{2}:\\d{2}$/.test(trimmed)) {\n\t\tconst [hhStr, mmStr] = trimmed.split(\":\");\n\t\tconst hh = Number(hhStr);\n\t\tconst mm = Number(mmStr);\n\t\tif (Number.isNaN(hh) || Number.isNaN(mm)) return \"00:00\";\n\t\tif (hh < 0 || hh > 23 || mm < 0 || mm > 59) return \"00:00\";\n\t\treturn trimmed;\n\t}\n\n\tconst digits = trimmed.replace(/:/g, \"\");\n\n\t// 先頭4桁を使って HH:mm に組み立てる\n\tlet four: string;\n\tif (digits.length >= 4) {\n\t\tfour = digits.slice(0, 4);\n\t} else {\n\t\tfour = digits.padStart(4, \"0\");\n\t}\n\n\tconst hh = Number(four.slice(0, 2));\n\tconst mm = Number(four.slice(2, 4));\n\n\t// 存在しない時間は 00:00 にする\n\tif (Number.isNaN(hh) || Number.isNaN(mm)) return \"00:00\";\n\tif (hh < 0 || hh > 23 || mm < 0 || mm > 59) return \"00:00\";\n\n\treturn `${four.slice(0, 2)}:${four.slice(2, 4)}`;\n}\n\ntype TimeSelectInputProps = {\n\tvalue?: string;\n\tdefaultValue?: string;\n\tonChange?: (value: string) => void;\n\tclassName?: string;\n\tdisabled?: boolean;\n};\n\nconst TimeSelectInput = ({\n\tvalue: controlledValue,\n\tdefaultValue = \"\",\n\tonChange,\n\tclassName,\n\tdisabled = false,\n}: TimeSelectInputProps) => {\n\tconst [open, setOpen] = useState(false);\n\tconst [internalValue, setInternalValue] = useState(defaultValue);\n\tconst blurTimeoutRef = useRef | null>(null);\n\tconst wrapperRef = useRef(null);\n\tconst didSelectFromDropdownRef = useRef(false);\n\n\tconst isControlled = controlledValue !== undefined;\n\tconst value = (isControlled ? controlledValue : internalValue) ?? \"\";\n\n\tconst setValue = useCallback(\n\t\t(v: string) => {\n\t\t\tif (!isControlled) setInternalValue(v);\n\t\t\tonChange?.(v);\n\t\t},\n\t\t[isControlled, onChange],\n\t);\n\n\tconst handleInputChange = (e: React.ChangeEvent) => {\n\t\tconst raw = e.target.value;\n\t\tconst next = raw.length > MAX_LENGTH ? raw.slice(0, MAX_LENGTH) : raw;\n\t\tsetValue(next);\n\t};\n\n\tconst handleBlur = () => {\n\t\tblurTimeoutRef.current = setTimeout(() => {\n\t\t\tsetOpen(false);\n\t\t\t// セレクトから選択した直後は正規化しない(選択した値が上書きされるのを防ぐ)\n\t\t\tif (didSelectFromDropdownRef.current) {\n\t\t\t\tdidSelectFromDropdownRef.current = false;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// フォーカス後に入力がなければ何もしない\n\t\t\tif (!value.trim()) return;\n\t\t\tconst normalized = normalizeTime(value);\n\t\t\tsetValue(normalized);\n\t\t}, 150);\n\t};\n\n\tconst handleFocus = () => {\n\t\tif (blurTimeoutRef.current) {\n\t\t\tclearTimeout(blurTimeoutRef.current);\n\t\t\tblurTimeoutRef.current = null;\n\t\t}\n\t\tsetOpen(true);\n\t};\n\n\tconst handleSelectOption = (option: string) => {\n\t\tdidSelectFromDropdownRef.current = true;\n\t\tsetValue(option);\n\t\tsetOpen(false);\n\t\twrapperRef.current?.querySelector(\"input\")?.blur();\n\t};\n\n\tconst handleClear = (e: React.MouseEvent) => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\tsetValue(\"\");\n\t};\n\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tif (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\t\t};\n\t}, []);\n\n\tconst displayValue = value;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{value.trim() ? (\n\t\t\t\t\t\t e.preventDefault()}\n\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t\tclassName=\"absolute right-1.5 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n\t\t\t\t\t\t\taria-label=\"クリア\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : null}\n\t\t\t\t
\n\t\t\t
\n\t\t\t e.preventDefault()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t{SELECT_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t handleSelectOption(option)}\n\t\t\t\t\t\t\t\tonMouseDown={(e) => e.preventDefault()}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{option}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n};\n\nexport default TimeSelectInput;\n", "type": "registry:component" } ], "type": "registry:component" }