{ "name": "timeline", "type": "registry:ui", "files": [ { "path": "ui/timeline.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva } from \"class-variance-authority\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useIsomorphicLayoutEffect } from \"@/registry/bases/base/hooks/use-isomorphic-layout-effect\";\nimport { useLazyRef } from \"@/registry/bases/base/hooks/use-lazy-ref\";\nimport { useComposedRefs } from \"@/registry/bases/base/lib/compose-refs\";\nimport { useDirection } from \"@/registry/bases/base/ui/direction\";\n\ntype Direction = \"ltr\" | \"rtl\";\ntype Orientation = \"vertical\" | \"horizontal\";\ntype Variant = \"default\" | \"alternate\";\ntype Status = \"completed\" | \"active\" | \"pending\";\n\ntype ItemElement = HTMLDivElement;\n\nconst ROOT_NAME = \"Timeline\";\nconst ITEM_NAME = \"TimelineItem\";\nconst DOT_NAME = \"TimelineDot\";\nconst CONNECTOR_NAME = \"TimelineConnector\";\nconst CONTENT_NAME = \"TimelineContent\";\n\nfunction getItemStatus(itemIndex: number, activeIndex?: number): Status {\n if (activeIndex === undefined) return \"pending\";\n if (itemIndex < activeIndex) return \"completed\";\n if (itemIndex === activeIndex) return \"active\";\n return \"pending\";\n}\n\nfunction getSortedEntries(\n entries: [string, React.RefObject][],\n) {\n return entries.sort((a, b) => {\n const elementA = a[1].current;\n const elementB = b[1].current;\n if (!elementA || !elementB) return 0;\n const position = elementA.compareDocumentPosition(elementB);\n if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;\n if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;\n return 0;\n });\n}\n\nfunction useStore(selector: (store: Store) => T): T {\n const store = React.useContext(StoreContext);\n if (!store) {\n throw new Error(`\\`useStore\\` must be used within \\`${ROOT_NAME}\\``);\n }\n\n const getSnapshot = React.useCallback(\n () => selector(store),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface StoreState {\n items: Map>;\n}\n\ninterface Store {\n subscribe: (callback: () => void) => () => void;\n getState: () => StoreState;\n notify: () => void;\n onItemRegister: (\n id: string,\n ref: React.RefObject,\n ) => void;\n onItemUnregister: (id: string) => void;\n getNextItemStatus: (id: string, activeIndex?: number) => Status | undefined;\n getItemIndex: (id: string) => number;\n}\n\nconst StoreContext = React.createContext(null);\n\nfunction useStoreContext(consumerName: string) {\n const context = React.useContext(StoreContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`${ROOT_NAME}\\``);\n }\n return context;\n}\n\ninterface TimelineContextValue {\n dir: Direction;\n orientation: Orientation;\n variant: Variant;\n activeIndex?: number;\n}\n\nconst TimelineContext = React.createContext(null);\n\nfunction useTimelineContext(consumerName: string) {\n const context = React.useContext(TimelineContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`${ROOT_NAME}\\``);\n }\n return context;\n}\n\nconst timelineVariants = cva(\n \"relative flex [--timeline-connector-thickness:0.125rem] [--timeline-dot-size:0.875rem]\",\n {\n variants: {\n orientation: {\n vertical: \"flex-col\",\n horizontal: \"flex-row items-start\",\n },\n variant: {\n default: \"\",\n alternate: \"\",\n },\n },\n compoundVariants: [\n {\n orientation: \"vertical\",\n variant: \"default\",\n class: \"gap-6\",\n },\n {\n orientation: \"horizontal\",\n variant: \"default\",\n class: \"gap-8\",\n },\n {\n orientation: \"vertical\",\n variant: \"alternate\",\n class: \"relative w-full gap-3\",\n },\n {\n orientation: \"horizontal\",\n variant: \"alternate\",\n class: \"items-center gap-4\",\n },\n ],\n defaultVariants: {\n orientation: \"vertical\",\n variant: \"default\",\n },\n },\n);\n\ninterface TimelineProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {\n dir?: Direction;\n orientation?: Orientation;\n variant?: Variant;\n activeIndex?: number;\n}\n\nfunction Timeline(props: TimelineProps) {\n const {\n orientation = \"vertical\",\n variant = \"default\",\n dir: dirProp,\n activeIndex,\n render,\n className,\n ...rootProps\n } = props;\n\n const contextDir = useDirection();\n const dir = dirProp ?? contextDir;\n\n const listenersRef = useLazyRef(() => new Set<() => void>());\n const stateRef = useLazyRef(() => ({\n items: new Map(),\n }));\n\n const store = React.useMemo(() => {\n return {\n subscribe: (cb) => {\n listenersRef.current.add(cb);\n return () => listenersRef.current.delete(cb);\n },\n getState: () => stateRef.current,\n notify: () => {\n for (const cb of listenersRef.current) {\n cb();\n }\n },\n onItemRegister: (\n id: string,\n ref: React.RefObject,\n ) => {\n stateRef.current.items.set(id, ref);\n store.notify();\n },\n onItemUnregister: (id: string) => {\n stateRef.current.items.delete(id);\n store.notify();\n },\n getNextItemStatus: (id: string, activeIndex?: number) => {\n const entries = Array.from(stateRef.current.items.entries());\n const sortedEntries = getSortedEntries(entries);\n\n const currentIndex = sortedEntries.findIndex(([key]) => key === id);\n if (currentIndex === -1 || currentIndex === sortedEntries.length - 1) {\n return undefined;\n }\n\n const nextItemIndex = currentIndex + 1;\n return getItemStatus(nextItemIndex, activeIndex);\n },\n getItemIndex: (id: string) => {\n const entries = Array.from(stateRef.current.items.entries());\n const sortedEntries = getSortedEntries(entries);\n return sortedEntries.findIndex(([key]) => key === id);\n },\n };\n }, [listenersRef, stateRef]);\n\n const contextValue = React.useMemo(\n () => ({\n dir,\n orientation,\n variant,\n activeIndex,\n }),\n [dir, orientation, variant, activeIndex],\n );\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"list\",\n \"aria-orientation\": orientation,\n dir,\n className: cn(timelineVariants({ orientation, variant, className })),\n },\n rootProps,\n ),\n render,\n state: {\n slot: \"timeline\",\n orientation,\n variant,\n },\n });\n\n return (\n \n \n {element}\n \n \n );\n}\n\ninterface TimelineItemContextValue {\n id: string;\n status: Status;\n isAlternateRight: boolean;\n}\n\nconst TimelineItemContext =\n React.createContext(null);\n\nfunction useTimelineItemContext(consumerName: string) {\n const context = React.useContext(TimelineItemContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`${ITEM_NAME}\\``);\n }\n return context;\n}\n\nconst timelineItemVariants = cva(\"relative flex\", {\n variants: {\n orientation: {\n vertical: \"\",\n horizontal: \"\",\n },\n variant: {\n default: \"\",\n alternate: \"\",\n },\n isAlternateRight: {\n true: \"\",\n false: \"\",\n },\n },\n compoundVariants: [\n {\n orientation: \"vertical\",\n variant: \"default\",\n class: \"gap-3 pb-8 last:pb-0\",\n },\n {\n orientation: \"horizontal\",\n variant: \"default\",\n class: \"flex-col gap-3\",\n },\n {\n orientation: \"vertical\",\n variant: \"alternate\",\n isAlternateRight: false,\n class: \"w-1/2 gap-3 pr-6 pb-12 last:pb-0\",\n },\n {\n orientation: \"vertical\",\n variant: \"alternate\",\n isAlternateRight: true,\n class: \"ml-auto w-1/2 flex-row-reverse gap-3 pb-12 pl-6 last:pb-0\",\n },\n {\n orientation: \"horizontal\",\n variant: \"alternate\",\n class: \"grid min-w-0 grid-rows-[1fr_auto_1fr] gap-3\",\n },\n ],\n defaultVariants: {\n orientation: \"vertical\",\n variant: \"default\",\n isAlternateRight: false,\n },\n});\n\ninterface TimelineItemProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction TimelineItem(props: TimelineItemProps) {\n const { render, className, id, ref, ...itemProps } = props;\n\n const { dir, orientation, variant, activeIndex } =\n useTimelineContext(ITEM_NAME);\n const store = useStoreContext(ITEM_NAME);\n\n const instanceId = React.useId();\n const itemId = id ?? instanceId;\n const itemRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, itemRef);\n\n const itemIndex = useStore((state) => state.getItemIndex(itemId));\n\n const status = React.useMemo(() => {\n return getItemStatus(itemIndex, activeIndex);\n }, [activeIndex, itemIndex]);\n\n useIsomorphicLayoutEffect(() => {\n store.onItemRegister(itemId, itemRef);\n return () => {\n store.onItemUnregister(itemId);\n };\n }, [id, store]);\n\n const isAlternateRight = variant === \"alternate\" && itemIndex % 2 === 1;\n\n const itemContextValue = React.useMemo(\n () => ({ id: itemId, status, isAlternateRight }),\n [itemId, status, isAlternateRight],\n );\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"listitem\",\n \"aria-current\": status === \"active\" ? \"step\" : undefined,\n id: itemId,\n dir,\n ref: composedRef,\n className: cn(\n timelineItemVariants({\n orientation,\n variant,\n isAlternateRight,\n className,\n }),\n ),\n },\n itemProps,\n ),\n render,\n state: {\n slot: \"timeline-item\",\n status,\n orientation,\n ...(isAlternateRight && { \"alternate-right\": \"\" }),\n },\n });\n\n return (\n \n {element}\n \n );\n}\n\nconst timelineContentVariants = cva(\"flex-1\", {\n variants: {\n orientation: {\n vertical: \"\",\n horizontal: \"\",\n },\n variant: {\n default: \"\",\n alternate: \"\",\n },\n isAlternateRight: {\n true: \"\",\n false: \"\",\n },\n },\n compoundVariants: [\n {\n variant: \"alternate\",\n orientation: \"vertical\",\n isAlternateRight: false,\n class: \"text-right\",\n },\n {\n variant: \"alternate\",\n orientation: \"horizontal\",\n isAlternateRight: false,\n class: \"row-start-3 pt-2\",\n },\n {\n variant: \"alternate\",\n orientation: \"horizontal\",\n isAlternateRight: true,\n class: \"row-start-1 pb-2\",\n },\n ],\n defaultVariants: {\n orientation: \"vertical\",\n variant: \"default\",\n isAlternateRight: false,\n },\n});\n\ninterface TimelineContentProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction TimelineContent(props: TimelineContentProps) {\n const { render, className, ...contentProps } = props;\n\n const { variant, orientation } = useTimelineContext(CONTENT_NAME);\n const { status, isAlternateRight } = useTimelineItemContext(CONTENT_NAME);\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\n timelineContentVariants({\n orientation,\n variant,\n isAlternateRight,\n className,\n }),\n ),\n },\n contentProps,\n ),\n render,\n state: {\n slot: \"timeline-content\",\n status,\n },\n });\n}\n\nconst timelineDotVariants = cva(\n \"relative z-10 flex size-[var(--timeline-dot-size)] shrink-0 items-center justify-center rounded-full border-2 bg-background\",\n {\n variants: {\n status: {\n completed: \"border-primary\",\n active: \"border-primary\",\n pending: \"border-border\",\n },\n orientation: {\n vertical: \"\",\n horizontal: \"\",\n },\n variant: {\n default: \"\",\n alternate: \"\",\n },\n isAlternateRight: {\n true: \"\",\n false: \"\",\n },\n },\n compoundVariants: [\n {\n variant: \"alternate\",\n orientation: \"vertical\",\n isAlternateRight: false,\n class:\n \"absolute -right-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] bg-background\",\n },\n {\n variant: \"alternate\",\n orientation: \"vertical\",\n isAlternateRight: true,\n class:\n \"absolute -left-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] bg-background\",\n },\n {\n variant: \"alternate\",\n orientation: \"horizontal\",\n class: \"row-start-2 bg-background\",\n },\n {\n variant: \"alternate\",\n status: \"completed\",\n class: \"bg-background\",\n },\n {\n variant: \"alternate\",\n status: \"active\",\n class: \"bg-background\",\n },\n ],\n defaultVariants: {\n status: \"pending\",\n orientation: \"vertical\",\n variant: \"default\",\n isAlternateRight: false,\n },\n },\n);\n\ninterface TimelineDotProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction TimelineDot(props: TimelineDotProps) {\n const { render, className, ...dotProps } = props;\n\n const { orientation, variant } = useTimelineContext(DOT_NAME);\n const { status, isAlternateRight } = useTimelineItemContext(DOT_NAME);\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\n timelineDotVariants({\n status,\n orientation,\n variant,\n isAlternateRight,\n className,\n }),\n ),\n },\n dotProps,\n ),\n render,\n state: {\n slot: \"timeline-dot\",\n status,\n orientation,\n },\n });\n}\n\nconst timelineConnectorVariants = cva(\"absolute z-0\", {\n variants: {\n isCompleted: {\n true: \"bg-primary\",\n false: \"bg-border\",\n },\n orientation: {\n vertical: \"\",\n horizontal: \"\",\n },\n variant: {\n default: \"\",\n alternate: \"\",\n },\n isAlternateRight: {\n true: \"\",\n false: \"\",\n },\n },\n compoundVariants: [\n {\n orientation: \"vertical\",\n variant: \"default\",\n class:\n \"start-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] top-3 h-[calc(100%+0.5rem)] w-[var(--timeline-connector-thickness)]\",\n },\n {\n orientation: \"horizontal\",\n variant: \"default\",\n class:\n \"start-3 top-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] h-[var(--timeline-connector-thickness)] w-[calc(100%+0.5rem)]\",\n },\n {\n orientation: \"vertical\",\n variant: \"alternate\",\n isAlternateRight: false,\n class:\n \"top-2 -right-[calc(var(--timeline-connector-thickness)/2)] h-full w-[var(--timeline-connector-thickness)]\",\n },\n {\n orientation: \"vertical\",\n variant: \"alternate\",\n isAlternateRight: true,\n class:\n \"top-2 -left-[calc(var(--timeline-connector-thickness)/2)] h-full w-[var(--timeline-connector-thickness)]\",\n },\n {\n orientation: \"horizontal\",\n variant: \"alternate\",\n class:\n \"top-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] left-3 row-start-2 h-[var(--timeline-connector-thickness)] w-[calc(100%+0.5rem)]\",\n },\n ],\n defaultVariants: {\n isCompleted: false,\n orientation: \"vertical\",\n variant: \"default\",\n isAlternateRight: false,\n },\n});\n\ninterface TimelineConnectorProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {\n forceMount?: boolean;\n}\n\nfunction TimelineConnector(props: TimelineConnectorProps) {\n const { render, forceMount, className, ...connectorProps } = props;\n\n const { orientation, variant, activeIndex } =\n useTimelineContext(CONNECTOR_NAME);\n const { id, status, isAlternateRight } =\n useTimelineItemContext(CONNECTOR_NAME);\n\n const nextItemStatus = useStore((state) =>\n state.getNextItemStatus(id, activeIndex),\n );\n\n const isLastItem = nextItemStatus === undefined;\n const isConnectorCompleted =\n nextItemStatus === \"completed\" || nextItemStatus === \"active\";\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n \"aria-hidden\": \"true\",\n className: cn(\n timelineConnectorVariants({\n isCompleted: isConnectorCompleted,\n orientation,\n variant,\n isAlternateRight,\n className,\n }),\n ),\n },\n connectorProps,\n ),\n render,\n state: {\n slot: \"timeline-connector\",\n ...(isConnectorCompleted && { completed: \"\" }),\n status,\n orientation,\n },\n });\n\n if (!forceMount && isLastItem) return null;\n\n return element;\n}\n\ninterface TimelineHeaderProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction TimelineHeader(props: TimelineHeaderProps) {\n const { render, className, ...headerProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\"flex flex-col gap-1\", className),\n },\n headerProps,\n ),\n render,\n state: {\n slot: \"timeline-header\",\n },\n });\n}\n\ninterface TimelineTitleProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction TimelineTitle(props: TimelineTitleProps) {\n const { render, className, ...titleProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\"font-semibold leading-none\", className),\n },\n titleProps,\n ),\n render,\n state: {\n slot: \"timeline-title\",\n },\n });\n}\n\ninterface TimelineDescriptionProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction TimelineDescription(props: TimelineDescriptionProps) {\n const { render, className, ...descriptionProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\"text-muted-foreground text-sm\", className),\n },\n descriptionProps,\n ),\n render,\n state: {\n slot: \"timeline-description\",\n },\n });\n}\n\ninterface TimelineTimeProps\n extends React.ComponentProps<\"time\">,\n useRender.ComponentProps<\"time\"> {}\n\nfunction TimelineTime(props: TimelineTimeProps) {\n const { render, className, ...timeProps } = props;\n\n return useRender({\n defaultTagName: \"time\",\n props: mergeProps<\"time\">(\n {\n className: cn(\"text-muted-foreground text-xs\", className),\n },\n timeProps,\n ),\n render,\n state: {\n slot: \"timeline-time\",\n },\n });\n}\n\nexport {\n Timeline,\n TimelineConnector,\n TimelineContent,\n TimelineDescription,\n TimelineDot,\n TimelineHeader,\n TimelineItem,\n type TimelineProps,\n TimelineTime,\n TimelineTitle,\n};\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ], "registryDependencies": [ "direction", "@diceui/use-isomorphic-layout-effect", "@diceui/use-lazy-ref" ], "dependencies": [ "@base-ui/react" ] }