Skip to content

Commit 3830252

Browse files
authored
Improve accessility of the SideNavigation's primary links (#2725)
1 parent a4f2f9d commit 3830252

File tree

7 files changed

+133
-30
lines changed

7 files changed

+133
-30
lines changed

.changeset/eight-lizards-decide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sumup/circuit-ui': minor
3+
---
4+
5+
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.

.changeset/fluffy-ducks-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sumup/circuit-ui': minor
3+
---
4+
5+
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.

packages/circuit-ui/components/SideNavigation/SideNavigation.stories.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const baseArgs: SideNavigationProps = {
5757
href: '/shop',
5858
onClick: action('Shop'),
5959
isActive: true,
60-
badge: true,
60+
badge: { variant: 'promo', label: 'New items' },
6161
secondaryGroups: [
6262
{
6363
secondaryLinks: [
@@ -115,6 +115,7 @@ export const baseArgs: SideNavigationProps = {
115115
href: 'https://support.example.com',
116116
onClick: action('Support'),
117117
target: '_blank',
118+
externalLabel: 'Opens in a new tab',
118119
},
119120
],
120121
};

packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.module.css

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,37 @@
117117
}
118118
}
119119

120-
.icon-badge::after {
120+
.badge::after {
121121
position: absolute;
122122
top: -8px;
123123
right: -8px;
124124
display: block;
125125
width: 10px;
126126
height: 10px;
127127
content: "";
128-
background-color: var(--cui-fg-promo);
129128
border-radius: var(--cui-border-radius-circle);
130129
}
131130

131+
.success::after {
132+
background-color: var(--cui-bg-success-strong);
133+
}
134+
135+
.warning::after {
136+
background-color: var(--cui-bg-warning-strong);
137+
}
138+
139+
.danger::after {
140+
background-color: var(--cui-bg-danger-strong);
141+
}
142+
143+
.neutral::after {
144+
background-color: var(--cui-bg-highlight);
145+
}
146+
147+
.promo::after {
148+
background-color: var(--cui-bg-promo-strong);
149+
}
150+
132151
.suffix {
133152
flex-shrink: 0;
134153
width: var(--cui-icon-sizes-kilo);

packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.spec.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ describe('PrimaryLink', () => {
5151
expect(screen.getByRole('link')).toHaveAttribute('aria-current', 'page');
5252
});
5353

54+
it('should render with a badge', () => {
55+
renderPrimaryLink(render, {
56+
...baseProps,
57+
badge: { label: 'New' },
58+
});
59+
expect(screen.getByRole('link')).toHaveAccessibleDescription('New');
60+
});
61+
5462
it('should render with an active icon', () => {
5563
renderPrimaryLink(render, {
5664
...baseProps,
@@ -60,7 +68,16 @@ describe('PrimaryLink', () => {
6068
expect(screen.getByTestId('active-icon')).toBeVisible();
6169
});
6270

63-
it.todo('should render with an external icon');
71+
it('should render with an external icon', () => {
72+
renderPrimaryLink(render, {
73+
...baseProps,
74+
isExternal: true,
75+
externalLabel: 'Opens in a new tab',
76+
});
77+
expect(screen.getByRole('link')).toHaveAccessibleDescription(
78+
'Opens in a new tab',
79+
);
80+
});
6481

6582
it('should render with a suffix icon', () => {
6683
renderPrimaryLink(render, {

packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.tsx

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@
1616
'use client';
1717

1818
import { ArrowRight } from '@sumup/icons';
19-
import type { ComponentType } from 'react';
19+
import { useId, type ComponentType } from 'react';
2020

2121
import type { AsPropType } from '../../../../types/prop-types.js';
2222
import { useComponents } from '../../../ComponentsContext/index.js';
2323
import { Body } from '../../../Body/index.js';
2424
import { Skeleton } from '../../../Skeleton/index.js';
25-
import type { PrimaryLinkProps as PrimaryLinkType } from '../../types.js';
25+
import type {
26+
PrimaryLinkProps as PrimaryLinkType,
27+
PrimaryBadgeProps,
28+
} from '../../types.js';
29+
import { isObject } from '../../../../util/type-check.js';
2630
import { clsx } from '../../../../styles/clsx.js';
2731
import { utilClasses } from '../../../../styles/utility.js';
2832

@@ -39,13 +43,24 @@ export function PrimaryLink({
3943
label,
4044
isActive,
4145
isExternal,
46+
externalLabel,
4247
suffix: Suffix,
4348
badge,
4449
secondaryGroups,
4550
className,
51+
'aria-describedby': descriptionId,
4652
...props
4753
}: PrimaryLinkProps) {
4854
const { Link } = useComponents();
55+
const badgeLabelId = useId();
56+
const externalLabelId = useId();
57+
58+
const badgeProps = getBadgeProps(badge);
59+
const descriptionIds = clsx(
60+
badgeProps?.label && badgeLabelId,
61+
externalLabel && externalLabelId,
62+
descriptionId,
63+
);
4964

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

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

5974
return (
60-
<Element
61-
{...props}
62-
className={clsx(classes.base, utilClasses.focusVisibleInset, className)}
63-
aria-current={isActive ? 'page' : undefined}
64-
>
65-
<Skeleton className={clsx(classes.icon, badge && classes['icon-badge'])}>
66-
<Icon aria-hidden="true" size="24" />
67-
</Skeleton>
68-
<Skeleton>
69-
<Body as="span" className={classes.label}>
70-
{label}
71-
</Body>
72-
</Skeleton>
73-
{/* FIXME: Make this accessible to screen readers */}
74-
{isExternalLink && (
75-
<ArrowRight
76-
size="16"
77-
aria-hidden="true"
78-
className={clsx(classes.suffix, classes['external-icon'])}
79-
/>
75+
<>
76+
<Element
77+
{...props}
78+
className={clsx(classes.base, utilClasses.focusVisibleInset, className)}
79+
aria-current={isActive ? 'page' : undefined}
80+
aria-describedby={descriptionIds}
81+
>
82+
<Skeleton
83+
className={clsx(
84+
classes.icon,
85+
badgeProps && classes.badge,
86+
badgeProps && classes[badgeProps.variant],
87+
)}
88+
>
89+
<Icon aria-hidden="true" size="24" />
90+
</Skeleton>
91+
<Skeleton>
92+
<Body as="span" className={classes.label}>
93+
{label}
94+
</Body>
95+
</Skeleton>
96+
{isExternalLink && (
97+
<ArrowRight
98+
size="16"
99+
aria-hidden="true"
100+
className={clsx(classes.suffix, classes['external-icon'])}
101+
/>
102+
)}
103+
{suffix}
104+
</Element>
105+
{badgeProps?.label && (
106+
<span id={badgeLabelId} className={utilClasses.hideVisually}>
107+
{badgeProps.label}
108+
</span>
109+
)}
110+
{isExternalLink && externalLabel && (
111+
<span id={externalLabelId} className={utilClasses.hideVisually}>
112+
{externalLabel}
113+
</span>
80114
)}
81-
{suffix}
82-
</Element>
115+
</>
83116
);
84117
}
118+
119+
function getBadgeProps(badge?: boolean | PrimaryBadgeProps) {
120+
if (!badge) {
121+
return null;
122+
}
123+
const defaultProps = { variant: 'promo', label: '' } as const;
124+
return isObject(badge) ? { ...defaultProps, ...badge } : defaultProps;
125+
}

packages/circuit-ui/components/SideNavigation/types.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,35 @@ export interface PrimaryLinkProps
4242
*/
4343
isActive?: boolean;
4444
/**
45-
* Whether the link is the currently active page.
45+
* Whether the link leads to an external page or opens in a new tab.
4646
*/
4747
isExternal?: boolean;
48+
/**
49+
* Short label to describe that the link leads to an external page or opens in a new tab.
50+
*/
51+
externalLabel?: string;
4852
/**
4953
* Whether to show a small circular badge to indicate that a nested secondary
5054
* link has a badge.
5155
*/
52-
badge?: boolean;
56+
badge?: boolean | PrimaryBadgeProps;
5357
/**
5458
* A collection of secondary groups with nested secondary navigation links.
5559
*/
5660
secondaryGroups?: SecondaryGroupProps[];
5761
}
5862

63+
export type PrimaryBadgeProps = {
64+
/**
65+
* Choose the style variant. Default: 'promo'.
66+
*/
67+
variant?: BadgeProps['variant'];
68+
/**
69+
* A clear and concise description of the badge's meaning.
70+
*/
71+
label: string;
72+
};
73+
5974
export interface SecondaryGroupProps {
6075
/**
6176
* A label that is displayed above the secondary navigation. Only optional

0 commit comments

Comments
 (0)