{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "timeline", "title": "Timeline", "description": "Vertical timeline with sticky year and optional scroll-driven axis fill. | 左にスティッキーな年表示、オプションでスクロール連動の軸線。", "dependencies": [ "motion" ], "registryDependencies": [ "card" ], "files": [ { "path": "components/layout/timeline.tsx", "content": "\"use client\";\n// TODO: 靜態timeline 與動態 timeline 分離\nimport * as React from \"react\";\nimport {\n createContext,\n memo,\n useMemo,\n useEffect,\n useRef,\n useState,\n useContext,\n cloneElement,\n isValidElement,\n type ReactNode,\n} from \"react\";\nimport { motion, useScroll, useTransform, type MotionValue } from \"motion/react\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\nimport { cn } from \"@/lib/utils\";\n\n/** Type for useScroll offset (framer-motion expects specific literal unions, not [string, string]). */\ntype ScrollOffsetForUseScroll = NonNullable<\n NonNullable[0]>[\"offset\"]\n>;\n\nexport interface TimelineScrollAxisOptions {\n /** Enable scroll-driven axis fill effect. Default false. */\n enabled?: boolean;\n /** useScroll offset: [start, end]. Default [\"start 10%\", \"end 50%\"]. */\n offset?: [string, string];\n}\n\nexport interface TimelineItemData {\n date: Date | string;\n title: string;\n description?: string | React.ReactNode;\n id?: string;\n}\n\n/** @deprecated Use TimelineItemData. Kept for backward compatibility. */\nexport type TimelineItem = TimelineItemData;\n\ninterface TimelineContextValue {\n scrollAxisConfig: { enabled: boolean; offset: [string, string] };\n trackHeight: number;\n heightTransform: MotionValue | null;\n opacityTransform: MotionValue | null;\n}\n\nconst TimelineContext = createContext(null);\n\nfunction useTimelineContext(component: string) {\n const ctx = useContext(TimelineContext);\n if (!ctx) {\n throw new Error(\n `${component} must be used within Timeline.Root or Timeline.Provider`\n );\n }\n return ctx;\n}\n\nfunction formatDate(date: Date | string): { year: string; monthDay: string } {\n const dateObj = typeof date === \"string\" ? new Date(date) : date;\n if (isNaN(dateObj.getTime())) {\n return { year: \"Invalid\", monthDay: \"Date\" };\n }\n const year = String(dateObj.getFullYear());\n const month = String(dateObj.getMonth() + 1);\n const day = String(dateObj.getDate());\n return { year, monthDay: `${month}月${day}日` };\n}\n\nfunction normalizeScrollAxis(\n scrollAxis?: boolean | TimelineScrollAxisOptions\n): { enabled: boolean; offset: [string, string] } {\n if (scrollAxis === undefined || scrollAxis === false) {\n return { enabled: false, offset: [\"start 10%\", \"end 50%\"] };\n }\n if (scrollAxis === true) {\n return { enabled: true, offset: [\"start 10%\", \"end 50%\"] };\n }\n return {\n enabled: scrollAxis.enabled ?? true,\n offset: scrollAxis.offset ?? [\"start 10%\", \"end 50%\"],\n };\n}\n\n// ---------------------------------------------------------------------------\n// Timeline.Provider — provides context and minimal wrapper (refs attached for scroll/line).\n// Use when you want full control over layout but still need scroll axis + Timeline.Line.\n// ---------------------------------------------------------------------------\ninterface TimelineProviderProps {\n children: ReactNode;\n className?: string;\n scrollAxis?: boolean | TimelineScrollAxisOptions;\n}\n\nfunction TimelineProvider({ children, className, scrollAxis: scrollAxisProp }: TimelineProviderProps) {\n const trackRef = useRef(null);\n const containerRef = useRef(null);\n const [trackHeight, setTrackHeight] = useState(0);\n\n const scrollAxisConfig = useMemo(\n () => normalizeScrollAxis(scrollAxisProp),\n [scrollAxisProp]\n );\n\n useEffect(() => {\n if (!scrollAxisConfig.enabled || !trackRef.current) return;\n const el = trackRef.current;\n const observer = new ResizeObserver(() => {\n setTrackHeight(el.getBoundingClientRect().height);\n });\n observer.observe(el);\n setTrackHeight(el.getBoundingClientRect().height);\n return () => observer.disconnect();\n }, [scrollAxisConfig.enabled]);\n\n const { scrollYProgress } = useScroll({\n target: containerRef,\n offset: scrollAxisConfig.offset as ScrollOffsetForUseScroll,\n });\n\n const heightTransform = useTransform(\n scrollYProgress,\n [0, 1],\n [0, trackHeight]\n );\n const opacityTransform = useTransform(scrollYProgress, [0, 0.1], [0, 1]);\n\n const value = useMemo(\n () => ({\n scrollAxisConfig,\n trackHeight,\n heightTransform: scrollAxisConfig.enabled ? heightTransform : null,\n opacityTransform: scrollAxisConfig.enabled ? opacityTransform : null,\n }),\n [scrollAxisConfig, trackHeight, heightTransform, opacityTransform]\n );\n\n const childArray = React.Children.toArray(children);\n const itemCount = childArray.filter(isTimelineItemChild).length;\n let itemIndex = 0;\n const renderedChildren = childArray.map((child) => {\n if (isTimelineItemChild(child)) {\n const index = itemIndex++;\n return cloneElement(child, {\n index,\n isLast: index === itemCount - 1,\n } as Partial);\n }\n return child;\n });\n\n return (\n \n
\n
\n {renderedChildren}\n
\n
\n
\n );\n}\n\n// ---------------------------------------------------------------------------\n// Timeline.Root — container + provider. Renders track div, injects index/isLast into Items, optional Line.\n// ---------------------------------------------------------------------------\ninterface TimelineRootProps {\n children: ReactNode;\n className?: string;\n scrollAxis?: boolean | TimelineScrollAxisOptions;\n}\n\nfunction isTimelineItemChild(child: React.ReactNode): child is React.ReactElement & { type: typeof TimelineItem } {\n return isValidElement(child) && (child.type as unknown) === TimelineItem;\n}\n\nfunction TimelineRoot({ children, className, scrollAxis: scrollAxisProp }: TimelineRootProps) {\n const trackRef = useRef(null);\n const containerRef = useRef(null);\n const [trackHeight, setTrackHeight] = useState(0);\n\n const scrollAxisConfig = useMemo(\n () => normalizeScrollAxis(scrollAxisProp),\n [scrollAxisProp]\n );\n\n useEffect(() => {\n if (!scrollAxisConfig.enabled || !trackRef.current) return;\n const el = trackRef.current;\n const observer = new ResizeObserver(() => {\n setTrackHeight(el.getBoundingClientRect().height);\n });\n observer.observe(el);\n setTrackHeight(el.getBoundingClientRect().height);\n return () => observer.disconnect();\n }, [scrollAxisConfig.enabled]);\n\n const { scrollYProgress } = useScroll({\n target: containerRef,\n offset: scrollAxisConfig.offset as ScrollOffsetForUseScroll,\n });\n\n const heightTransform = useTransform(\n scrollYProgress,\n [0, 1],\n [0, trackHeight]\n );\n const opacityTransform = useTransform(scrollYProgress, [0, 0.1], [0, 1]);\n\n const contextValue = useMemo(\n () => ({\n scrollAxisConfig,\n trackHeight,\n heightTransform: scrollAxisConfig.enabled ? heightTransform : null,\n opacityTransform: scrollAxisConfig.enabled ? opacityTransform : null,\n }),\n [scrollAxisConfig, trackHeight, heightTransform, opacityTransform]\n );\n\n const childArray = React.Children.toArray(children);\n const itemCount = childArray.filter(isTimelineItemChild).length;\n let itemIndex = 0;\n\n const renderedChildren = childArray.map((child) => {\n if (isTimelineItemChild(child)) {\n const index = itemIndex++;\n const isLast = index === itemCount - 1;\n return cloneElement(child, { index, isLast } as Partial);\n }\n return child;\n });\n\n return (\n \n
\n
\n {renderedChildren}\n {scrollAxisConfig.enabled && trackHeight > 0 && (\n \n )}\n
\n
\n
\n );\n}\n\n// ---------------------------------------------------------------------------\n// Timeline.Line — scroll-driven axis. Used internally by Root or composed explicitly.\n// ---------------------------------------------------------------------------\ninterface TimelineLineProps {\n trackHeight?: number;\n heightTransform?: MotionValue;\n opacityTransform?: MotionValue;\n}\n\nfunction TimelineLineInternal({\n trackHeight: heightProp,\n heightTransform: heightPropTransform,\n opacityTransform: opacityPropTransform,\n}: TimelineLineProps) {\n const ctx = useContext(TimelineContext);\n const trackHeight = heightProp ?? ctx?.trackHeight ?? 0;\n const heightTransform = heightPropTransform ?? ctx?.heightTransform;\n const opacityTransform = opacityPropTransform ?? ctx?.opacityTransform;\n const enabled = ctx?.scrollAxisConfig.enabled ?? (heightProp != null && heightProp > 0);\n\n if (!enabled || trackHeight <= 0 || !heightTransform || !opacityTransform) {\n return null;\n }\n\n return (\n \n {/* Track: same hue as connector (primary) at low opacity so no double-line; fill grows on top */}\n
\n \n
\n );\n}\n\nfunction TimelineLine(props: TimelineLineProps) {\n return ;\n}\n\n// ---------------------------------------------------------------------------\n// Timeline.Item — single entry: sticky date, dot/connector, content (Card or children).\n// ---------------------------------------------------------------------------\nconst timelineItemVariants = {\n default: {\n root: \"relative flex gap-4 md:gap-6\",\n date: \"flex flex-col text-foreground\",\n dateYear: \"text-4xl md:text-5xl font-bold leading-tight\",\n dateMonthDay:\n \"text-md md:text-lg font-thin tracking-widest leading-tight mt-1 text-muted-foreground\",\n dotColumn:\n \"relative flex flex-col items-center flex-shrink-0 w-4 self-stretch\",\n line: \"absolute left-1/2 -translate-x-1/2 w-1 bg-primary\",\n dot: \"relative z-10 size-4 rounded-full bg-primary-foreground border-2 border-primary\",\n content: \"flex-1 pb-4 md:pb-8\",\n card: \"hover:shadow-md transition-shadow duration-200\",\n },\n} as const;\n\nexport type TimelineItemVariant = keyof typeof timelineItemVariants;\n\nexport interface TimelineItemClassNames {\n root?: string;\n date?: string;\n dateYear?: string;\n dateMonthDay?: string;\n dotColumn?: string;\n line?: string;\n dot?: string;\n content?: string;\n card?: string;\n}\n\ninterface TimelineItemProps {\n date: Date | string;\n title?: string;\n description?: string | React.ReactNode;\n children?: ReactNode;\n /** Variant for default styles. Merged with classNames via cn(). */\n variant?: TimelineItemVariant;\n /** Override or extend styles per slot. Merged after variant. */\n classNames?: TimelineItemClassNames;\n /** @deprecated Use classNames.root. Kept for backward compatibility. */\n className?: string;\n /** Injected by Timeline.Root via cloneElement. */\n index?: number;\n /** Injected by Timeline.Root via cloneElement. */\n isLast?: boolean;\n}\n\nconst TimelineItemComponent = memo(function TimelineItemComponent({\n date,\n title,\n description,\n children,\n variant = \"default\",\n classNames,\n className,\n isLast = true,\n}: TimelineItemProps) {\n const formattedDate = useMemo(() => formatDate(date), [date]);\n const v = timelineItemVariants[variant];\n\n const content =\n children !== undefined ? (\n children\n ) : title !== undefined ? (\n \n \n {title}\n \n {description != null && (\n \n \n {description}\n \n \n )}\n \n ) : null;\n\n return (\n // Left Side Date Section\n \n
\n
\n \n {/* Year */}\n \n {formattedDate.year}\n \n {/* Month and Day */}\n \n {formattedDate.monthDay}\n \n \n
\n
\n\n
\n
\n
\n {!isLast && (\n
\n )}\n
\n\n \n {content}\n \n \n );\n});\n\nTimelineItemComponent.displayName = \"TimelineItemComponent\";\n\nfunction TimelineItem(props: TimelineItemProps) {\n return ;\n}\n\n// ---------------------------------------------------------------------------\n// Compound export\n// ---------------------------------------------------------------------------\nexport const Timeline = {\n Root: TimelineRoot,\n Provider: TimelineProvider,\n Item: TimelineItem,\n Line: TimelineLine,\n};\n", "type": "registry:ui" } ], "type": "registry:ui" }