diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 4b0263308..2f9f94b16 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -3,6 +3,7 @@ import { Amount, Avatar, AvatarGroup, + Breadcrumb, Button, Calendar, Callout, diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 53e48e8d2..b59bbf2c0 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -18,6 +18,7 @@ import { BellIcon, FilterIcon, OrganizationIcon, + ShoppingBagFilledIcon, SidebarIcon } from '@raystack/apsara/icons'; import dayjs from 'dayjs'; @@ -38,6 +39,7 @@ export default function Demo(props: DemoProps) { ...Apsara, BellIcon, FilterIcon, + ShoppingBagFilledIcon, OrganizationIcon, SidebarIcon, DataTableDemo, diff --git a/apps/www/src/components/playground/breadcrumb-examples.tsx b/apps/www/src/components/playground/breadcrumb-examples.tsx index 86e4c8f7c..4dd76624a 100644 --- a/apps/www/src/components/playground/breadcrumb-examples.tsx +++ b/apps/www/src/components/playground/breadcrumb-examples.tsx @@ -13,13 +13,13 @@ export function BreadcrumbExamples() { { console.log('Clothes'); } }, { - label: 'Electronics', + children: 'Electronics', onClick: () => { console.log('Electronics'); } diff --git a/apps/www/src/content/docs/components/breadcrumb/demo.ts b/apps/www/src/content/docs/components/breadcrumb/demo.ts index e2d5948d8..c9eaee7de 100644 --- a/apps/www/src/content/docs/components/breadcrumb/demo.ts +++ b/apps/www/src/content/docs/components/breadcrumb/demo.ts @@ -26,23 +26,30 @@ export const playground = { export const sizeDemo = { type: 'code', - code: ` - - - Home - - Products - - Shoes - - - Home - - Products - - Shoes - - ` + tabs: [ + { + name: 'Small', + code: ` + + Home + + Products + + Shoes + ` + }, + { + name: 'Medium', + code: ` + + Home + + Products + + Shoes + ` + } + ] }; export const separatorDemo = { @@ -87,13 +94,30 @@ export const dropdownDemo = { Category {console.log('Option 1')}}, - { label: 'Option 2', onClick: () => {console.log('Option 2')}} + { children: 'Option 1', onClick: () => {console.log('Option 1')}}, + { children: 'Option 2', onClick: () => {console.log('Option 2')}} ]}>Subcategory Current Page ` }; + +export const dropdownLinksDemo = { + type: 'code', + code: ` + + Home + + }, + { children: 'Clothing', render: }, + { children: 'Books', onClick: () => {console.log('Books')}} + ]}>Categories + + Current + ` +}; + export const asDemo = { type: 'code', code: ` @@ -106,29 +130,63 @@ export const asDemo = { ` }; +export const disabledDemo = { + type: 'code', + code: ` + + Home + + Loading… + + Products + ` +}; + export const iconsDemo = { type: 'code', tabs: [ { - name: 'Text with Icon', + name: 'Leading Icon', + code: ` + + }>Home + + }>Documents + + }>Settings + ` + }, + { + name: 'Trailing Icon', + code: ` + + }>Home + + }>Documents + + }>Settings + ` + }, + { + name: 'Both Icons', code: ` - H}>Home + } trailingIcon={}>Home - D}>Documents + } trailingIcon={}>Documents - S}>Settings + } trailingIcon={}>Settings ` }, { name: 'Only Icon', code: ` - H}/> + }/> - D}/> + }/> - S}/> + }/> ` } ] diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx index 63a53ae4f..c5f22a2e3 100644 --- a/apps/www/src/content/docs/components/breadcrumb/index.mdx +++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx @@ -11,7 +11,9 @@ import { iconsDemo, ellipsisDemo, dropdownDemo, + dropdownLinksDemo, asDemo, + disabledDemo, } from "./demo.ts"; @@ -42,7 +44,25 @@ Groups all parts of the breadcrumb navigation. ### Item -Renders an individual breadcrumb link. Ref is forwarded to the rendered element when using `render` (not when using `dropdownItems`). +Renders an individual breadcrumb link. Use the `current` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`). Use the `disabled` prop for non-clickable, visually muted items (e.g. loading or no access). + +Item elements expose data attributes for CSS state targeting so you can style current and disabled states without relying on internal class names: + +| Attribute | When present | +|-----------|----------------| +| `data-current="true"` | Item is the current page (`current` prop) | +| `data-disabled="true"` | Item is disabled (`disabled` prop) | + +Example: + +```css +[data-current="true"] { + color: var(--my-brand); +} +[data-disabled="true"] { + opacity: 0.6; +} +``` @@ -80,17 +100,18 @@ Use the `Breadcrumb.Ellipsis` component to truncate the breadcrumb trail when yo ### Icons -Breadcrumb items can include icons either alongside text or as standalone elements. +Breadcrumb items can include icons via `leadingIcon` (before the label) or `trailingIcon` (after the label), either alongside text or as standalone elements. ### Dropdown -Breadcrumb items can include dropdown menus for additional navigation options. Specify the dropdown items using the `dropdownItems` prop. +Breadcrumb items can include dropdown menus for additional navigation options. Specify them with the `dropdownItems` prop: each entry is the same props as `` (e.g. `children` for the label, `onClick`, `disabled`, `render` for a link such as ``, etc.). You can also pass `key` for stable list keys. **Note:** When `dropdownItems` is provided, the `render` and `href` props are ignored. + ### Render @@ -103,8 +124,15 @@ Use the `render` prop to render the breadcrumb item as a custom component. Pass +### Disabled + +Use the `disabled` prop for non-clickable, visually muted items—for example, loading states or segments the user does not have access to. Disabled items render as a span with `aria-disabled="true"` and do not navigate. + + + ## Accessibility - Uses `nav` element with `aria-label="Breadcrumb"` for proper landmark identification - Current page is indicated with `aria-current="page"` -- Separator elements are hidden from screen readers with `aria-hidden` +- Disabled items use `aria-disabled="true"` +- Separator elements are decorative and use `role="presentation"` and `aria-hidden="true"` so screen readers skip them diff --git a/apps/www/src/content/docs/components/breadcrumb/props.ts b/apps/www/src/content/docs/components/breadcrumb/props.ts index ba334786f..35a817508 100644 --- a/apps/www/src/content/docs/components/breadcrumb/props.ts +++ b/apps/www/src/content/docs/components/breadcrumb/props.ts @@ -1,4 +1,4 @@ -import { ReactElement, ReactEventHandler, ReactNode } from 'react'; +import { ReactElement, ReactNode } from 'react'; export interface BreadcrumbItem { /** Text to display for the item */ @@ -7,9 +7,12 @@ export interface BreadcrumbItem { /** URL for the item link */ href?: string; - /** Optional icon element to display */ + /** Optional icon element to display before the label */ leadingIcon?: ReactNode; + /** Optional icon element to display after the label */ + trailingIcon?: ReactNode; + /** * Whether the item is the current page * @defaultValue false @@ -17,18 +20,22 @@ export interface BreadcrumbItem { current?: boolean; /** - * Optional array of dropdown items + * When true, the item is non-clickable and visually muted (e.g. loading or no access). + * @defaultValue false + */ + disabled?: boolean; + + /** + * Optional array of dropdown entries; each object is passed to `` + * (e.g. `children`, `onClick`, `render` for a link, etc.), plus optional `key` + * for React list reconciliation (not forwarded to `Menu.Item`). * * When `dropdownItems` is provided, the `render` and `href` props are ignored. */ - dropdownItems?: { - /** Optional stable key for list reconciliation. Falls back to index if omitted. */ + dropdownItems?: (Record & { key?: string; - /** Text to display for the dropdown item */ - label: string; - /** Callback function when a dropdown item is clicked */ - onClick?: ReactEventHandler; - }[]; + children?: ReactNode; + })[]; /** * Render prop for polymorphism (Base UI `useRender`). @@ -37,6 +44,9 @@ export interface BreadcrumbItem { * Example: `render={}` */ render?: ReactElement; + + /** Custom CSS class name applied to the list item wrapper */ + className?: string; } export interface BreadcrumbProps { diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx index 9f4282f1f..a016c01e3 100644 --- a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx +++ b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx @@ -129,7 +129,56 @@ describe('Breadcrumb', () => { expect(screen.getByText('Home')).toBeInTheDocument(); }); - it('applies current/active state', () => { + it('renders with trailing icon', () => { + render( + + ▶} + > + Next + + + ); + + const icon = screen.getByTestId('trailing-icon'); + expect(icon).toBeInTheDocument(); + expect(icon.parentElement).toHaveClass(styles['breadcrumb-icon']); + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + it('renders with both leading and trailing icons', () => { + const { container } = render( + + L} + trailingIcon={T} + > + Label + + + ); + + const leading = screen.getByTestId('leading'); + const trailing = screen.getByTestId('trailing'); + const label = screen.getByText('Label'); + + expect(leading).toBeInTheDocument(); + expect(trailing).toBeInTheDocument(); + expect(label).toBeInTheDocument(); + expect(leading.parentElement).toHaveClass(styles['breadcrumb-icon']); + expect(trailing.parentElement).toHaveClass(styles['breadcrumb-icon']); + + const link = container.querySelector(`.${styles['breadcrumb-link']}`); + const iconWrappers = link?.querySelectorAll( + `.${styles['breadcrumb-icon']}` + ); + expect(iconWrappers).toHaveLength(2); + expect(iconWrappers?.[0]).toContainElement(leading); + expect(iconWrappers?.[1]).toContainElement(trailing); + expect(link?.textContent).toMatch(/L\s*Label\s*T/); + }); + + it('applies current/active state and renders as span with aria-current', () => { const { container } = render( Current Page @@ -142,6 +191,11 @@ describe('Breadcrumb', () => { ); expect(currentEl).toBeInTheDocument(); expect(currentEl).toHaveAttribute('aria-current', 'page'); + expect(currentEl).toHaveClass(styles['breadcrumb-link']); + expect(currentEl).toHaveClass(styles['breadcrumb-link-active']); + expect(currentEl).toHaveAttribute('data-current', 'true'); + expect(currentEl).toHaveTextContent('Current Page'); + expect(container.querySelector('a')).not.toBeInTheDocument(); }); it('renders with custom element using render prop', () => { @@ -220,13 +274,72 @@ describe('Breadcrumb', () => { expect(link).toHaveAttribute('aria-label', 'Products'); expect(link).toHaveAttribute('data-testid', 'item'); }); + + it('renders as span with disabled styles when disabled', () => { + const { container } = render( + + Loading… + + ); + + const link = container.querySelector('a'); + expect(link).not.toBeInTheDocument(); + + const span = container.querySelector( + `span.${styles['breadcrumb-link-disabled']}` + ); + expect(span).toBeInTheDocument(); + expect(span).toHaveClass(styles['breadcrumb-link']); + expect(span).toHaveClass(styles['breadcrumb-link-disabled']); + expect(span).toHaveAttribute('aria-disabled', 'true'); + expect(span).toHaveAttribute('data-disabled', 'true'); + expect(span).toHaveTextContent('Loading…'); + }); + + it('disabled item has no href and is not focusable as link', () => { + const { container } = render( + + + No access + + + ); + + const span = container.querySelector( + `span.${styles['breadcrumb-link-disabled']}` + ); + expect(span).toBeInTheDocument(); + expect(container.querySelector('a')).not.toBeInTheDocument(); + }); + + it('disabled with dropdownItems renders as disabled span not dropdown', () => { + const items = [ + { children: 'Option 1', onClick: vi.fn() }, + { children: 'Option 2', onClick: vi.fn() } + ]; + const { container } = render( + + + Categories + + + ); + + const span = container.querySelector( + `span.${styles['breadcrumb-link-disabled']}` + ); + expect(span).toBeInTheDocument(); + expect(span).toHaveTextContent('Categories'); + fireEvent.click(span!); + expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); + }); }); describe('BreadcrumbItem with Dropdown', () => { it('renders dropdown trigger when dropdownItems provided', () => { const items = [ - { label: 'Option 1', onClick: vi.fn() }, - { label: 'Option 2', onClick: vi.fn() } + { children: 'Option 1', onClick: vi.fn() }, + { children: 'Option 2', onClick: vi.fn() } ]; render( @@ -244,9 +357,9 @@ describe('Breadcrumb', () => { it('renders dropdown items on click', () => { const items = [ - { label: 'Electronics' }, - { label: 'Clothing' }, - { label: 'Books' } + { children: 'Electronics' }, + { children: 'Clothing' }, + { children: 'Books' } ]; render( @@ -263,6 +376,41 @@ describe('Breadcrumb', () => { expect(screen.getByText('Clothing')).toBeInTheDocument(); expect(screen.getByText('Books')).toBeInTheDocument(); }); + + it('renders dropdown items with href as links', () => { + render( + + + ) + }, + { + children: 'Same tab', + render: + } + ]} + > + Categories + + + ); + + fireEvent.click(screen.getByText('Categories')); + + const newTabLink = screen.getByText('New tab'); + expect(newTabLink.tagName).toBe('A'); + expect(newTabLink).toHaveAttribute('href', '/page'); + expect(newTabLink).toHaveAttribute('target', '_blank'); + expect(newTabLink).toHaveAttribute('rel', 'noopener noreferrer'); + + const sameTabLink = screen.getByText('Same tab'); + expect(sameTabLink.tagName).toBe('A'); + expect(sameTabLink).toHaveAttribute('href', '/other'); + }); }); describe('BreadcrumbSeparator', () => { @@ -311,6 +459,22 @@ describe('Breadcrumb', () => { ); expect(ref).toHaveBeenCalled(); }); + + it('has role="presentation" and aria-hidden="true" for screen readers', () => { + const { container } = render( + + Home + + Products + + ); + + const separator = container.querySelector( + `.${styles['breadcrumb-separator']}` + ); + expect(separator).toHaveAttribute('role', 'presentation'); + expect(separator).toHaveAttribute('aria-hidden', 'true'); + }); }); describe('BreadcrumbEllipsis', () => { @@ -417,9 +581,9 @@ describe('Breadcrumb', () => { it('renders breadcrumb with icons and dropdown', () => { const categories = [ - { label: 'Electronics' }, - { label: 'Clothing' }, - { label: 'Books' } + { children: 'Electronics' }, + { children: 'Clothing' }, + { children: 'Books' } ]; render( diff --git a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx index cac155543..2f8d0b925 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx @@ -3,19 +3,25 @@ import { mergeProps, useRender } from '@base-ui/react'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { cx } from 'class-variance-authority'; -import React, { ReactNode } from 'react'; +import React, { ComponentProps, ReactNode } from 'react'; import { Menu } from '../menu'; import styles from './breadcrumb.module.css'; -export interface BreadcrumbDropdownItem { +/** + * Each entry maps to ``. Use `children`, `render`, `onClick`, + * `disabled`, etc. - whatever `Menu.Item` supports. + */ +export type BreadcrumbDropdownItem = ComponentProps & { + /** Optional stable key for React list reconciliation (not passed to `Menu.Item`). */ key?: string; - label: string; - onClick?: React.MouseEventHandler; -} +}; export interface BreadcrumbItemProps extends useRender.ComponentProps<'a'> { leadingIcon?: ReactNode; + trailingIcon?: ReactNode; current?: boolean; + /** When true, the item is non-clickable and visually muted (e.g. loading or no access). */ + disabled?: boolean; dropdownItems?: BreadcrumbDropdownItem[]; } @@ -25,19 +31,27 @@ export const BreadcrumbItem = ({ children, className, leadingIcon, + trailingIcon, current, + disabled, href, dropdownItems, ...props }: BreadcrumbItemProps) => { - const label = leadingIcon ? ( - <> - {leadingIcon} - {children != null && {children}} - - ) : ( - children - ); + const label = + leadingIcon || trailingIcon ? ( + <> + {leadingIcon && ( + {leadingIcon} + )} + {children != null && {children}} + {trailingIcon && ( + {trailingIcon} + )} + + ) : ( + children + ); const { id, @@ -61,7 +75,7 @@ export const BreadcrumbItem = ({ ) }); - if (dropdownItems) { + if (dropdownItems && !disabled) { return (
  • @@ -78,37 +92,51 @@ export const BreadcrumbItem = ({ - {dropdownItems.map((dropdownItem, dropdownIndex) => ( - - {dropdownItem.label} - - ))} + {dropdownItems.map((dropdownItem, dropdownIndex) => { + const { + key, + className: itemClassName, + ...menuItemProps + } = dropdownItem; + return ( + + ); + })}
  • ); } - if (current) { + + if (disabled || current) { return (
  • } className={cx( styles['breadcrumb-link'], - styles['breadcrumb-link-active'] + disabled && styles['breadcrumb-link-disabled'], + current && styles['breadcrumb-link-active'] )} - aria-current='page' - {...props} + {...(disabled && { + 'aria-disabled': 'true', + 'data-disabled': 'true' + })} + {...(current && { 'aria-current': 'page', 'data-current': 'true' })} > {label}
  • ); } + return (
  • {linkElement}
  • ); diff --git a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx index 48f72a1a2..e599b9404 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx @@ -49,6 +49,8 @@ export const BreadcrumbSeparator = ({