ADD new frontend
This commit is contained in:
337
frontend/src/components/layout/Breadcrumbs/Breadcrumbs.tsx
Normal file
337
frontend/src/components/layout/Breadcrumbs/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { getBreadcrumbs, getRouteByPath } from '../../../router/routes.config';
|
||||
import {
|
||||
ChevronRight,
|
||||
Home,
|
||||
MoreHorizontal,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
path: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
disabled?: boolean;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface BreadcrumbsProps {
|
||||
className?: string;
|
||||
/**
|
||||
* Custom breadcrumb items (overrides auto-generated ones)
|
||||
*/
|
||||
items?: BreadcrumbItem[];
|
||||
/**
|
||||
* Show home icon on first item
|
||||
*/
|
||||
showHome?: boolean;
|
||||
/**
|
||||
* Home path (defaults to '/')
|
||||
*/
|
||||
homePath?: string;
|
||||
/**
|
||||
* Home label
|
||||
*/
|
||||
homeLabel?: string;
|
||||
/**
|
||||
* Custom separator component
|
||||
*/
|
||||
separator?: React.ReactNode;
|
||||
/**
|
||||
* Maximum number of items to show before truncating
|
||||
*/
|
||||
maxItems?: number;
|
||||
/**
|
||||
* Show truncation in the middle (true) or at the beginning (false)
|
||||
*/
|
||||
truncateMiddle?: boolean;
|
||||
/**
|
||||
* Custom truncation component
|
||||
*/
|
||||
truncationComponent?: React.ReactNode;
|
||||
/**
|
||||
* Custom link component for external handling
|
||||
*/
|
||||
linkComponent?: React.ComponentType<{
|
||||
to: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
external?: boolean;
|
||||
}>;
|
||||
/**
|
||||
* Hide breadcrumbs for certain paths
|
||||
*/
|
||||
hiddenPaths?: string[];
|
||||
/**
|
||||
* Custom item renderer
|
||||
*/
|
||||
itemRenderer?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface BreadcrumbsRef {
|
||||
scrollToEnd: () => void;
|
||||
getCurrentPath: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumbs - Breadcrumb navigation component with links and separators
|
||||
*
|
||||
* Features:
|
||||
* - Auto-generate breadcrumbs from current route path
|
||||
* - Clickable navigation links with proper hover states
|
||||
* - Custom separators with good visual hierarchy
|
||||
* - Truncation for long paths with smart middle/start truncation
|
||||
* - Home icon and custom icons support
|
||||
* - External link handling with indicators
|
||||
* - Keyboard navigation support
|
||||
* - Responsive design with mobile adaptations
|
||||
* - Custom renderers for advanced use cases
|
||||
*/
|
||||
export const Breadcrumbs = forwardRef<BreadcrumbsRef, BreadcrumbsProps>(({
|
||||
className,
|
||||
items: customItems,
|
||||
showHome = true,
|
||||
homePath = '/',
|
||||
homeLabel = 'Inicio',
|
||||
separator,
|
||||
maxItems = 5,
|
||||
truncateMiddle = true,
|
||||
truncationComponent,
|
||||
linkComponent: CustomLink,
|
||||
hiddenPaths = [],
|
||||
itemRenderer,
|
||||
}, ref) => {
|
||||
const location = useLocation();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get breadcrumbs from router config or use custom items
|
||||
const routeBreadcrumbs = getBreadcrumbs(location.pathname);
|
||||
|
||||
const generateItems = (): BreadcrumbItem[] => {
|
||||
if (customItems) {
|
||||
return customItems;
|
||||
}
|
||||
|
||||
const items: BreadcrumbItem[] = [];
|
||||
|
||||
// Add home if enabled
|
||||
if (showHome && location.pathname !== homePath) {
|
||||
items.push({
|
||||
label: homeLabel,
|
||||
path: homePath,
|
||||
icon: Home,
|
||||
});
|
||||
}
|
||||
|
||||
// Add route breadcrumbs
|
||||
routeBreadcrumbs.forEach(route => {
|
||||
// Use breadcrumbTitle if available, otherwise use title
|
||||
const label = route.meta?.breadcrumbTitle || route.title;
|
||||
|
||||
items.push({
|
||||
label,
|
||||
path: route.path,
|
||||
disabled: route.path === location.pathname, // Current page is disabled
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const allItems = generateItems();
|
||||
|
||||
// Filter out hidden paths
|
||||
const visibleItems = allItems.filter(item => !hiddenPaths.includes(item.path));
|
||||
|
||||
// Handle truncation
|
||||
const getTruncatedItems = (): BreadcrumbItem[] => {
|
||||
if (visibleItems.length <= maxItems) {
|
||||
return visibleItems;
|
||||
}
|
||||
|
||||
if (truncateMiddle && visibleItems.length > 3) {
|
||||
// Keep first, last, and some middle items
|
||||
const first = visibleItems[0];
|
||||
const last = visibleItems[visibleItems.length - 1];
|
||||
const secondToLast = visibleItems[visibleItems.length - 2];
|
||||
|
||||
return [first, { label: '...', path: '', disabled: true }, secondToLast, last];
|
||||
} else {
|
||||
// Keep last maxItems
|
||||
return [
|
||||
{ label: '...', path: '', disabled: true },
|
||||
...visibleItems.slice(-(maxItems - 1))
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const displayItems = getTruncatedItems();
|
||||
|
||||
// Scroll to end
|
||||
const scrollToEnd = React.useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTo({
|
||||
left: containerRef.current.scrollWidth,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get current path
|
||||
const getCurrentPath = React.useCallback(() => {
|
||||
return location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
// Expose ref methods
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToEnd,
|
||||
getCurrentPath,
|
||||
}), [scrollToEnd, getCurrentPath]);
|
||||
|
||||
// Auto-scroll to end when items change
|
||||
React.useEffect(() => {
|
||||
scrollToEnd();
|
||||
}, [scrollToEnd, displayItems.length]);
|
||||
|
||||
// Default separator
|
||||
const defaultSeparator = (
|
||||
<ChevronRight className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
);
|
||||
|
||||
// Default truncation component
|
||||
const defaultTruncationComponent = (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-[var(--text-tertiary)]">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Default link component
|
||||
const DefaultLink: React.FC<{
|
||||
to: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
external?: boolean;
|
||||
}> = ({ to, className, children, external }) => {
|
||||
if (external) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={to} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkComponent = CustomLink || DefaultLink;
|
||||
|
||||
// Render single item
|
||||
const renderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
|
||||
if (itemRenderer) {
|
||||
return itemRenderer(item, index, isLast);
|
||||
}
|
||||
|
||||
const ItemIcon = item.icon;
|
||||
|
||||
// Handle truncation item
|
||||
if (item.label === '...') {
|
||||
return (
|
||||
<li key={`truncation-${index}`} className="flex items-center">
|
||||
{truncationComponent || defaultTruncationComponent}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{ItemIcon && (
|
||||
<ItemIcon className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
)}
|
||||
<span className={clsx(
|
||||
'truncate transition-colors duration-200',
|
||||
isLast
|
||||
? 'text-[var(--text-primary)] font-medium'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.external && (
|
||||
<ExternalLink className="w-3 h-3 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={item.path || index} className="flex items-center min-w-0">
|
||||
{item.disabled || isLast ? (
|
||||
<span className="flex items-center gap-2 px-2 py-1 min-w-0">
|
||||
{content}
|
||||
</span>
|
||||
) : (
|
||||
<LinkComponent
|
||||
to={item.path}
|
||||
external={item.external}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1 rounded-md min-w-0',
|
||||
'transition-colors duration-200',
|
||||
'hover:bg-[var(--bg-secondary)] hover:text-[var(--text-primary)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
'focus:bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</LinkComponent>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render if no items or path is hidden
|
||||
if (displayItems.length === 0 || hiddenPaths.includes(location.pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 text-sm overflow-x-auto',
|
||||
'scrollbar-none pb-1', // Hide scrollbar but allow scrolling
|
||||
className
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
role="navigation"
|
||||
>
|
||||
<ol className="flex items-center gap-1 min-w-0 flex-nowrap">
|
||||
{displayItems.map((item, index) => {
|
||||
const isLast = index === displayItems.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.path || index}>
|
||||
{renderItem(item, index, isLast)}
|
||||
|
||||
{!isLast && (
|
||||
<li className="flex items-center flex-shrink-0 mx-1" aria-hidden="true">
|
||||
{separator || defaultSeparator}
|
||||
</li>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
});
|
||||
|
||||
Breadcrumbs.displayName = 'Breadcrumbs';
|
||||
2
frontend/src/components/layout/Breadcrumbs/index.ts
Normal file
2
frontend/src/components/layout/Breadcrumbs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Breadcrumbs } from './Breadcrumbs';
|
||||
export type { BreadcrumbsProps, BreadcrumbsRef, BreadcrumbItem } from './Breadcrumbs';
|
||||
Reference in New Issue
Block a user