1'use client';
2
3import { cva, type VariantProps } from 'class-variance-authority';
4import * as React from 'react';
5import { cn } from '../lib/cn';
6
7type TimelineOrientation = 'vertical' | 'horizontal';
8
9interface TimelineLabels {
10 completed: string;
11 list: string;
12}
13
14const DEFAULT_LABELS: TimelineLabels = {
15 completed: 'Completed',
16 list: 'Timeline',
17};
18
19interface TimelineContextValue {
20 orientation: TimelineOrientation;
21 value: number;
22 completedLabel: string;
23}
24
25interface TimelineItemContextValue {
26 step: number;
27 completed: boolean;
28 active: boolean;
29}
30
31const TimelineContext = React.createContext<TimelineContextValue | null>(null);
32const TimelineItemContext = React.createContext<TimelineItemContextValue | null>(null);
33
34function useTimelineContext(): TimelineContextValue {
35 const context = React.use(TimelineContext);
36 if (!context) throw new Error('Timeline components must be used within Timeline');
37 return context;
38}
39
40function useTimelineItemContext(): TimelineItemContextValue {
41 const context = React.use(TimelineItemContext);
42 if (!context) throw new Error('Timeline item parts must be used within Timeline.Item');
43 return context;
44}
45
46const timelineVariants = cva('group/timeline m-0 flex list-none p-0', {
47 variants: {
48 orientation: {
49 vertical: 'flex-col',
50 horizontal:
51 'w-full flex-row overflow-x-auto rounded-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
52 },
53 alternate: {
54 true: '',
55 false: '',
56 },
57 },
58 compoundVariants: [
59 {
60 orientation: 'vertical',
61 alternate: true,
62 className: [
63 'sm:[&>[data-slot=timeline-item]]:w-[calc(50%+0.5rem)]',
64 'sm:[&>[data-slot=timeline-item]:nth-child(odd)]:self-end',
65 'sm:[&>[data-slot=timeline-item]:nth-child(even)]:items-end',
66 'sm:[&>[data-slot=timeline-item]:nth-child(even)]:self-start',
67 'sm:[&>[data-slot=timeline-item]:nth-child(even)]:pe-8',
68 'sm:[&>[data-slot=timeline-item]:nth-child(even)]:ps-0',
69 'sm:[&>[data-slot=timeline-item]:nth-child(even)]:text-end',
70 'sm:[&>[data-slot=timeline-item]:nth-child(even)_[data-slot=timeline-indicator]]:right-2',
71 'sm:[&>[data-slot=timeline-item]:nth-child(even)_[data-slot=timeline-indicator]]:left-auto',
72 'sm:[&>[data-slot=timeline-item]:nth-child(even)_[data-slot=timeline-indicator]]:translate-x-1/2',
73 'sm:[&>[data-slot=timeline-item]:nth-child(even)_[data-slot=timeline-separator]]:right-2',
74 'sm:[&>[data-slot=timeline-item]:nth-child(even)_[data-slot=timeline-separator]]:left-auto',
75 'sm:[&>[data-slot=timeline-item]:nth-child(even)_[data-slot=timeline-separator]]:translate-x-1/2',
76 ].join(' '),
77 },
78 ],
79 defaultVariants: {
80 orientation: 'vertical',
81 alternate: false,
82 },
83});
84
85interface TimelineProps
86 extends
87 Omit<React.HTMLAttributes<HTMLOListElement>, 'defaultValue'>,
88 VariantProps<typeof timelineVariants> {
89 value?: number;
90 labels?: Partial<TimelineLabels>;
91}
92
93interface TimelineItemProps extends React.HTMLAttributes<HTMLLIElement> {
94 step: number;
95}
96
97type TimelineHeaderProps = React.HTMLAttributes<HTMLDivElement>;
98type TimelineDateProps = React.TimeHTMLAttributes<HTMLTimeElement>;
99
100interface TimelineTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
101 as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
102}
103
104type TimelineIndicatorProps = React.HTMLAttributes<HTMLDivElement>;
105type TimelineSeparatorProps = React.HTMLAttributes<HTMLDivElement>;
106type TimelineContentProps = React.HTMLAttributes<HTMLDivElement>;
107
108const TimelineRoot = ({
109 className,
110 orientation = 'vertical',
111 alternate = false,
112 value = 1,
113 labels: labelOverrides,
114 ref,
115 ...props
116}: TimelineProps & { ref?: React.Ref<HTMLOListElement> }) => {
117 const resolvedOrientation: TimelineOrientation = orientation ?? 'vertical';
118 const labels = { ...DEFAULT_LABELS, ...labelOverrides };
119 const completedLabel = labels.completed;
120 const context = React.useMemo(
121 () => ({ orientation: resolvedOrientation, value, completedLabel }),
122 [resolvedOrientation, value, completedLabel],
123 );
124 const scrollProps =
125 resolvedOrientation === 'horizontal' ? { tabIndex: 0, 'aria-label': labels.list } : undefined;
126
127 return (
128 <TimelineContext value={context}>
129 <ol
130 ref={ref}
131 data-slot="timeline"
132 data-orientation={resolvedOrientation}
133 {...scrollProps}
134 className={cn(timelineVariants({ orientation: resolvedOrientation, alternate }), className)}
135 {...props}
136 />
137 </TimelineContext>
138 );
139};
140
141TimelineRoot.displayName = 'Timeline';
142
143const TimelineItem = ({
144 className,
145 step,
146 children,
147 ref,
148 ...props
149}: TimelineItemProps & { ref?: React.Ref<HTMLLIElement> }) => {
150 const { orientation, value, completedLabel } = useTimelineContext();
151 const completed = step <= value;
152 const active = step === value;
153 const context = React.useMemo(() => ({ step, completed, active }), [step, completed, active]);
154
155 return (
156 <TimelineItemContext value={context}>
157 <li
158 ref={ref}
159 data-slot="timeline-item"
160 data-orientation={orientation}
161 data-completed={completed ? '' : undefined}
162 data-active={active ? '' : undefined}
163 aria-current={active ? 'step' : undefined}
164 className={cn(
165 'group/item relative flex flex-1 flex-col gap-0.5',
166 orientation === 'vertical' ? 'ps-8 pb-8 last:pb-0' : 'min-w-32 pe-8 pt-8 last:pe-0',
167 className,
168 )}
169 {...props}
170 >
171 {completed && !active && completedLabel ? (
172 <span className="sr-only">{completedLabel}</span>
173 ) : null}
174 {children}
175 </li>
176 </TimelineItemContext>
177 );
178};
179
180TimelineItem.displayName = 'TimelineItem';
181
182const TimelineHeader = ({
183 className,
184 ref,
185 ...props
186}: TimelineHeaderProps & { ref?: React.Ref<HTMLDivElement> }) => {
187 return (
188 <div
189 ref={ref}
190 data-slot="timeline-header"
191 className={cn('flex flex-col gap-0.5', className)}
192 {...props}
193 />
194 );
195};
196
197TimelineHeader.displayName = 'TimelineHeader';
198
199const TimelineDate = ({
200 className,
201 ref,
202 ...props
203}: TimelineDateProps & { ref?: React.Ref<HTMLTimeElement> }) => {
204 return (
205 <time
206 ref={ref}
207 data-slot="timeline-date"
208 className={cn('text-muted-foreground block text-xs', className)}
209 {...props}
210 />
211 );
212};
213
214TimelineDate.displayName = 'TimelineDate';
215
216const TimelineTitle = ({
217 className,
218 as: Component = 'h3',
219 ref,
220 ...props
221}: TimelineTitleProps & { ref?: React.Ref<HTMLHeadingElement> }) => {
222 return (
223 <Component
224 ref={ref}
225 data-slot="timeline-title"
226 className={cn('text-foreground text-sm font-medium', className)}
227 {...props}
228 />
229 );
230};
231
232TimelineTitle.displayName = 'TimelineTitle';
233
234const TimelineIndicator = ({
235 className,
236 ref,
237 ...props
238}: TimelineIndicatorProps & { ref?: React.Ref<HTMLDivElement> }) => {
239 const { orientation } = useTimelineContext();
240 useTimelineItemContext();
241
242 return (
243 <div
244 ref={ref}
245 aria-hidden="true"
246 data-slot="timeline-indicator"
247 className={cn(
248 'border-border bg-background absolute z-10 flex size-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 [&_svg]:size-3',
249 'group-data-[completed]/item:border-primary group-data-[completed]/item:bg-primary group-data-[completed]/item:text-primary-foreground',
250 orientation === 'vertical' ? 'top-2.5 left-2' : 'top-2 left-2',
251 className,
252 )}
253 {...props}
254 />
255 );
256};
257
258TimelineIndicator.displayName = 'TimelineIndicator';
259
260const TimelineSeparator = ({
261 className,
262 ref,
263 ...props
264}: TimelineSeparatorProps & { ref?: React.Ref<HTMLDivElement> }) => {
265 const { orientation } = useTimelineContext();
266 useTimelineItemContext();
267
268 return (
269 <div
270 ref={ref}
271 aria-hidden="true"
272 data-slot="timeline-separator"
273 className={cn(
274 'bg-border absolute group-last/item:hidden',
275 'group-data-[completed]/item:bg-primary',
276 orientation === 'vertical'
277 ? 'top-2.5 -bottom-2.5 left-2 w-px -translate-x-1/2'
278 : 'top-2 -right-2 left-2 h-px -translate-y-1/2',
279 className,
280 )}
281 {...props}
282 />
283 );
284};
285
286TimelineSeparator.displayName = 'TimelineSeparator';
287
288const TimelineContent = ({
289 className,
290 ref,
291 ...props
292}: TimelineContentProps & { ref?: React.Ref<HTMLDivElement> }) => {
293 return (
294 <div
295 ref={ref}
296 data-slot="timeline-content"
297 className={cn('text-muted-foreground text-sm', className)}
298 {...props}
299 />
300 );
301};
302
303TimelineContent.displayName = 'TimelineContent';
304
305const Timeline = Object.assign(TimelineRoot, {
306 Item: TimelineItem,
307 Header: TimelineHeader,
308 Date: TimelineDate,
309 Title: TimelineTitle,
310 Indicator: TimelineIndicator,
311 Separator: TimelineSeparator,
312 Content: TimelineContent,
313});
314
315export { Timeline, timelineVariants, useTimelineItemContext };
316export type {
317 TimelineContentProps,
318 TimelineDateProps,
319 TimelineHeaderProps,
320 TimelineIndicatorProps,
321 TimelineItemProps,
322 TimelineLabels,
323 TimelineOrientation,
324 TimelineProps,
325 TimelineSeparatorProps,
326 TimelineTitleProps,
327};