From 3830252cf1072972fce7866fae11a2edbee682da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Thu, 17 Oct 2024 14:06:40 +0200 Subject: [PATCH] Improve accessility of the SideNavigation's primary links (#2725) --- .changeset/eight-lizards-decide.md | 5 ++ .changeset/fluffy-ducks-roll.md | 5 ++ .../SideNavigation/SideNavigation.stories.tsx | 3 +- .../PrimaryLink/PrimaryLink.module.css | 23 ++++- .../PrimaryLink/PrimaryLink.spec.tsx | 19 +++- .../components/PrimaryLink/PrimaryLink.tsx | 89 ++++++++++++++----- .../components/SideNavigation/types.ts | 19 +++- 7 files changed, 133 insertions(+), 30 deletions(-) create mode 100644 .changeset/eight-lizards-decide.md create mode 100644 .changeset/fluffy-ducks-roll.md diff --git a/.changeset/eight-lizards-decide.md b/.changeset/eight-lizards-decide.md new file mode 100644 index 0000000000..959abef017 --- /dev/null +++ b/.changeset/eight-lizards-decide.md @@ -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. diff --git a/.changeset/fluffy-ducks-roll.md b/.changeset/fluffy-ducks-roll.md new file mode 100644 index 0000000000..9606be89e8 --- /dev/null +++ b/.changeset/fluffy-ducks-roll.md @@ -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. diff --git a/packages/circuit-ui/components/SideNavigation/SideNavigation.stories.tsx b/packages/circuit-ui/components/SideNavigation/SideNavigation.stories.tsx index af11662d9d..1e972ab9d1 100644 --- a/packages/circuit-ui/components/SideNavigation/SideNavigation.stories.tsx +++ b/packages/circuit-ui/components/SideNavigation/SideNavigation.stories.tsx @@ -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: [ @@ -115,6 +115,7 @@ export const baseArgs: SideNavigationProps = { href: 'https://support.example.com', onClick: action('Support'), target: '_blank', + externalLabel: 'Opens in a new tab', }, ], }; diff --git a/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.module.css b/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.module.css index 7bc44e48c7..b8ffd7edc3 100644 --- a/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.module.css +++ b/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.module.css @@ -117,7 +117,7 @@ } } -.icon-badge::after { +.badge::after { position: absolute; top: -8px; right: -8px; @@ -125,10 +125,29 @@ 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); diff --git a/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.spec.tsx b/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.spec.tsx index 40f0827a92..f3462b7252 100644 --- a/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.spec.tsx +++ b/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.spec.tsx @@ -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, @@ -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, { diff --git a/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.tsx b/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.tsx index 1ae4e1941c..dd596a6e46 100644 --- a/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.tsx +++ b/packages/circuit-ui/components/SideNavigation/components/PrimaryLink/PrimaryLink.tsx @@ -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'; @@ -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'; @@ -57,28 +72,54 @@ export function PrimaryLink({ const Icon = isActive && activeIcon ? activeIcon : icon; return ( - - - - - - {label} - - - {/* FIXME: Make this accessible to screen readers */} - {isExternalLink && ( - + ); } + +function getBadgeProps(badge?: boolean | PrimaryBadgeProps) { + if (!badge) { + return null; + } + const defaultProps = { variant: 'promo', label: '' } as const; + return isObject(badge) ? { ...defaultProps, ...badge } : defaultProps; +} diff --git a/packages/circuit-ui/components/SideNavigation/types.ts b/packages/circuit-ui/components/SideNavigation/types.ts index 33d09b2709..79513090c8 100644 --- a/packages/circuit-ui/components/SideNavigation/types.ts +++ b/packages/circuit-ui/components/SideNavigation/types.ts @@ -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