diff --git a/apps/www/src/components/playground/breadcrumb-examples.tsx b/apps/www/src/components/playground/breadcrumb-examples.tsx index 9d9fbb7f1..a02b4477b 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 0050e847d..9d19dec0d 100644 --- a/apps/www/src/content/docs/components/breadcrumb/demo.ts +++ b/apps/www/src/content/docs/components/breadcrumb/demo.ts @@ -94,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: ` diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx index 1d7db91ec..57333ab5e 100644 --- a/apps/www/src/content/docs/components/breadcrumb/index.mdx +++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx @@ -11,6 +11,7 @@ import { iconsDemo, ellipsisDemo, dropdownDemo, + dropdownLinksDemo, asDemo, disabledDemo, } from "./demo.ts"; @@ -45,6 +46,24 @@ Groups all parts of the breadcrumb navigation. 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; +} +``` + ### Separator @@ -87,12 +106,14 @@ Breadcrumb items can include icons via `leadingIcon` (before the label) or `trai ### 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 `as` and `href` props are ignored. +**Note:** When `dropdownItems` is provided, the `as` and `href` props on the breadcrumb item are ignored. + + ### As Use the `as` prop to render the breadcrumb item as a custom component. By default, breadcrumb items are rendered as `a` tags. diff --git a/apps/www/src/content/docs/components/breadcrumb/props.ts b/apps/www/src/content/docs/components/breadcrumb/props.ts index 0290ed8e7..075671adc 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 */ @@ -26,18 +26,16 @@ export interface BreadcrumbItem { disabled?: boolean; /** - * Optional array of dropdown items + * 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 `as` 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; + })[]; /** * Custom element used to render the Item. diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx index 647d66098..4eaa2dd7d 100644 --- a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx +++ b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx @@ -194,6 +194,7 @@ describe('Breadcrumb', () => { expect(span).toHaveClass(styles['breadcrumb-link']); expect(span).toHaveClass(styles['breadcrumb-link-active']); expect(span).toHaveAttribute('aria-current', 'page'); + expect(span).toHaveAttribute('data-current', 'true'); expect(span).toHaveTextContent('Current Page'); }); @@ -267,6 +268,7 @@ describe('Breadcrumb', () => { 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…'); }); @@ -288,8 +290,8 @@ describe('Breadcrumb', () => { it('disabled with dropdownItems renders as disabled span not dropdown', () => { 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() } ]; const { container } = render( @@ -312,8 +314,8 @@ describe('Breadcrumb', () => { 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( @@ -331,9 +333,9 @@ describe('Breadcrumb', () => { it('renders dropdown items on click', () => { const items = [ - { label: 'Electronics' }, - { label: 'Clothing' }, - { label: 'Books' } + { children: 'Electronics' }, + { children: 'Clothing' }, + { children: 'Books' } ]; render( @@ -350,6 +352,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', () => { @@ -520,9 +557,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 98c2535d1..b3bfad30e 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx @@ -11,11 +11,14 @@ import 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 ComponentProps<'a'> { leadingIcon?: ReactNode; @@ -65,15 +68,23 @@ export const BreadcrumbItem = ({ - {dropdownItems.map((dropdownItem, dropdownIndex) => ( - - {dropdownItem.label} - - ))} + {dropdownItems.map((dropdownItem, dropdownIndex) => { + const { + key, + className: itemClassName, + ...menuItemProps + } = dropdownItem; + return ( + + ); + })} ); @@ -88,8 +99,11 @@ export const BreadcrumbItem = ({ disabled && styles['breadcrumb-link-disabled'], current && styles['breadcrumb-link-active'] )} - {...(disabled && { 'aria-disabled': 'true' })} - {...(current && { 'aria-current': 'page' })} + {...(disabled && { + 'aria-disabled': 'true', + 'data-disabled': 'true' + })} + {...(current && { 'aria-current': 'page', 'data-current': 'true' })} > {label} @@ -101,11 +115,11 @@ export const BreadcrumbItem = ({ {cloneElement( renderedElement, { - ref, className: styles['breadcrumb-link'], href, ...props, - ...renderedElement.props + ...renderedElement.props, + ref }, label )} diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index ad63a33ad..47b21edc1 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -87,10 +87,23 @@ } .breadcrumb-dropdown-item { + display: block; + width: 100%; + padding: var(--rs-space-3); cursor: pointer; color: var(--rs-color-foreground-base-primary); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + text-decoration: none; + border: none; + background: none; + text-align: left; + box-sizing: border-box; } .breadcrumb-dropdown-item:hover { background-color: var(--rs-color-background-base-primary-hover); + border-radius: var(--rs-radius-2); }