{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chronological-timeline",
  "type": "registry:component",
  "title": "Chronological Timeline",
  "description": "Media-rich, scroll-driven chronological timeline with alternating cards, image/video/audio media, and a progress strip.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/chronological-timeline/chronological-timeline.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\n/**\n * Media payload for a {@link ChronoEvent}.\n *\n * @public\n */\nexport type ChronoMedia =\n  | {\n      alt: string;\n      caption?: ReactNode;\n      credit?: ReactNode;\n      src: string;\n      type: \"image\";\n    }\n  | {\n      alt?: string;\n      caption?: ReactNode;\n      credit?: ReactNode;\n      src: string;\n      type: \"audio\";\n    }\n  | {\n      caption?: ReactNode;\n      credit?: ReactNode;\n      src: string;\n      title?: string;\n      type: \"video\";\n    };\n\ntype ChronoCtx = {\n  registerEvent: (id: string, node: HTMLElement | null) => void;\n  setActiveId: (id: string) => void;\n  titleId: string;\n};\n\nconst ChronoContext = createContext<ChronoCtx | null>(null);\n\nfunction useChronoContext(): ChronoCtx {\n  const ctx = useContext(ChronoContext);\n  if (!ctx) {\n    throw new Error(\"ChronoEvent used outside ChronologicalTimeline.\");\n  }\n  return ctx;\n}\n\n/**\n * Props for {@link ChronologicalTimeline}.\n *\n * @public\n */\nexport type ChronologicalTimelineProps = {\n  /** Aria-label for the timeline progress strip. Defaults to `\"Timeline progress\"`. */\n  progressLabel?: string;\n  /** Headline rendered at the top of the timeline. */\n  title?: ReactNode;\n} & ComponentPropsWithoutRef<\"section\">;\n\n/**\n * Props for {@link ChronoEvent}.\n *\n * @public\n */\nexport type ChronoEventProps = {\n  /** Display date (free-form string — pass `\"1957\"`, `\"October 4, 1957\"`, etc.). */\n  date: ReactNode;\n  /** When `true`, the card renders larger to emphasise pivotal events. */\n  featured?: boolean;\n  /** Stable id. Defaults to a generated id; pass one for deep links. */\n  id?: string;\n  /** Optional media payload — image / video / audio. */\n  media?: ChronoMedia;\n  /** Optional subtitle. */\n  subtitle?: ReactNode;\n  /** Card title. */\n  title: ReactNode;\n} & Omit<ComponentPropsWithoutRef<\"article\">, \"id\" | \"title\">;\n\ntype ImageMediaProps = {\n  media: Extract<ChronoMedia, { type: \"image\" }>;\n};\n\nfunction ImageMedia({ media }: ImageMediaProps): ReactNode {\n  return (\n    <figure className=\"overflow-hidden rounded-xl border bg-muted\">\n      <img\n        alt={media.alt}\n        className=\"aspect-video w-full object-cover\"\n        loading=\"lazy\"\n        src={media.src}\n      />\n      {media.caption || media.credit ? (\n        <figcaption className=\"border-t bg-background px-3 py-2 text-xs text-muted-foreground\">\n          {media.caption ? (\n            <span className=\"block\">{media.caption}</span>\n          ) : null}\n          {media.credit ? (\n            <span className=\"block italic\">{media.credit}</span>\n          ) : null}\n        </figcaption>\n      ) : null}\n    </figure>\n  );\n}\n\ntype VideoMediaProps = {\n  media: Extract<ChronoMedia, { type: \"video\" }>;\n};\n\nfunction VideoMedia({ media }: VideoMediaProps): ReactNode {\n  const iframeTitle = media.title || \"Embedded timeline video\";\n  return (\n    <figure className=\"overflow-hidden rounded-xl border bg-muted\">\n      <div className=\"aspect-video w-full\">\n        <iframe\n          allow=\"accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture\"\n          allowFullScreen\n          className=\"h-full w-full\"\n          src={media.src}\n          title={iframeTitle}\n        />\n      </div>\n      {media.caption || media.credit ? (\n        <figcaption className=\"border-t bg-background px-3 py-2 text-xs text-muted-foreground\">\n          {media.caption ? (\n            <span className=\"block\">{media.caption}</span>\n          ) : null}\n          {media.credit ? (\n            <span className=\"block italic\">{media.credit}</span>\n          ) : null}\n        </figcaption>\n      ) : null}\n    </figure>\n  );\n}\n\ntype AudioMediaProps = {\n  media: Extract<ChronoMedia, { type: \"audio\" }>;\n};\n\nfunction AudioMedia({ media }: AudioMediaProps): ReactNode {\n  return (\n    <figure className=\"overflow-hidden rounded-xl border bg-muted p-3\">\n      <audio\n        aria-label={media.alt}\n        className=\"w-full\"\n        controls\n        preload=\"metadata\"\n        src={media.src}\n      >\n        <track kind=\"captions\" />\n      </audio>\n      {media.caption || media.credit ? (\n        <figcaption className=\"pt-2 text-xs text-muted-foreground\">\n          {media.caption ? (\n            <span className=\"block\">{media.caption}</span>\n          ) : null}\n          {media.credit ? (\n            <span className=\"block italic\">{media.credit}</span>\n          ) : null}\n        </figcaption>\n      ) : null}\n    </figure>\n  );\n}\n\ntype MediaProps = {\n  media: ChronoMedia;\n};\n\nfunction Media({ media }: MediaProps): ReactNode {\n  if (media.type === \"image\") return <ImageMedia media={media} />;\n  if (media.type === \"video\") return <VideoMedia media={media} />;\n  return <AudioMedia media={media} />;\n}\n\ntype DateColumnProps = {\n  date: ReactNode;\n};\n\nfunction DateColumn({ date }: DateColumnProps): ReactNode {\n  return (\n    <div className=\"hidden items-start justify-end pr-4 text-right text-xs font-semibold uppercase tracking-wide text-muted-foreground md:flex md:group-[[data-side='right']]:order-3 md:group-[[data-side='right']]:justify-start md:group-[[data-side='right']]:pl-4 md:group-[[data-side='right']]:text-left\">\n      <time className=\"pt-2\">{date}</time>\n    </div>\n  );\n}\n\ntype RailColumnProps = {\n  featured: boolean;\n};\n\nfunction RailColumn({ featured }: RailColumnProps): ReactNode {\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"relative hidden md:flex md:w-6 md:items-start md:justify-center\"\n    >\n      <span className=\"absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border\" />\n      <span\n        className={cn(\n          \"relative z-10 mt-3 block size-3 rounded-full border-2 border-background bg-primary\",\n          featured ? \"size-4\" : \"\",\n        )}\n      />\n    </div>\n  );\n}\n\ntype EventCardProps = {\n  children?: ReactNode;\n  date: ReactNode;\n  eventId: string;\n  featured: boolean;\n  media?: ChronoMedia;\n  subtitle?: ReactNode;\n  title: ReactNode;\n};\n\nfunction EventCard({\n  children,\n  date,\n  eventId,\n  featured,\n  media,\n  subtitle,\n  title,\n}: EventCardProps): ReactNode {\n  return (\n    <div\n      className={cn(\n        \"rounded-2xl border bg-background p-5 shadow-sm md:group-[[data-side='right']]:order-1\",\n        featured ? \"ring-1 ring-primary/30\" : \"\",\n      )}\n    >\n      <header className=\"mb-3 flex flex-col gap-1\">\n        <time className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground md:hidden\">\n          {date}\n        </time>\n        <h3\n          className={cn(\n            \"font-semibold text-foreground\",\n            featured ? \"text-xl\" : \"text-lg\",\n          )}\n          id={`${eventId}-title`}\n        >\n          {title}\n        </h3>\n        {subtitle ? (\n          <p className=\"text-sm text-muted-foreground\">{subtitle}</p>\n        ) : null}\n      </header>\n      {media ? (\n        <div className=\"mb-3\">\n          <Media media={media} />\n        </div>\n      ) : null}\n      {children ? (\n        <div className=\"space-y-2 text-sm leading-relaxed text-foreground [&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-muted-foreground\">\n          {children}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\n/**\n * A single event card inside {@link ChronologicalTimeline}. Place\n * narrative paragraphs, blockquotes, and lists as children.\n *\n * @public\n */\nexport const ChronoEvent = forwardRef<HTMLElement, ChronoEventProps>(\n  (props, forwardedRef) => {\n    const {\n      children,\n      className,\n      date,\n      featured = false,\n      id,\n      media,\n      subtitle,\n      title,\n      ...rest\n    } = props;\n    const generatedId = useId();\n    const eventId = id ?? generatedId;\n    const ref = useRef<HTMLElement | null>(null);\n    const { registerEvent, setActiveId } = useChronoContext();\n\n    const refCallback = useCallback(\n      (node: HTMLElement | null) => {\n        ref.current = node;\n        registerEvent(eventId, node);\n        if (typeof forwardedRef === \"function\") forwardedRef(node);\n        else if (forwardedRef) forwardedRef.current = node;\n      },\n      [eventId, forwardedRef, registerEvent],\n    );\n\n    const handleFocus = useCallback(() => {\n      setActiveId(eventId);\n    }, [eventId, setActiveId]);\n\n    return (\n      <article\n        aria-labelledby={`${eventId}-title`}\n        className={cn(\n          \"group relative grid gap-4 py-6 md:grid-cols-[1fr_auto_1fr] md:gap-8\",\n          className,\n        )}\n        data-event-id={eventId}\n        data-featured={featured ? \"true\" : undefined}\n        id={eventId}\n        onFocus={handleFocus}\n        ref={refCallback}\n        {...rest}\n      >\n        <DateColumn date={date} />\n        <RailColumn featured={featured} />\n        <EventCard\n          date={date}\n          eventId={eventId}\n          featured={featured}\n          media={media}\n          subtitle={subtitle}\n          title={title}\n        >\n          {children}\n        </EventCard>\n      </article>\n    );\n  },\n);\nChronoEvent.displayName = \"ChronoEvent\";\n\ntype ProgressStripProps = {\n  activeId?: string;\n  ids: string[];\n  label: string;\n};\n\nfunction ProgressStrip({\n  activeId,\n  ids,\n  label,\n}: ProgressStripProps): ReactNode {\n  if (ids.length === 0) return null;\n  const activeIndex = activeId ? ids.indexOf(activeId) : -1;\n  const ratio = activeIndex < 0 ? 0 : (activeIndex + 1) / ids.length;\n  return (\n    <div\n      aria-label={label}\n      aria-valuemax={100}\n      aria-valuemin={0}\n      aria-valuenow={Math.round(ratio * 100)}\n      className=\"sticky top-0 z-10 h-1 w-full bg-border\"\n      role=\"progressbar\"\n    >\n      <span\n        className=\"block h-full bg-primary transition-[width] duration-200\"\n        style={{ width: `${(ratio * 100).toString()}%` }}\n      />\n    </div>\n  );\n}\n\nfunction useChronoActiveTracker(): {\n  activeId?: string;\n  ids: string[];\n  registerEvent: (id: string, node: HTMLElement | null) => void;\n  setActiveId: (id: string) => void;\n} {\n  const eventsRef = useRef<Map<string, HTMLElement>>(new Map());\n  const [ids, setIds] = useState<string[]>([]);\n  const [activeId, setActiveId] = useState<string | undefined>();\n\n  const registerEvent = useCallback((id: string, node: HTMLElement | null) => {\n    const map = eventsRef.current;\n    if (node) map.set(id, node);\n    else map.delete(id);\n    setIds([...map.keys()]);\n  }, []);\n\n  useEffect(() => {\n    if (typeof IntersectionObserver === \"undefined\") return;\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const visible = entries\n          .filter((entry) => entry.isIntersecting)\n          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);\n        const first = visible[0];\n        if (first) {\n          const target = first.target;\n          if (target instanceof HTMLElement) {\n            const eventId = target.dataset.eventId;\n            if (eventId) setActiveId(eventId);\n          }\n        }\n      },\n      { rootMargin: \"-30% 0px -50% 0px\", threshold: 0.1 },\n    );\n    [...eventsRef.current.values()].forEach((node) => {\n      observer.observe(node);\n    });\n    return () => {\n      observer.disconnect();\n    };\n  }, [ids]);\n\n  return { activeId, ids, registerEvent, setActiveId };\n}\n\ntype EventListProps = {\n  activeId?: string;\n  children: ReactNode;\n};\n\nfunction EventList({ activeId, children }: EventListProps): ReactNode {\n  if (!Array.isArray(children)) {\n    return (\n      <ol className=\"relative flex flex-col px-4 pb-6 md:px-6\">{children}</ol>\n    );\n  }\n  return (\n    <ol className=\"relative flex flex-col px-4 pb-6 md:px-6\">\n      {children.map((child, index) => (\n        <li\n          className=\"block list-none\"\n          data-active={\n            isReactElementWithEventId(child, activeId) ? \"true\" : undefined\n          }\n          data-side={index % 2 === 0 ? \"left\" : \"right\"}\n          key={getChildKey(child, index)}\n        >\n          {child}\n        </li>\n      ))}\n    </ol>\n  );\n}\n\n/**\n * Media-rich, scroll-driven chronological timeline. Cards alternate\n * sides on desktop and stack on mobile. Each {@link ChronoEvent} can\n * include an image, video, or audio payload plus narrative children\n * (paragraphs, blockquotes, lists). An `IntersectionObserver` follows\n * the reader and drives the progress strip + the active card flag.\n *\n * @example\n * ```tsx\n * <ChronologicalTimeline title=\"The Space Race\">\n *   <ChronoEvent date=\"October 4, 1957\" title=\"Sputnik 1\" subtitle=\"First artificial satellite\">\n *     <p>The Soviet Union launched Sputnik 1...</p>\n *   </ChronoEvent>\n *   <ChronoEvent date=\"July 20, 1969\" title=\"Apollo 11\" featured>\n *     <p>Neil Armstrong and Buzz Aldrin walked on the Moon...</p>\n *   </ChronoEvent>\n * </ChronologicalTimeline>\n * ```\n *\n * @public\n */\nexport const ChronologicalTimeline = forwardRef<\n  HTMLElement,\n  ChronologicalTimelineProps\n>((props, ref) => {\n  const {\n    children,\n    className,\n    progressLabel = \"Timeline progress\",\n    title,\n    ...rest\n  } = props;\n  const titleId = useId();\n  const { activeId, ids, registerEvent, setActiveId } =\n    useChronoActiveTracker();\n\n  const ctx = useMemo<ChronoCtx>(\n    () => ({ registerEvent, setActiveId, titleId }),\n    [registerEvent, setActiveId, titleId],\n  );\n\n  return (\n    <ChronoContext.Provider value={ctx}>\n      <section\n        aria-labelledby={title ? titleId : undefined}\n        className={cn(\n          \"relative mx-auto flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl border bg-background text-foreground\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        <ProgressStrip activeId={activeId} ids={ids} label={progressLabel} />\n        {title ? (\n          <header className=\"p-6\">\n            <h2 className=\"text-2xl font-semibold tracking-tight\" id={titleId}>\n              {title}\n            </h2>\n          </header>\n        ) : null}\n        <EventList activeId={activeId}>{children}</EventList>\n      </section>\n    </ChronoContext.Provider>\n  );\n});\nChronologicalTimeline.displayName = \"ChronologicalTimeline\";\n\nfunction isReactElementWithEventId(\n  child: unknown,\n  activeId: string | undefined,\n): boolean {\n  if (!activeId) return false;\n  if (typeof child !== \"object\" || child === null) return false;\n  if (!(\"props\" in child)) return false;\n  const props = (child as { props: unknown }).props;\n  if (typeof props !== \"object\" || props === null) return false;\n  const id = (props as { id?: unknown }).id;\n  return typeof id === \"string\" && id === activeId;\n}\n\nfunction getChildKey(child: unknown, fallback: number): number | string {\n  if (typeof child !== \"object\" || child === null) return fallback;\n  if (!(\"key\" in child)) return fallback;\n  const key = (child as { key: unknown }).key;\n  if (typeof key === \"string\" || typeof key === \"number\") return key;\n  return fallback;\n}\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
