Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/breadcrumb-aria-current.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] Breadcrumbs: auto-detected current breadcrumb now places `aria-current="page"` on the item's content element (link/button/span), matching the explicit `isCurrent` path, instead of on the outer `<li>`. When the last breadcrumb is a link, the anchor itself now carries `aria-current` so screen readers announce it as the current page (#3343).
@cixzhang
25 changes: 20 additions & 5 deletions packages/core/src/Breadcrumbs/BreadcrumbItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,21 @@ export function BreadcrumbItem({
const LinkComponent = useLinkComponent(as);
const isSupporting = ctx.variant === 'supporting';
const liRef = useRef<HTMLLIElement>(null);
// Points at the element we render to hold the item's content (the
// link/button/span in the auto-candidate path). Auto-current detection sets
// aria-current on this instead of guessing the <li>'s last child.
const contentRef = useRef<HTMLElement>(null);

const isCurrent = isCurrentProp === true;
const isAutoCandidate = isCurrentProp == null;

// Auto-detect: if no sibling has aria-current="page" and this is the last
// non-separator item, set aria-current on our content element.
// Runs as useEffect (not layout) — only sets an aria attribute, no visual change.
// Placed on the item's content element (the link/button/span after the
// separator), matching where the explicit `isCurrent` path sets it, so
// aria-current lands on the actual interactive element — including when the
// last item is a link — rather than on the outer <li> (navigation-11).
useEffect(() => {
if (!isAutoCandidate) {
return;
Expand All @@ -211,12 +219,16 @@ export function BreadcrumbItem({
const hasExplicit = ol.querySelector('[aria-current="page"]');

if (isLast && !hasExplicit) {
li.setAttribute('aria-current', 'page');
// We control the element that holds the content (see the auto-candidate
// render path below), so set aria-current on that ref rather than
// assuming a positional last child. Fall back to the <li> only if the
// ref is somehow unresolved.
const target = contentRef.current ?? li;
target.setAttribute('aria-current', 'page');
return () => {
target.removeAttribute('aria-current');
};
}

return () => {
li.removeAttribute('aria-current');
};
});

const content = (
Expand Down Expand Up @@ -280,6 +292,7 @@ export function BreadcrumbItem({
</span>
{href != null ? (
<LinkComponent
ref={contentRef}
href={href}
onClick={onClick}
{...stylex.props(
Expand All @@ -290,6 +303,7 @@ export function BreadcrumbItem({
</LinkComponent>
) : onClick != null ? (
<button
ref={contentRef as React.RefObject<HTMLButtonElement | null>}
type="button"
onClick={onClick}
{...stylex.props(
Expand All @@ -301,6 +315,7 @@ export function BreadcrumbItem({
</button>
) : (
<span
ref={contentRef}
{...stylex.props(
itemStyles.contentWrapper,
itemStyles.current,
Expand Down
41 changes: 31 additions & 10 deletions packages/core/src/Breadcrumbs/Breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,31 @@ describe('BreadcrumbItem', () => {
<BreadcrumbItem>Last Item</BreadcrumbItem>
</Breadcrumbs>,
);
// aria-current is set via useEffect, so we need to wait for it
const lastLi = screen.getByText('Last Item').closest('li')!;
// aria-current is set via useEffect on the content element (matching the
// explicit isCurrent path), not the outer <li>.
const lastContent = screen.getByText('Last Item');
await waitFor(() => {
expect(lastLi).toHaveAttribute('aria-current', 'page');
expect(lastContent).toHaveAttribute('aria-current', 'page');
});
expect(screen.getByText('Last Item').tagName).toBe('SPAN');
expect(lastContent.tagName).toBe('SPAN');
// The <li> wrapper must NOT carry aria-current.
expect(lastContent.closest('li')).not.toHaveAttribute('aria-current');
});

it('auto-detects aria-current on the anchor when the last item is a link', async () => {
render(
<Breadcrumbs>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem href="/projects/current">Current</BreadcrumbItem>
</Breadcrumbs>,
);
const lastLink = screen.getByText('Current');
await waitFor(() => {
expect(lastLink).toHaveAttribute('aria-current', 'page');
});
// aria-current is on the anchor itself, not the <li>.
expect(lastLink.tagName).toBe('A');
expect(lastLink.closest('li')).not.toHaveAttribute('aria-current');
});

it('does not auto-detect when isCurrent is explicitly set', async () => {
Expand Down Expand Up @@ -270,11 +289,12 @@ describe('BreadcrumbItem', () => {
<BreadcrumbItem>Only Item</BreadcrumbItem>
</Breadcrumbs>,
);
const li = screen.getByText('Only Item').closest('li')!;
const content = screen.getByText('Only Item');
await waitFor(() => {
expect(li).toHaveAttribute('aria-current', 'page');
expect(content).toHaveAttribute('aria-current', 'page');
});
expect(screen.getByText('Only Item').tagName).toBe('SPAN');
expect(content.tagName).toBe('SPAN');
expect(content.closest('li')).not.toHaveAttribute('aria-current');
});

it('auto-detects last child as current with supporting variant', async () => {
Expand All @@ -284,11 +304,12 @@ describe('BreadcrumbItem', () => {
<BreadcrumbItem>Last</BreadcrumbItem>
</Breadcrumbs>,
);
const li = screen.getByText('Last').closest('li')!;
const content = screen.getByText('Last');
await waitFor(() => {
expect(li).toHaveAttribute('aria-current', 'page');
expect(content).toHaveAttribute('aria-current', 'page');
});
expect(screen.getByText('Last').tagName).toBe('SPAN');
expect(content.tagName).toBe('SPAN');
expect(content.closest('li')).not.toHaveAttribute('aria-current');
});

it('renders custom component for non-current items when as is provided', () => {
Expand Down
Loading