mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat: add Breadcrumb component
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import React, { useId } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { cn, findMenuPath } from '@/lib/helper';
|
||||
import { Size } from '@/types/theme';
|
||||
import Button from '@/components/Button';
|
||||
import { MAIN_DRAWER_LINKS } from '@/config/constant';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps extends React.HTMLAttributes<HTMLElement> {
|
||||
items: BreadcrumbItem[];
|
||||
size?: Size;
|
||||
maxVisibleItems?: number;
|
||||
showEllipsisDropdown?: boolean;
|
||||
}
|
||||
|
||||
export function buildBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
||||
const menuPath = findMenuPath(MAIN_DRAWER_LINKS, pathname);
|
||||
|
||||
if (!menuPath) return [];
|
||||
|
||||
return menuPath.map((menu, index) => {
|
||||
const isLast = index === menuPath.length - 1;
|
||||
|
||||
return {
|
||||
label: menu.text,
|
||||
href: isLast ? menu.link : undefined,
|
||||
isActive: isLast,
|
||||
icon: menu.icon ? (
|
||||
<Icon icon={menu.icon} width={16} height={16} />
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const EllipsisDropdown = ({
|
||||
hiddenItems,
|
||||
}: {
|
||||
hiddenItems: BreadcrumbItem[];
|
||||
}) => {
|
||||
const dropdownId = useId();
|
||||
const anchorId = useId();
|
||||
|
||||
return (
|
||||
<li>
|
||||
{/* Ellipsis Button */}
|
||||
<Button
|
||||
popoverTarget={dropdownId}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
style={
|
||||
{
|
||||
anchorName: `--breadcrumb-ellipsis-anchor-${anchorId}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Icon icon='material-symbols:more-horiz' width={16} height={16} />
|
||||
</Button>
|
||||
|
||||
{/* Dropdown Menu using popover API */}
|
||||
<ul
|
||||
className='dropdown menu rounded-box bg-base-100 border border-base-300 shadow-lg z-[9999] [&_a:hover]:no-underline [&_a:focus]:no-underline [&&]:no-underline [&&_a]:no-underline [&&]:hover:no-underline [&&]:flex [&&]:items-start [&&]:justify-start w-max'
|
||||
popover='auto'
|
||||
id={dropdownId}
|
||||
style={
|
||||
{
|
||||
positionAnchor: `--breadcrumb-ellipsis-anchor-${anchorId}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{hiddenItems.map((item, index) => {
|
||||
const itemStyles = cn(
|
||||
'[&]:flex [&]:items-center [&]:justify-start py-1 text-sm',
|
||||
// Disabled state
|
||||
item.isDisabled && 'text-base-content/40 opacity-50',
|
||||
// Active/Last state
|
||||
(item.isActive || item.isDisabled) && 'text-primary',
|
||||
// Regular clickable state
|
||||
!item.isDisabled && 'text-base-content/50'
|
||||
);
|
||||
|
||||
const itemContent = (
|
||||
<div className={itemStyles}>
|
||||
{item.icon && (
|
||||
<span className='inline-flex mr-2'>{item.icon}</span>
|
||||
)}
|
||||
{item.label}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`ellipsis-${index}`}
|
||||
className='[&&]:text-left [&&]:block w-full'
|
||||
>
|
||||
{item.href && !item.isDisabled ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className='block !no-underline [&&]:text-left w-full'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{itemContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div className='block !no-underline [&&]:cursor-default [&&]:hover:cursor-default [&&]:hover:bg-base-100 [&&]:text-left'>
|
||||
{itemContent}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const Breadcrumb = ({
|
||||
items,
|
||||
size = 'md',
|
||||
maxVisibleItems = 3,
|
||||
showEllipsisDropdown = true,
|
||||
className,
|
||||
...props
|
||||
}: BreadcrumbsProps) => {
|
||||
const sizeClasses = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
};
|
||||
|
||||
const getItemStyles = (
|
||||
item: BreadcrumbItem,
|
||||
position: 'first' | 'middle' | 'last' = 'middle'
|
||||
) => {
|
||||
const baseClasses = 'inline-flex items-center gap-2';
|
||||
|
||||
// Disabled state
|
||||
if (item.isDisabled) {
|
||||
return `${baseClasses} text-base-content/40 !cursor-default opacity-50 hover:!no-underline`;
|
||||
}
|
||||
|
||||
// Active/Last state (no underline)
|
||||
if (item.isActive || position === 'last') {
|
||||
return `${baseClasses} text-primary !cursor-pointer hover:!no-underline`;
|
||||
}
|
||||
|
||||
// Regular clickable state
|
||||
return `${baseClasses} text-base-content/60`;
|
||||
};
|
||||
|
||||
const renderItem = (
|
||||
item: BreadcrumbItem,
|
||||
position: 'first' | 'middle' | 'last' = 'middle'
|
||||
) => {
|
||||
const styles = getItemStyles(item, position);
|
||||
|
||||
// Disabled items
|
||||
if (item.isDisabled) {
|
||||
return (
|
||||
<span className={styles}>
|
||||
{item.icon && item.icon}
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Active/Last items
|
||||
if (item.isActive || position === 'last') {
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link href={item.href} className={styles}>
|
||||
{item.icon && (
|
||||
<span className='inline-flex gap-2'>{item.icon}</span>
|
||||
)}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles}>
|
||||
{item.icon && item.icon}
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular items
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link href={item.href} className={styles}>
|
||||
{item.icon && <span className='inline-flex gap-2'>{item.icon}</span>}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles}>
|
||||
{item.icon && item.icon}
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBreadcrumbList = () => {
|
||||
// Show all items if within limit
|
||||
if (items.length <= maxVisibleItems) {
|
||||
return items.map((item, index) => {
|
||||
const position =
|
||||
index === 0
|
||||
? 'first'
|
||||
: index === items.length - 1
|
||||
? 'last'
|
||||
: 'middle';
|
||||
return <li key={index}>{renderItem(item, position)}</li>;
|
||||
});
|
||||
}
|
||||
|
||||
// Collapsed items indexing when exceeding limit
|
||||
const firstItem = items[0];
|
||||
const lastItem = items[items.length - 1];
|
||||
const visibleMiddleItems = items.slice(1, -1).slice(-(maxVisibleItems - 2));
|
||||
const hiddenItems = items.slice(1, -1).slice(0, -(maxVisibleItems - 2));
|
||||
const showEllipsis = showEllipsisDropdown && hiddenItems.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<li>{renderItem(firstItem, 'first')}</li>
|
||||
|
||||
{/* Ellipsis for hidden items with dropdown */}
|
||||
{showEllipsis && <EllipsisDropdown hiddenItems={hiddenItems} />}
|
||||
|
||||
{/* Middle items */}
|
||||
{visibleMiddleItems.map((item, index) => (
|
||||
<li key={`middle-${index}`}>{renderItem(item, 'middle')}</li>
|
||||
))}
|
||||
|
||||
<li>{renderItem(lastItem, 'last')}</li>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label='Breadcrumb'
|
||||
className={cn('breadcrumbs', sizeClasses[size], className)}
|
||||
{...props}
|
||||
>
|
||||
<ul className='text-sm'>{renderBreadcrumbList()}</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumb;
|
||||
Reference in New Issue
Block a user