Skip to content

Commit 3d69846

Browse files
committed
feat: add component breadcrumb
1 parent 824a708 commit 3d69846

File tree

16 files changed

+593
-1
lines changed

16 files changed

+593
-1
lines changed

packages/ui-variants/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './variants/alert';
77
export * from './variants/alert-dialog';
88
export * from './variants/avatar';
99
export * from './variants/badge';
10+
export * from './variants/breadcrumb';
1011
export * from './variants/button';
1112
export * from './variants/button-group';
1213
export * from './variants/card';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { tv } from 'tailwind-variants';
2+
3+
export const breadcrumbVariants = tv({
4+
defaultVariants: {
5+
size: 'md'
6+
},
7+
slots: {
8+
ellipsis: 'flex items-center justify-center',
9+
item: 'inline-flex items-center list-none',
10+
link: 'hover:text-foreground transition-colors-200 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-primary',
11+
list: 'flex flex-wrap items-center my-0 break-words text-muted-foreground',
12+
page: 'font-normal text-foreground',
13+
root: '',
14+
separator: 'text-muted-foreground flex-shrink-0 list-none'
15+
},
16+
variants: {
17+
size: {
18+
'2xl': {
19+
item: 'gap-3.5',
20+
list: 'gap-4.5',
21+
root: 'text-xl'
22+
},
23+
lg: {
24+
item: 'gap-2.5',
25+
list: 'gap-3.5',
26+
root: 'text-base'
27+
},
28+
md: {
29+
item: 'gap-2',
30+
list: 'gap-3',
31+
root: 'text-sm'
32+
},
33+
sm: {
34+
item: 'gap-1.75',
35+
list: 'gap-2.5',
36+
root: 'text-xs'
37+
},
38+
xl: {
39+
item: 'gap-3',
40+
list: 'gap-4',
41+
root: 'text-lg'
42+
},
43+
xs: {
44+
item: 'gap-1.5',
45+
list: 'gap-2',
46+
root: 'text-2xs'
47+
}
48+
}
49+
}
50+
});
51+
52+
export type BreadcrumbSlots = keyof typeof breadcrumbVariants.slots;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export { default as Breadcrumb } from './source/Breadcrumb';
2+
export { default as BreadcrumbEllipsis } from './source/BreadcrumbEllipsis';
3+
export { default as BreadcrumbItemContent } from './source/BreadcrumbItem';
4+
export { default as BreadcrumbLink } from './source/BreadcrumbLink';
5+
export { default as BreadcrumbList } from './source/BreadcrumbList';
6+
export { default as BreadcrumbPage } from './source/BreadcrumbPage';
7+
export { default as BreadcrumbRoot } from './source/BreadcrumbRoot';
8+
export { default as BreadcrumbSeparator } from './source/BreadcrumbSeparator';
9+
10+
export * from './types';
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { Ref } from 'react';
2+
import { Fragment, forwardRef } from 'react';
3+
4+
import type { BreadcrumbItem, BreadcrumbProps } from '../types';
5+
6+
import BreadcrumbEllipsis from './BreadcrumbEllipsis';
7+
import BreadcrumbItemContent from './BreadcrumbItem';
8+
import BreadcrumbLink from './BreadcrumbLink';
9+
import BreadcrumbList from './BreadcrumbList';
10+
import BreadcrumbPage from './BreadcrumbPage';
11+
import BreadcrumbRoot from './BreadcrumbRoot';
12+
import BreadcrumbSeparator from './BreadcrumbSeparator';
13+
14+
type EllipsisProps<T extends BreadcrumbItem> = Pick<
15+
BreadcrumbProps<T>,
16+
'className' | 'ellipsisIcon' | 'items' | 'renderEllipsis'
17+
>;
18+
19+
const Ellipsis = <T extends BreadcrumbItem>({ className, ellipsisIcon, items, renderEllipsis }: EllipsisProps<T>) => {
20+
if (!renderEllipsis) return <BreadcrumbEllipsis className={className}>{ellipsisIcon}</BreadcrumbEllipsis>;
21+
22+
return renderEllipsis(items);
23+
};
24+
25+
function renderBreadcrumbContent<T extends BreadcrumbItem>(item: T, renderItem: BreadcrumbProps<T>['renderItem']) {
26+
if (renderItem) return renderItem(item);
27+
28+
if (item.href) return <BreadcrumbLink {...item}>{item.label}</BreadcrumbLink>;
29+
30+
return <BreadcrumbPage {...item}>{item.label}</BreadcrumbPage>;
31+
}
32+
33+
const Breadcrumb = <T extends BreadcrumbItem>(props: BreadcrumbProps<T>, ref: Ref<HTMLElement>) => {
34+
const { className, classNames, ellipsis, ellipsisIcon, items, renderEllipsis, renderItem, separator, size, ...rest } =
35+
props;
36+
37+
const computedEllipsisRange = getEllipsisRange();
38+
39+
const itemsFilterEllipsis = getItemsFilterEllipsis();
40+
41+
const startEllipsisIndex = computedEllipsisRange?.[0];
42+
43+
const ellipsisItems = computedEllipsisRange ? items.slice(computedEllipsisRange[0], computedEllipsisRange[1]) : [];
44+
45+
function getItemsFilterEllipsis() {
46+
if (!computedEllipsisRange) return items;
47+
48+
const [start, end] = computedEllipsisRange;
49+
50+
return [...items.slice(0, start), ...items.slice(end)];
51+
}
52+
53+
function getEllipsisRange() {
54+
/** when the item count is greater than 4, we will show ellipsis */
55+
const MIN_ITEM_COUNT_WITH_ELLIPSIS = 5;
56+
57+
if (!ellipsis || items.length < MIN_ITEM_COUNT_WITH_ELLIPSIS) return null;
58+
59+
if (ellipsis === true) {
60+
return [1, items.length - 2];
61+
}
62+
63+
let [start, end] = ellipsis;
64+
65+
if (start === 0) {
66+
start = 1;
67+
}
68+
69+
if (end === items.length) {
70+
end = items.length - 1;
71+
}
72+
73+
return [start, end];
74+
}
75+
76+
return (
77+
<BreadcrumbRoot
78+
className={className || classNames?.root}
79+
size={size}
80+
{...rest}
81+
ref={ref}
82+
>
83+
<BreadcrumbList
84+
className={classNames?.list}
85+
size={size}
86+
>
87+
{itemsFilterEllipsis.map((item, index) => {
88+
const isEllipsis = startEllipsisIndex && startEllipsisIndex === index;
89+
90+
const isShowSeparator = index < itemsFilterEllipsis.length - 1;
91+
92+
return (
93+
<Fragment key={item.value}>
94+
{isEllipsis && (
95+
<>
96+
<Ellipsis<T>
97+
className={classNames?.ellipsis}
98+
ellipsisIcon={ellipsisIcon}
99+
items={ellipsisItems}
100+
renderEllipsis={renderEllipsis}
101+
/>
102+
{separator || <BreadcrumbSeparator className={classNames?.separator} />}
103+
</>
104+
)}
105+
106+
<BreadcrumbItemContent
107+
className={classNames?.item}
108+
size={size}
109+
>
110+
{item.leading}
111+
112+
{renderBreadcrumbContent(item, renderItem)}
113+
114+
{item.trailing}
115+
</BreadcrumbItemContent>
116+
117+
{isShowSeparator && (separator || <BreadcrumbSeparator className={classNames?.separator} />)}
118+
</Fragment>
119+
);
120+
})}
121+
</BreadcrumbList>
122+
</BreadcrumbRoot>
123+
);
124+
};
125+
126+
Breadcrumb.displayName = 'Breadcrumb';
127+
128+
export default forwardRef(Breadcrumb);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { breadcrumbVariants, cn } from '@soybean-react-ui/variants';
2+
import { Ellipsis } from 'lucide-react';
3+
4+
import type { BreadcrumbEllipsisProps } from '../types';
5+
6+
const BreadcrumbEllipsis = (props: BreadcrumbEllipsisProps) => {
7+
const { children, className, ...rest } = props;
8+
9+
const { ellipsis } = breadcrumbVariants();
10+
11+
const mergedCls = cn(ellipsis, className);
12+
return (
13+
<span
14+
aria-hidden="true"
15+
className={mergedCls}
16+
role="presentation"
17+
{...rest}
18+
>
19+
{children || <Ellipsis />}
20+
<span className="sr-only">More</span>
21+
</span>
22+
);
23+
};
24+
25+
BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';
26+
27+
export default BreadcrumbEllipsis;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { breadcrumbVariants, cn } from '@soybean-react-ui/variants';
2+
import { forwardRef } from 'react';
3+
4+
import type { BreadcrumbItemProps } from '../types';
5+
6+
const BreadcrumbItem = forwardRef<HTMLLIElement, BreadcrumbItemProps>((props, ref) => {
7+
const { className, size, ...rest } = props;
8+
9+
const { item } = breadcrumbVariants({ size });
10+
11+
const mergedCls = cn(item(), className);
12+
13+
return (
14+
<li
15+
className={mergedCls}
16+
ref={ref}
17+
{...rest}
18+
/>
19+
);
20+
});
21+
22+
BreadcrumbItem.displayName = 'BreadcrumbItem';
23+
24+
export default BreadcrumbItem;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Slot } from '@radix-ui/react-slot';
2+
import { breadcrumbVariants, cn } from '@soybean-react-ui/variants';
3+
import { forwardRef } from 'react';
4+
5+
import type { BreadcrumbLinkProps } from '../types';
6+
7+
const BreadcrumbLink = forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>((props, ref) => {
8+
const { asChild, className, ...rest } = props;
9+
10+
const Comp = asChild ? Slot : 'a';
11+
12+
const { link } = breadcrumbVariants();
13+
14+
const mergedCls = cn(link(), className);
15+
16+
return (
17+
<Comp
18+
className={mergedCls}
19+
ref={ref}
20+
{...rest}
21+
/>
22+
);
23+
});
24+
25+
BreadcrumbLink.displayName = 'BreadcrumbLink';
26+
27+
export default BreadcrumbLink;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { breadcrumbVariants, cn } from '@soybean-react-ui/variants';
2+
import { forwardRef } from 'react';
3+
4+
import type { BreadcrumbListProps } from '../types';
5+
6+
const BreadcrumbList = forwardRef<HTMLOListElement, BreadcrumbListProps>((props, ref) => {
7+
const { className, size, ...rest } = props;
8+
9+
const { list } = breadcrumbVariants({ size });
10+
11+
const mergedCls = cn(list(), className);
12+
13+
return (
14+
<ol
15+
className={mergedCls}
16+
ref={ref}
17+
{...rest}
18+
/>
19+
);
20+
});
21+
22+
BreadcrumbList.displayName = 'BreadcrumbList';
23+
24+
export default BreadcrumbList;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { breadcrumbVariants, cn } from '@soybean-react-ui/variants';
2+
import { forwardRef } from 'react';
3+
4+
import type { BreadcrumbPageProps } from '../types';
5+
6+
const BreadcrumbPage = forwardRef<HTMLSpanElement, BreadcrumbPageProps>((props, ref) => {
7+
const { className, size, ...rest } = props;
8+
9+
const { page } = breadcrumbVariants({ size });
10+
11+
const mergedCls = cn(page(), className);
12+
13+
return (
14+
<span
15+
aria-current="page"
16+
aria-disabled="true"
17+
className={mergedCls}
18+
ref={ref}
19+
role="link"
20+
{...rest}
21+
/>
22+
);
23+
});
24+
25+
BreadcrumbPage.displayName = 'BreadcrumbPage';
26+
27+
export default BreadcrumbPage;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { breadcrumbVariants, cn } from '@soybean-react-ui/variants';
2+
import { forwardRef } from 'react';
3+
4+
import type { BreadcrumbRootProps } from '../types';
5+
6+
const BreadcrumbRoot = forwardRef<HTMLElement, BreadcrumbRootProps>((props, ref) => {
7+
const { className, size, ...rest } = props;
8+
9+
const { root } = breadcrumbVariants({ size });
10+
11+
const mergedCls = cn(root(), className);
12+
13+
return (
14+
<nav
15+
aria-label="breadcrumb"
16+
className={mergedCls}
17+
ref={ref}
18+
{...rest}
19+
/>
20+
);
21+
});
22+
23+
BreadcrumbRoot.displayName = 'BreadcrumbRoot';
24+
25+
export default BreadcrumbRoot;

0 commit comments

Comments
 (0)