Skip to content

Commit ae673fb

Browse files
authored
Merge pull request #1207 from Financial-Times/BOLT-327-improve-accessibility-for-subnav-dropdown
BOLT-327 Improve accessibility of subnavigation dropdown components
2 parents 99289bb + a4d0dd1 commit ae673fb

7 files changed

Lines changed: 79 additions & 46 deletions

File tree

examples/ft-ui/__test__/subnav.test.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,23 @@ describe('Sub-navigation (/subnav)', () => {
1313
await expect(page).toMatchElement('.o-header__subnav-item')
1414
})
1515

16-
it('renders subnav dropdowns as initially hidden/collapsed', async () => {
17-
await expect(page).toMatchElement('.o-header__subnav-dropdown[aria-hidden="true"]')
18-
await expect(page).toMatchElement('.o-header__subnav-dropdown[aria-expanded="false"]')
16+
it('renders subnav dropdowns as initially collapsed', async () => {
17+
await expect(page).toMatchElement('[data-o-header-subnav-dropdown-button][aria-expanded="false"]')
1918
})
2019

2120
it('renders subnav dropdown items and links', async () => {
2221
await expect(page).toMatchElement('.o-header__subnav-dropdown')
2322
await expect(page).toMatchElement('.o-header__subnav-dropdown-item')
2423
await expect(page).toMatchElement('.o-header__subnav-dropdown-link')
2524
})
25+
26+
it('subnav elements have appropriate accessibility properties', async () => {
27+
await expect(page).toMatchElement(
28+
'[data-o-header-subnav-dropdown-button][id="subnav-dropdown-button-federal-reserve"][aria-controls="subnav-dropdown-modal-federal-reserve"][aria-haspopup="dialog"][aria-expanded="false"]'
29+
)
30+
31+
await expect(page).toMatchElement(
32+
'[data-o-header-subnav-dropdown-modal][id="subnav-dropdown-modal-federal-reserve"][role="dialog"][aria-modal="true"][aria-labelledby="subnav-dropdown-button-federal-reserve"]'
33+
)
34+
})
2635
})

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dotcom-ui-header/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
},
3131
"peerDependencies": {
3232
"@financial-times/logo-images": "^1.10.1",
33-
"@financial-times/o-header": "^15.4.0",
33+
"@financial-times/o-header": "^15.4.1",
3434
"@financial-times/o3-button": "^3.15.0",
3535
"n-topic-search": "^10.1.1",
3636
"preact": "^10.23.2",

packages/dotcom-ui-header/src/__test__/components/SubNavDropdown.spec.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,23 @@ describe('SubNavigation with dropdown options', () => {
3535
}
3636

3737
it('renders hidden dropdown modal with provided options', () => {
38-
const { container, getByRole } = render(<SubNavigation {...mockProps} />)
38+
const { container } = render(<SubNavigation {...mockProps} />)
3939

40-
const button = getByRole('button')
40+
const button = container.querySelector('[data-o-header-subnav-dropdown-button]')
4141
expect(button).toBeInTheDocument()
4242
expect(button).toHaveAttribute('data-trackable', 'Markets Data')
43-
expect(button).toHaveAttribute('tabIndex', '0')
44-
expect(button.tagName).toBe('SPAN')
43+
expect(button).toHaveAttribute('id', 'subnav-dropdown-button-markets-data')
44+
expect(button).toHaveAttribute('aria-controls', 'subnav-dropdown-modal-markets-data')
45+
expect(button).toHaveAttribute('aria-haspopup', 'dialog')
46+
expect(button).toHaveAttribute('aria-expanded', 'false')
4547

46-
const dropdown = container.querySelector('[data-o-header-subnav-dropdown]')
48+
const dropdown = container.querySelector('[data-o-header-subnav-dropdown-modal]')
4749
expect(dropdown).toBeInTheDocument()
48-
expect(dropdown).toHaveAttribute('aria-hidden', 'true')
49-
expect(dropdown).toHaveAttribute('aria-expanded', 'false')
50+
expect(dropdown).toHaveAttribute('id', 'subnav-dropdown-modal-markets-data')
5051
expect(dropdown).toHaveAttribute('role', 'dialog')
5152
expect(dropdown).toHaveAttribute('aria-modal', 'true')
52-
expect(dropdown).toHaveAttribute('aria-label', 'Markets Data')
53+
expect(dropdown).toHaveAttribute('aria-labelledby', 'subnav-dropdown-button-markets-data')
54+
expect(dropdown).not.toHaveAttribute('aria-label')
5355

5456
const title = container.querySelector('.o-header__subnav-dropdown-title')
5557
expect(title).toBeInTheDocument()
@@ -58,7 +60,6 @@ describe('SubNavigation with dropdown options', () => {
5860
const closeButton = container.querySelector('[data-o-header-subnav-dropdown-close]')
5961
expect(closeButton).toBeInTheDocument()
6062
expect(closeButton).toHaveAttribute('aria-label', 'Close menu')
61-
expect(closeButton).toHaveAttribute('role', 'button')
6263
const closeIcon = container.querySelector('.o-header__subnav-dropdown-close-icon')
6364
expect(closeIcon).toBeInTheDocument()
6465

@@ -90,15 +91,15 @@ describe('SubNavigation with dropdown options', () => {
9091
}
9192
}
9293

93-
const { container, getAllByRole, queryByRole } = render(<SubNavigation {...propsWithoutDropdowns} />)
94+
const { container, getAllByRole } = render(<SubNavigation {...propsWithoutDropdowns} />)
9495

9596
const links = getAllByRole('link')
9697
const overviewLink = links.find((link) => link.textContent === 'Markets Overview')
9798
expect(overviewLink).toBeInTheDocument()
9899
expect(overviewLink).toHaveAttribute('href', '/markets-overview')
99100

100-
const dropdown = container.querySelector('[data-o-header-subnav-dropdown]')
101-
const button = queryByRole('button')
101+
const dropdown = container.querySelector('[data-o-header-subnav-dropdown-modal]')
102+
const button = container.querySelector('[data-o-header-subnav-dropdown-button]')
102103
expect(dropdown).not.toBeInTheDocument()
103104
expect(button).not.toBeInTheDocument()
104105
})
@@ -126,15 +127,15 @@ describe('SubNavigation with dropdown options', () => {
126127
}
127128
}
128129

129-
const { container, getAllByRole, queryByRole } = render(<SubNavigation {...propsWithDropdownsDisabled} />)
130+
const { container, getAllByRole } = render(<SubNavigation {...propsWithDropdownsDisabled} />)
130131

131132
const links = getAllByRole('link')
132133
const overviewLink = links.find((link) => link.textContent === 'Markets Overview')
133134
expect(overviewLink).toBeInTheDocument()
134135
expect(overviewLink).toHaveAttribute('href', '/markets-overview')
135136

136-
const dropdown = container.querySelector('[data-o-header-subnav-dropdown]')
137-
const button = queryByRole('button')
137+
const dropdown = container.querySelector('[data-o-header-subnav-dropdown-modal]')
138+
const button = container.querySelector('[data-o-header-subnav-dropdown-button]')
138139
expect(dropdown).not.toBeInTheDocument()
139140
expect(button).not.toBeInTheDocument()
140141
})

packages/dotcom-ui-header/src/components/sub-navigation/SubNavDropdown.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,31 @@ interface DropdownItem {
77
}
88

99
interface SubNavDropdownProps {
10+
id: string
1011
items: DropdownItem[]
12+
ariaLabelledBy: string
1113
title?: string
1214
}
1315

14-
const SubNavDropdown: React.FC<SubNavDropdownProps> = ({ items, title }) => {
16+
const SubNavDropdown: React.FC<SubNavDropdownProps> = ({ id, items, ariaLabelledBy, title }) => {
1517
return (
1618
<div
19+
id={id}
1720
className="o-header__subnav-dropdown"
18-
data-o-header-subnav-dropdown
19-
aria-hidden="true"
20-
aria-expanded="false"
21+
data-o-header-subnav-dropdown-modal
2122
role="dialog"
2223
aria-modal="true"
23-
aria-label={title || 'Navigation menu'}
24+
aria-labelledby={ariaLabelledBy}
2425
>
2526
{title && <h2 className="o-header__subnav-dropdown-title">{title}</h2>}
26-
<span
27+
<button
2728
className="o-header__subnav-dropdown-close"
2829
data-o-header-subnav-dropdown-close
2930
aria-label="Close menu"
30-
role="button"
31+
type="button"
3132
>
3233
<span className="o-header__subnav-dropdown-close-icon" />
33-
</span>
34+
</button>
3435
<ul className="o-header__subnav-dropdown-list">
3536
{items.map((item, index) => (
3637
<li key={index} className="o-header__subnav-dropdown-item">

packages/dotcom-ui-header/src/components/sub-navigation/SubNavWithDropdown.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,27 @@ interface SubNavWithDropdownProps {
1010

1111
const SubNavWithDropdown: React.FC<SubNavWithDropdownProps> = ({ item, selected }) => {
1212
const dropdownItems = item.subnavDropdownOptions || []
13+
const labelSlug = item.label?.toLowerCase().replace(/\s+/g, '-')
14+
const buttonId = `subnav-dropdown-button-${labelSlug}`
15+
const modalId = `subnav-dropdown-modal-${labelSlug}`
1316

1417
return (
15-
<span
16-
role="button"
17-
tabIndex={0}
18-
className={`o-header__subnav-link ${selected}`}
19-
{...ariaSelected(item)}
20-
data-trackable={item.label}
21-
>
22-
{item.label}
23-
<SubNavDropdown items={dropdownItems} title={item.label} />
24-
</span>
18+
<div data-o-header-subnav-dropdown-parent>
19+
<button
20+
id={buttonId}
21+
type="button"
22+
className={`o-header__subnav-link ${selected}`}
23+
{...ariaSelected(item)}
24+
data-trackable={item.label}
25+
aria-controls={modalId}
26+
aria-haspopup="dialog"
27+
aria-expanded="false"
28+
data-o-header-subnav-dropdown-button
29+
>
30+
{item.label}
31+
</button>
32+
<SubNavDropdown id={modalId} ariaLabelledBy={buttonId} items={dropdownItems} title={item.label} />
33+
</div>
2534
)
2635
}
2736

packages/dotcom-ui-header/src/components/sub-navigation/subNavDropdown.scss

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@import '@financial-times/o3-foundation/css/professional.css';
22

3-
.o-header__subnav-link:has([data-o-header-subnav-dropdown]) {
4-
cursor: pointer;
3+
[data-o-header-subnav-dropdown-parent] {
4+
cursor: default;
55
display: inline-block;
66
position: relative;
77
}
@@ -50,8 +50,21 @@
5050
}
5151
}
5252

53+
[data-o-header-subnav-dropdown-button] {
54+
background-color: inherit;
55+
font-family: inherit;
56+
font-size: inherit;
57+
font-weight: inherit;
58+
line-height: inherit;
59+
&:focus,
60+
&:focus-visible {
61+
box-shadow: var(--o3-focus-use-case-outline-color);
62+
}
63+
cursor: default !important; // On desktop the hover action will open the modal with click doing nothing additional. Therefore the pointer is misleading.
64+
}
65+
5366
@media (hover: none) {
54-
.o-header__subnav-link:has([data-o-header-subnav-dropdown]) {
67+
[data-o-header-subnav-dropdown-parent] {
5568
-webkit-tap-highlight-color: transparent;
5669
}
5770

@@ -61,8 +74,8 @@
6174
* limited to iOS to prevent android widening the layout when items overflow.
6275
*/
6376
@supports (-webkit-touch-callout: none) {
64-
.o-header__subnav:has([data-o-header-subnav-dropdown][aria-hidden='false']) .o-header__subnav-wrap-outside,
65-
.o-header__subnav:has([data-o-header-subnav-dropdown][aria-hidden='false']) .o-header__subnav-wrap-inside {
77+
.o-header__subnav:has([data-o-header-subnav-dropdown-button][aria-expanded='true']) .o-header__subnav-wrap-outside,
78+
.o-header__subnav:has([data-o-header-subnav-dropdown-button][aria-expanded='true']) .o-header__subnav-wrap-inside {
6679
overflow: visible;
6780
}
6881
}

0 commit comments

Comments
 (0)