Skip to content

Commit

Permalink
Improve accessility of the SideNavigation's primary links (#2725)
Browse files Browse the repository at this point in the history
  • Loading branch information
connor-baer authored Oct 17, 2024
1 parent a4f2f9d commit 3830252
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/eight-lizards-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/circuit-ui': minor
---

Extended the `badge` prop of the SideNavigation's primary link props to accept an object with a custom badge color and a label for visually impaired users.
5 changes: 5 additions & 0 deletions .changeset/fluffy-ducks-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/circuit-ui': minor
---

Added an `externalLabel` prop to the SideNavigation's primary link props to describe to visually impaired users that the link leads to an external page or opens in a new tab.
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const baseArgs: SideNavigationProps = {
href: '/shop',
onClick: action('Shop'),
isActive: true,
badge: true,
badge: { variant: 'promo', label: 'New items' },
secondaryGroups: [
{
secondaryLinks: [
Expand Down Expand Up @@ -115,6 +115,7 @@ export const baseArgs: SideNavigationProps = {
href: 'https://support.example.com',
onClick: action('Support'),
target: '_blank',
externalLabel: 'Opens in a new tab',
},
],
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,37 @@
}
}

.icon-badge::after {
.badge::after {
position: absolute;
top: -8px;
right: -8px;
display: block;
width: 10px;
height: 10px;
content: "";
background-color: var(--cui-fg-promo);
border-radius: var(--cui-border-radius-circle);
}

.success::after {
background-color: var(--cui-bg-success-strong);
}

.warning::after {
background-color: var(--cui-bg-warning-strong);
}

.danger::after {
background-color: var(--cui-bg-danger-strong);
}

.neutral::after {
background-color: var(--cui-bg-highlight);
}

.promo::after {
background-color: var(--cui-bg-promo-strong);
}

.suffix {
flex-shrink: 0;
width: var(--cui-icon-sizes-kilo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ describe('PrimaryLink', () => {
expect(screen.getByRole('link')).toHaveAttribute('aria-current', 'page');
});

it('should render with a badge', () => {
renderPrimaryLink(render, {
...baseProps,
badge: { label: 'New' },
});
expect(screen.getByRole('link')).toHaveAccessibleDescription('New');
});

it('should render with an active icon', () => {
renderPrimaryLink(render, {
...baseProps,
Expand All @@ -60,7 +68,16 @@ describe('PrimaryLink', () => {
expect(screen.getByTestId('active-icon')).toBeVisible();
});

it.todo('should render with an external icon');
it('should render with an external icon', () => {
renderPrimaryLink(render, {
...baseProps,
isExternal: true,
externalLabel: 'Opens in a new tab',
});
expect(screen.getByRole('link')).toHaveAccessibleDescription(
'Opens in a new tab',
);
});

it('should render with a suffix icon', () => {
renderPrimaryLink(render, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
'use client';

import { ArrowRight } from '@sumup/icons';
import type { ComponentType } from 'react';
import { useId, type ComponentType } from 'react';

import type { AsPropType } from '../../../../types/prop-types.js';
import { useComponents } from '../../../ComponentsContext/index.js';
import { Body } from '../../../Body/index.js';
import { Skeleton } from '../../../Skeleton/index.js';
import type { PrimaryLinkProps as PrimaryLinkType } from '../../types.js';
import type {
PrimaryLinkProps as PrimaryLinkType,
PrimaryBadgeProps,
} from '../../types.js';
import { isObject } from '../../../../util/type-check.js';
import { clsx } from '../../../../styles/clsx.js';
import { utilClasses } from '../../../../styles/utility.js';

Expand All @@ -39,13 +43,24 @@ export function PrimaryLink({
label,
isActive,
isExternal,
externalLabel,
suffix: Suffix,
badge,
secondaryGroups,
className,
'aria-describedby': descriptionId,
...props
}: PrimaryLinkProps) {
const { Link } = useComponents();
const badgeLabelId = useId();
const externalLabelId = useId();

const badgeProps = getBadgeProps(badge);
const descriptionIds = clsx(
badgeProps?.label && badgeLabelId,
externalLabel && externalLabelId,
descriptionId,
);

const Element = props.href ? (Link as AsPropType) : 'button';

Expand All @@ -57,28 +72,54 @@ export function PrimaryLink({
const Icon = isActive && activeIcon ? activeIcon : icon;

return (
<Element
{...props}
className={clsx(classes.base, utilClasses.focusVisibleInset, className)}
aria-current={isActive ? 'page' : undefined}
>
<Skeleton className={clsx(classes.icon, badge && classes['icon-badge'])}>
<Icon aria-hidden="true" size="24" />
</Skeleton>
<Skeleton>
<Body as="span" className={classes.label}>
{label}
</Body>
</Skeleton>
{/* FIXME: Make this accessible to screen readers */}
{isExternalLink && (
<ArrowRight
size="16"
aria-hidden="true"
className={clsx(classes.suffix, classes['external-icon'])}
/>
<>
<Element
{...props}
className={clsx(classes.base, utilClasses.focusVisibleInset, className)}
aria-current={isActive ? 'page' : undefined}
aria-describedby={descriptionIds}
>
<Skeleton
className={clsx(
classes.icon,
badgeProps && classes.badge,
badgeProps && classes[badgeProps.variant],
)}
>
<Icon aria-hidden="true" size="24" />
</Skeleton>
<Skeleton>
<Body as="span" className={classes.label}>
{label}
</Body>
</Skeleton>
{isExternalLink && (
<ArrowRight
size="16"
aria-hidden="true"
className={clsx(classes.suffix, classes['external-icon'])}
/>
)}
{suffix}
</Element>
{badgeProps?.label && (
<span id={badgeLabelId} className={utilClasses.hideVisually}>
{badgeProps.label}
</span>
)}
{isExternalLink && externalLabel && (
<span id={externalLabelId} className={utilClasses.hideVisually}>
{externalLabel}
</span>
)}
{suffix}
</Element>
</>
);
}

function getBadgeProps(badge?: boolean | PrimaryBadgeProps) {
if (!badge) {
return null;
}
const defaultProps = { variant: 'promo', label: '' } as const;
return isObject(badge) ? { ...defaultProps, ...badge } : defaultProps;
}
19 changes: 17 additions & 2 deletions packages/circuit-ui/components/SideNavigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,35 @@ export interface PrimaryLinkProps
*/
isActive?: boolean;
/**
* Whether the link is the currently active page.
* Whether the link leads to an external page or opens in a new tab.
*/
isExternal?: boolean;
/**
* Short label to describe that the link leads to an external page or opens in a new tab.
*/
externalLabel?: string;
/**
* Whether to show a small circular badge to indicate that a nested secondary
* link has a badge.
*/
badge?: boolean;
badge?: boolean | PrimaryBadgeProps;
/**
* A collection of secondary groups with nested secondary navigation links.
*/
secondaryGroups?: SecondaryGroupProps[];
}

export type PrimaryBadgeProps = {
/**
* Choose the style variant. Default: 'promo'.
*/
variant?: BadgeProps['variant'];
/**
* A clear and concise description of the badge's meaning.
*/
label: string;
};

export interface SecondaryGroupProps {
/**
* A label that is displayed above the secondary navigation. Only optional
Expand Down

0 comments on commit 3830252

Please sign in to comment.