diff --git a/src/components/Breadcrumbs/Breadcrumbs.stories.tsx b/src/components/Breadcrumbs/Breadcrumbs.stories.tsx new file mode 100644 index 000000000..5f60e860c --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.stories.tsx @@ -0,0 +1,56 @@ +import { Meta, StoryObj } from '@storybook/react-vite'; +import { Breadcrumbs, BreadcrumbItem, BreadcrumbSeparator } from './Breadcrumbs'; + +const meta: Meta = { + component: Breadcrumbs, + title: 'Navigation/Breadcrumbs', + tags: ['breadcrumbs', 'autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => ( + + + Home + + + Data sources + + ClickPipes + + GCS Unordered mode with service account + + ), +}; + +export const WithIcon: Story = { + render: () => ( + + + Data sources + + + Settings + + ), +}; + +export const TwoLevels: Story = { + render: () => ( + + Home + + Current page + + ), +}; diff --git a/src/components/Breadcrumbs/Breadcrumbs.test.tsx b/src/components/Breadcrumbs/Breadcrumbs.test.tsx new file mode 100644 index 000000000..1ab5342d2 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.test.tsx @@ -0,0 +1,51 @@ +import { Breadcrumbs, BreadcrumbItem, BreadcrumbSeparator } from './Breadcrumbs'; +import { renderCUI } from '@/utils/test-utils'; + +describe('Breadcrumbs', () => { + const renderBreadcrumbs = () => + renderCUI( + + + Home + + + Data sources + + Current page + + ); + + it('should render all breadcrumb items', () => { + const { getByText } = renderBreadcrumbs(); + expect(getByText('Home')).toBeInTheDocument(); + expect(getByText('Data sources')).toBeInTheDocument(); + expect(getByText('Current page')).toBeInTheDocument(); + }); + + it('should have a navigation landmark with accessible label', () => { + const { getByRole } = renderBreadcrumbs(); + const nav = getByRole('navigation'); + expect(nav).toHaveAccessibleName('Breadcrumb'); + }); + + it('should mark the active item with aria-current="page"', () => { + const { getByText } = renderBreadcrumbs(); + const activeItem = getByText('Current page').closest('li'); + expect(activeItem).toHaveAttribute('aria-current', 'page'); + }); + + it('should render links for non-active items with href', () => { + const { getByText } = renderBreadcrumbs(); + const link = getByText('Data sources').closest('a'); + expect(link).toHaveAttribute('href', '#'); + }); + + it('should not render a link for the active item', () => { + const { getByText } = renderBreadcrumbs(); + const activeItem = getByText('Current page'); + expect(activeItem.closest('a')).toBeNull(); + }); +}); diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 000000000..5da9fb9f7 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,129 @@ +import { styled } from 'styled-components'; +import { forwardRef, HTMLAttributes, ReactNode } from 'react'; +import { Icon } from '@/components/Icon/Icon'; +import type { IconName } from '@/components/Icon/types'; + +export interface BreadcrumbItemProps extends HTMLAttributes { + /** The text label of the breadcrumb */ + children: ReactNode; + /** Optional icon displayed before the label */ + icon?: IconName; + /** Whether this is the current/active page (last item) */ + active?: boolean; + /** Optional href — when provided, the item renders as a link */ + href?: string; +} + +export interface BreadcrumbsProps extends HTMLAttributes { + /** Breadcrumb items to render */ + children: ReactNode; +} + +const Nav = styled.nav` + display: flex; + align-items: center; +`; + +const List = styled.ol` + display: flex; + align-items: center; + list-style: none; + margin: 0; + padding: 0; +`; + +const Separator = styled.li` + display: flex; + align-items: center; + color: ${({ theme }) => theme.click.global.color.text.muted}; +`; + +const ItemWrapper = styled.li<{ $active?: boolean }>` + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + + ${({ $active, theme }) => ` + font: ${ + $active + ? theme.click.docs.typography.breadcrumbs.active + : theme.click.docs.typography.breadcrumbs.default + }; + color: ${ + $active + ? theme.click.global.color.text.default + : theme.click.global.color.text.muted + }; + `} +`; + +const StyledLink = styled.a` + && { + text-decoration: none; + color: ${({ theme }) => theme.click.global.color.text.muted}; + cursor: pointer; + } + + &&:hover { + text-decoration: underline; + } +`; + +const ItemIcon = styled(Icon)` + flex-shrink: 0; +`; + +const BreadcrumbItem = forwardRef( + ({ children, icon, active = false, href, ...props }, ref) => ( + + {icon && ( + + )} + {href && !active ? ( + {children} + ) : ( + {children} + )} + + ) +); + +BreadcrumbItem.displayName = 'Breadcrumbs.Item'; + +const BreadcrumbSeparator = () => ( + + + +); + +export const Breadcrumbs = forwardRef( + ({ children, ...props }, ref) => ( + + ) +); + +Breadcrumbs.displayName = 'Breadcrumbs'; + +export { BreadcrumbItem, BreadcrumbSeparator }; diff --git a/src/components/index.ts b/src/components/index.ts index 0978e31b7..6f1113143 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,6 +17,11 @@ export { export { AutoComplete } from '@/components/AutoComplete/AutoComplete'; export { Avatar } from '@/components/Avatar/Avatar'; export { Badge } from '@/components/Badge/Badge'; +export { + Breadcrumbs, + BreadcrumbItem, + BreadcrumbSeparator, +} from '@/components/Breadcrumbs/Breadcrumbs'; export { BigStat } from '@/components/BigStat/BigStat'; export { ButtonGroup } from '@/components/ButtonGroup/ButtonGroup'; export { Button } from '@/components/Button/Button'; diff --git a/src/components/types.ts b/src/components/types.ts index 21d89d830..6fd7c66f6 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -21,6 +21,7 @@ import { CardSecondaryProps, BadgeState } from './CardSecondary/CardSecondary'; import { ButtonProps, ButtonType } from './Button/Button'; import { ButtonGroupProps } from './ButtonGroup/ButtonGroup'; import { BadgeProps } from './Badge/Badge'; +import { BreadcrumbsProps, BreadcrumbItemProps } from './Breadcrumbs/Breadcrumbs'; import { AvatarProps } from './Avatar/Avatar'; import { AlertProps } from './Alert/Alert'; import { IconButtonProps } from './IconButton/IconButton'; @@ -88,6 +89,7 @@ export type { IconButtonProps }; export type { AlertProps }; export type { AvatarProps }; export type { BadgeProps }; +export type { BreadcrumbsProps, BreadcrumbItemProps }; export type { ButtonGroupProps }; export type { ButtonProps, ButtonType }; export type { CardSecondaryProps, BadgeState };