Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slow-sides-feel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Breadcrumbs: Add `visibleItemsOnNarrow` prop to control how many breadcrumb items are shown on narrow viewports (defaults to showing only the previous item as a back link).
28 changes: 28 additions & 0 deletions packages/react/src/Breadcrumbs/Breadcrumbs.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,31 @@ export const SpaciousVariantWithOverflowWrap = () => (
</Breadcrumbs.Item>
</Breadcrumbs>
)

export const NarrowVisibleItemsDefault = () => (
<Breadcrumbs>
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Products</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Category</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Subcategory</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Item</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Details</Breadcrumbs.Item>
<Breadcrumbs.Item href="#" selected>
Current Page
</Breadcrumbs.Item>
</Breadcrumbs>
)

export const NarrowVisibleItemsCustom = () => (
<Breadcrumbs visibleItemsOnNarrow={3}>
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Products</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Category</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Subcategory</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Item</Breadcrumbs.Item>
<Breadcrumbs.Item href="#">Details</Breadcrumbs.Item>
<Breadcrumbs.Item href="#" selected>
Current Page
</Breadcrumbs.Item>
</Breadcrumbs>
)
14 changes: 14 additions & 0 deletions packages/react/src/Breadcrumbs/Breadcrumbs.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,17 @@
}
}
}

/* On narrow viewports, hide breadcrumb items marked for narrow hiding */
@media screen and (max-width: 543.98px) {
.ItemWrapper[data-narrow-hidden] {
display: none;
}

/* Hide the trailing separator on the last visible item on narrow */
.ItemWrapper[data-narrow-last] {
&::after {
content: none;
}
}
}
69 changes: 64 additions & 5 deletions packages/react/src/Breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export type BreadcrumbsProps = React.PropsWithChildren<{
* Allows passing of CSS custom properties to the breadcrumbs container.
*/
style?: React.CSSProperties
/**
* The number of breadcrumb items to keep visible on narrow viewports.
* On small screens, only the N items immediately before the current page
* are shown (as back-navigation links). The current page itself is hidden.
* @default 1
*/
visibleItemsOnNarrow?: number
}>

const BreadcrumbsList = ({children}: React.PropsWithChildren) => {
Expand Down Expand Up @@ -145,10 +152,37 @@ const getValidChildren = (children: React.ReactNode) => {
return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement<any>[]
}

function Breadcrumbs({className, children, style, overflow = 'wrap', variant = 'normal'}: BreadcrumbsProps) {
function Breadcrumbs({
className,
children,
style,
overflow = 'wrap',
variant = 'normal',
visibleItemsOnNarrow = 1,
}: BreadcrumbsProps) {
const overflowMenuEnabled = useFeatureFlag('primer_react_breadcrumbs_overflow_menu')
const wrappedChildren = React.Children.map(children, child => <li className={classes.ItemWrapper}>{child}</li>)
const containerRef = useRef<HTMLElement>(null)
const childArray = useMemo(() => getValidChildren(children), [children])
const visibleCountOnNarrow = Math.max(1, Math.min(visibleItemsOnNarrow, childArray.length - 1))

const wrappedChildren = React.Children.toArray(children)
.filter(child => React.isValidElement(child))
.map((child, index, arr) => {
const applyNarrow = arr.length > 1
const isLast = index === arr.length - 1
const isVisibleOnNarrow = applyNarrow && index >= arr.length - 1 - visibleCountOnNarrow && !isLast
const isLastVisibleOnNarrow = applyNarrow && index === arr.length - 2
return (
<li
className={classes.ItemWrapper}
key={index}
data-narrow-hidden={isVisibleOnNarrow ? undefined : applyNarrow ? '' : undefined}
data-narrow-last={isLastVisibleOnNarrow ? '' : undefined}
>
{child}
</li>
)
})

const measureMenuButton = useCallback((element: HTMLDetailsElement | null) => {
if (element) {
Expand All @@ -163,7 +197,6 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = '

const hideRoot = !(overflow === 'menu-with-root')
const [effectiveHideRoot, setEffectiveHideRoot] = useState<boolean>(hideRoot)
const childArray = useMemo(() => getValidChildren(children), [children])

const rootItem = childArray[0]

Expand Down Expand Up @@ -288,7 +321,23 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = '
const finalChildren = React.useMemo(() => {
if (overflowMenuEnabled) {
if (overflow === 'wrap' || menuItems.length === 0) {
return React.Children.map(children, child => <li className={classes.ItemWrapper}>{child}</li>)
const validChildren = React.Children.toArray(children).filter(child => React.isValidElement(child))
return validChildren.map((child, index) => {
const applyNarrow = validChildren.length > 1
const isLast = index === validChildren.length - 1
const isVisibleOnNarrow = applyNarrow && index >= validChildren.length - 1 - visibleCountOnNarrow && !isLast
const isLastVisibleOnNarrow = applyNarrow && index === validChildren.length - 2
return (
<li
className={classes.ItemWrapper}
key={index}
data-narrow-hidden={isVisibleOnNarrow ? undefined : applyNarrow ? '' : undefined}
data-narrow-last={isLastVisibleOnNarrow ? '' : undefined}
>
{child}
</li>
)
})
}

let effectiveMenuItems = [...menuItems]
Expand Down Expand Up @@ -329,7 +378,17 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = '
return [rootElement, menuElement, ...visibleElements]
}
}
}, [overflowMenuEnabled, overflow, menuItems, effectiveHideRoot, measureMenuButton, visibleItems, rootItem, children])
}, [
overflowMenuEnabled,
overflow,
menuItems,
effectiveHideRoot,
measureMenuButton,
visibleItems,
rootItem,
children,
visibleCountOnNarrow,
])

return overflowMenuEnabled ? (
<nav
Expand Down
96 changes: 96 additions & 0 deletions packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -614,4 +614,100 @@ describe('Breadcrumbs', () => {
})
})
})

describe('visibleItemsOnNarrow prop', () => {
it('shows only the previous (parent) breadcrumb on narrow by default', () => {
const {container} = renderWithTheme(
<Breadcrumbs>
<Breadcrumbs.Item href="/home">Home</Breadcrumbs.Item>
<Breadcrumbs.Item href="/docs">Docs</Breadcrumbs.Item>
<Breadcrumbs.Item href="/components" selected>
Components
</Breadcrumbs.Item>
</Breadcrumbs>,
)

const items = container.querySelectorAll('li')
// Home: hidden on narrow
expect(items[0]).toHaveAttribute('data-narrow-hidden')
// Docs (previous/parent): visible on narrow
expect(items[1]).not.toHaveAttribute('data-narrow-hidden')
// Components (current page): hidden on narrow
expect(items[2]).toHaveAttribute('data-narrow-hidden')
})

it('respects custom visibleOnNarrow value', () => {
const {container} = renderWithTheme(
<Breadcrumbs visibleItemsOnNarrow={2}>
<Breadcrumbs.Item href="/home">Home</Breadcrumbs.Item>
<Breadcrumbs.Item href="/docs">Docs</Breadcrumbs.Item>
<Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item>
<Breadcrumbs.Item href="/breadcrumbs" selected>
Breadcrumbs
</Breadcrumbs.Item>
</Breadcrumbs>,
)

const items = container.querySelectorAll('li')
// Home: hidden on narrow
expect(items[0]).toHaveAttribute('data-narrow-hidden')
// Docs (2nd before last): visible on narrow
expect(items[1]).not.toHaveAttribute('data-narrow-hidden')
// Components (1st before last): visible on narrow
expect(items[2]).not.toHaveAttribute('data-narrow-hidden')
// Breadcrumbs (current page): hidden on narrow
expect(items[3]).toHaveAttribute('data-narrow-hidden')
})

it('clamps visibleOnNarrow to the number of non-current children', () => {
const {container} = renderWithTheme(
<Breadcrumbs visibleItemsOnNarrow={10}>
<Breadcrumbs.Item href="/home">Home</Breadcrumbs.Item>
<Breadcrumbs.Item href="/docs" selected>
Docs
</Breadcrumbs.Item>
</Breadcrumbs>,
)

const items = container.querySelectorAll('li')
// Home (previous): visible on narrow
expect(items[0]).not.toHaveAttribute('data-narrow-hidden')
// Docs (current page): hidden on narrow
expect(items[1]).toHaveAttribute('data-narrow-hidden')
})

it('adds data-narrow-hidden in menu mode with feature flag', () => {
const {container} = renderWithTheme(
<Breadcrumbs overflow="menu">
<Breadcrumbs.Item href="/home">Home</Breadcrumbs.Item>
<Breadcrumbs.Item href="/docs">Docs</Breadcrumbs.Item>
<Breadcrumbs.Item href="/components" selected>
Components
</Breadcrumbs.Item>
</Breadcrumbs>,
{primer_react_breadcrumbs_overflow_menu: true},
)

const items = container.querySelectorAll('li')
// Home: hidden on narrow
expect(items[0]).toHaveAttribute('data-narrow-hidden')
// Docs (previous/parent): visible on narrow
expect(items[1]).not.toHaveAttribute('data-narrow-hidden')
// Components (current page): hidden on narrow
expect(items[2]).toHaveAttribute('data-narrow-hidden')
})

it('does not hide the only item when there is a single breadcrumb', () => {
const {container} = renderWithTheme(
<Breadcrumbs>
<Breadcrumbs.Item href="/alerts">Alerts</Breadcrumbs.Item>
</Breadcrumbs>,
)

const items = container.querySelectorAll('li')
expect(items).toHaveLength(1)
// Single item should NOT be hidden on narrow
expect(items[0]).not.toHaveAttribute('data-narrow-hidden')
})
})
})
Loading