1import * as React from 'react';
2import { cn } from '../lib/cn';
3import { assignRef } from '../lib/overlay';
4
5type IconProps = React.SVGProps<SVGSVGElement> & {
6 size?: number | string;
7};
8
9function ChevronRightIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
10 return (
11 <svg
12 xmlns="http://www.w3.org/2000/svg"
13 width={size}
14 height={size}
15 viewBox="0 0 24 24"
16 fill="none"
17 stroke="currentColor"
18 strokeWidth={strokeWidth}
19 strokeLinecap="round"
20 strokeLinejoin="round"
21 aria-hidden="true"
22 {...props}
23 >
24 <path d="m9 6 6 6-6 6" />
25 </svg>
26 );
27}
28
29function MoreHorizontalIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
30 return (
31 <svg
32 xmlns="http://www.w3.org/2000/svg"
33 width={size}
34 height={size}
35 viewBox="0 0 24 24"
36 fill="none"
37 stroke="currentColor"
38 strokeWidth={strokeWidth}
39 strokeLinecap="round"
40 strokeLinejoin="round"
41 aria-hidden="true"
42 {...props}
43 >
44 <circle cx="5" cy="12" r="1" />
45 <circle cx="12" cy="12" r="1" />
46 <circle cx="19" cy="12" r="1" />
47 </svg>
48 );
49}
50
51type SlotProps = Record<string, unknown> & {
52 className?: string;
53 style?: React.CSSProperties;
54};
55
56function mergeSlotProps(slotProps: SlotProps, childProps: SlotProps): SlotProps {
57 const merged: SlotProps = { ...slotProps, ...childProps };
58 for (const key of Object.keys(slotProps)) {
59 const slotValue = slotProps[key];
60 const childValue = childProps[key];
61 if (
62 /^on[A-Z]/.test(key) &&
63 typeof slotValue === 'function' &&
64 typeof childValue === 'function'
65 ) {
66 merged[key] = (...args: unknown[]) => {
67 childValue(...args);
68 slotValue(...args);
69 };
70 }
71 }
72 if (slotProps.style || childProps.style) {
73 merged.style = { ...slotProps.style, ...childProps.style };
74 }
75 merged.className = cn(slotProps.className, childProps.className);
76 return merged;
77}
78
79const BreadcrumbRoot = ({
80 className,
81 ref,
82 ...props
83}: React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement> }) => {
84 return <nav ref={ref} aria-label="breadcrumb" className={className} {...props} />;
85};
86
87const BreadcrumbList = ({
88 className,
89 ref,
90 ...props
91}: React.OlHTMLAttributes<HTMLOListElement> & { ref?: React.Ref<HTMLOListElement> }) => {
92 return (
93 <ol
94 ref={ref}
95 className={cn(
96 'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5',
97 className,
98 )}
99 {...props}
100 />
101 );
102};
103
104const BreadcrumbItem = ({
105 className,
106 ref,
107 ...props
108}: React.LiHTMLAttributes<HTMLLIElement> & { ref?: React.Ref<HTMLLIElement> }) => {
109 return <li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />;
110};
111
112type BreadcrumbLinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
113 children: React.ReactNode;
114};
115
116const BreadcrumbLink = ({
117 className,
118 children,
119 ref,
120 ...props
121}: BreadcrumbLinkProps & { ref?: React.Ref<HTMLAnchorElement> }) => {
122 const isLinkComponent =
123 React.isValidElement(children) &&
124 (typeof children.type === 'function' || typeof children.type === 'object');
125
126 if (isLinkComponent) {
127 const childProps = children.props as SlotProps & { ref?: React.Ref<HTMLAnchorElement> };
128 const childRef = childProps.ref;
129 return React.cloneElement(children as React.ReactElement<SlotProps>, {
130 ...mergeSlotProps(
131 { ...props, className: cn('hover:text-foreground', className) },
132 childProps,
133 ),
134 ref:
135 ref || childRef
136 ? (node: HTMLAnchorElement | null) => {
137 assignRef(ref, node);
138 assignRef(childRef, node);
139 }
140 : undefined,
141 });
142 }
143
144 return (
145 <a ref={ref} className={cn('hover:text-foreground', className)} {...props}>
146 {children}
147 </a>
148 );
149};
150
151const BreadcrumbPage = ({
152 className,
153 ref,
154 ...props
155}: React.HTMLAttributes<HTMLSpanElement> & { ref?: React.Ref<HTMLSpanElement> }) => {
156 return (
157 <span
158 ref={ref}
159 aria-current="page"
160 className={cn('text-foreground font-normal', className)}
161 {...props}
162 />
163 );
164};
165
166const BreadcrumbSeparator = ({
167 children,
168 className,
169 ref,
170 ...props
171}: React.LiHTMLAttributes<HTMLLIElement> & { ref?: React.Ref<HTMLLIElement> }) => {
172 return (
173 <li
174 ref={ref}
175 role="presentation"
176 aria-hidden="true"
177 className={cn('[&>svg]:size-3.5', className)}
178 {...props}
179 >
180 {children ?? <ChevronRightIcon size={14} className="size-3.5" />}
181 </li>
182 );
183};
184
185type BreadcrumbEllipsisProps = React.HTMLAttributes<HTMLSpanElement> & {
186 label?: string;
187};
188
189const BreadcrumbEllipsis = ({
190 className,
191 label = 'More',
192 ref,
193 ...props
194}: BreadcrumbEllipsisProps & { ref?: React.Ref<HTMLSpanElement> }) => {
195 return (
196 <span ref={ref} className={cn('flex size-9 items-center justify-center', className)} {...props}>
197 <MoreHorizontalIcon className="size-4" size={16} />
198 <span className="sr-only">{label}</span>
199 </span>
200 );
201};
202
203const Breadcrumb = Object.assign(BreadcrumbRoot, {
204 List: BreadcrumbList,
205 Item: BreadcrumbItem,
206 Link: BreadcrumbLink,
207 Page: BreadcrumbPage,
208 Separator: BreadcrumbSeparator,
209 Ellipsis: BreadcrumbEllipsis,
210});
211
212export { Breadcrumb };
213export type { BreadcrumbEllipsisProps, BreadcrumbLinkProps };