Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve accessility of the SideNavigation's primary links #2725

Merged
merged 2 commits into from
Oct 17, 2024
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
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
Loading