Skip to content

Commit b6bb971

Browse files
Merge pull request #1030 from hashicorp/rn/fix/consent-manager-a11y-fixes
Consent Manager & Toggle a11y fixes
2 parents 750048c + bf4220c commit b6bb971

File tree

6 files changed

+97
-20
lines changed

6 files changed

+97
-20
lines changed

.changeset/big-beds-promise.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@hashicorp/react-consent-manager": patch
3+
"@hashicorp/react-toggle": patch
4+
---
5+
6+
Fix a variety of accessiblity issuies around the consent manager.
7+
- Toggles missing accessible names
8+
- see more/less button missing accessible name
9+
- allowing ESC to close the dialog
10+
- removing unneeded headers
11+
- trapping focus inside consent manager dialog

packages/consent-manager/components/dialog.js

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
Managing preferences dialog
99
*/
1010

11-
import { Component } from 'react'
11+
import { Component, createRef } from 'react'
1212
import getIntegrations from '../util/integrations'
1313
import Button from '@hashicorp/react-button'
1414
import Toggle from '@hashicorp/react-toggle'
@@ -33,6 +33,7 @@ export default class ConsentPreferences extends Component {
3333

3434
this.handleFold = this.handleFold.bind(this)
3535
this.getCategoryToggle = this.getCategoryToggle.bind(this)
36+
this.dialogRef = createRef()
3637
}
3738

3839
componentDidMount() {
@@ -44,6 +45,12 @@ export default class ConsentPreferences extends Component {
4445
).then((groupedIntegrations) => {
4546
this.setState({ groupedIntegrations })
4647
})
48+
49+
this.dialogRef.current?.showModal()
50+
}
51+
52+
componentWillUnmount() {
53+
this.dialogRef.current?.close()
4754
}
4855

4956
// Handler for when user toggles a category or individual integration
@@ -106,11 +113,16 @@ export default class ConsentPreferences extends Component {
106113
// Build each individual category and integration row
107114
buildCategory(items, name) {
108115
const categoryItems = items.map((item) => {
116+
const categoryItemTitleID = `consent-manager-categoryItemTitle-${item.name}`
117+
109118
return (
110119
<div className={s.categoryItem} key={item.name}>
111-
<header className={s.categoryItemHeader}>
112-
<h4 className={s.categoryItemTitle}>{item.name}</h4>
120+
<div className={s.categoryItemHeader}>
121+
<h4 id={categoryItemTitleID} className={s.categoryItemTitle}>
122+
{item.name}
123+
</h4>
113124
<Toggle
125+
ariaLabelledBy={categoryItemTitleID}
114126
onChange={this.handleToggle.bind(this, item.name, item.origin)}
115127
enabled={Boolean(
116128
this.state.consent.loadAll ||
@@ -119,18 +131,24 @@ export default class ConsentPreferences extends Component {
119131
this.state.consent[item.origin][item.name])
120132
)}
121133
/>
122-
</header>
134+
</div>
123135
<p className={s.categoryItemDescription}>{item.description}</p>
124136
</div>
125137
)
126138
})
127139

140+
const categoryHeaderTitleID = `consent-manager-categoryHeaderTitle-${name}`
141+
const categoryListID = `consent-manager-categoryHeaderTitle-${name}-list`
142+
128143
return (
129144
<div className={s.category} key={name}>
130-
<header className={s.categoryHeader}>
131-
<h3 className={s.categoryHeaderTitle}>{name}</h3>
145+
<div className={s.categoryHeader}>
146+
<h3 id={categoryHeaderTitleID} className={s.categoryHeaderTitle}>
147+
{name}
148+
</h3>
132149
{!this.state.showCategories[name] && (
133150
<Toggle
151+
ariaLabelledBy={categoryHeaderTitleID}
134152
onChange={this.handleToggle.bind(this, name, 'categories')}
135153
enabled={
136154
this.state.consent.loadAll || this.getCategoryToggle(name)
@@ -145,14 +163,20 @@ export default class ConsentPreferences extends Component {
145163
this.handleFold(name)
146164
}}
147165
aria-label={
148-
this.state.showCategories[name] ? 'See less' : 'See more'
166+
this.state.showCategories[name]
167+
? `See less in ${name}`
168+
: `See more in ${name}`
169+
}
170+
aria-expanded={this.state.showCategories[name] ? `true` : `false`}
171+
aria-controls={
172+
this.state.showCategories[name] ? categoryListID : null
149173
}
150174
>
151175
<IconArrowDown24 />
152176
</button>
153-
</header>
177+
</div>
154178
{this.state.showCategories[name] && (
155-
<div className={s.categoryFold}>
179+
<div className={s.categoryFold} id={categoryListID}>
156180
<p className={s.categoryFoldDescription}>
157181
{this.state.categories[name]}
158182
</p>
@@ -173,12 +197,19 @@ export default class ConsentPreferences extends Component {
173197
})
174198

175199
return (
176-
<div className={s.root} data-testid="consent-mgr-dialog">
200+
<dialog
201+
aria-labelledby="consent-mgr-dialog-title"
202+
className={s.root}
203+
data-testid="consent-mgr-dialog"
204+
ref={this.dialogRef}
205+
>
177206
{/* Manage preferences dialog */}
178207
<div className={s.visibleDialog}>
179-
<header className={s.dialogHeader}>
180-
<h2 className={s.dialogHeaderTitle}>Manage cookies</h2>
181-
</header>
208+
<div className={s.dialogHeader}>
209+
<h2 id="consent-mgr-dialog-title" className={s.dialogHeaderTitle}>
210+
Manage cookies
211+
</h2>
212+
</div>
182213
<div className={s.dialogBody}>
183214
<p className={s.dialogBodyIntro}>
184215
HashiCorp uses data collected by cookies and JavaScript libraries
@@ -224,6 +255,7 @@ export default class ConsentPreferences extends Component {
224255
}}
225256
/>
226257
<Button
258+
autoFocus
227259
className={s.saveButton}
228260
title="Accept all"
229261
onClick={() => {
@@ -232,7 +264,7 @@ export default class ConsentPreferences extends Component {
232264
/>
233265
</div>
234266
</div>
235-
</div>
267+
</dialog>
236268
)
237269
}
238270
}

packages/consent-manager/components/dialog.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
justify-content: center;
1414
align-items: center;
1515
z-index: 10;
16+
background-color: transparent;
1617
}
1718

1819
.flexCenteredRow {

packages/consent-manager/index.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ const defaultProps = {
8282
container: '#consent-manager',
8383
}
8484

85+
beforeAll(() => {
86+
HTMLDialogElement.prototype.show = jest.fn()
87+
HTMLDialogElement.prototype.showModal = jest.fn()
88+
HTMLDialogElement.prototype.close = jest.fn()
89+
})
90+
8591
test('shows the banner if the forceShow prop is true', () => {
8692
const { default: ConsentManager } = require('./')
8793
render(<ConsentManager {...defaultProps} forceShow={true} />)

packages/consent-manager/index.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export default function ConsentManager(props: ConsentManagerProps) {
5252
if (props.forceShow) return true
5353
})
5454

55+
const closeDialog = () => {
56+
document.body.classList.remove('g-noscroll')
57+
setShowDialog(false)
58+
setShowBanner(false)
59+
setPreferences(loadPreferences() ?? {})
60+
}
61+
5562
const hasEmptyPreferencesOrVersionMismatch =
5663
Object.keys(preferences).length === 0 ||
5764
preferences.version !== props.version
@@ -70,11 +77,7 @@ export default function ConsentManager(props: ConsentManagerProps) {
7077
return
7178
}
7279

73-
// Close all dialogs
74-
document.body.classList.remove('g-noscroll')
75-
setShowDialog(false)
76-
setShowBanner(false)
77-
setPreferences(loadPreferences() ?? {})
80+
closeDialog()
7881
},
7982
[props.version]
8083
)
@@ -115,6 +118,20 @@ export default function ConsentManager(props: ConsentManagerProps) {
115118
// eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this check once
116119
}, [])
117120

121+
const escFunction = useCallback((event) => {
122+
if (showDialog && event.key === "Escape") {
123+
closeDialog()
124+
}
125+
}, [showDialog])
126+
127+
useEffect(() => {
128+
document.addEventListener("keydown", escFunction, false)
129+
130+
return () => {
131+
document.removeEventListener("keydown", escFunction, false)
132+
}
133+
}, [escFunction])
134+
118135
const filteredAdditionalServices = props.additionalServices?.filter(
119136
(service) => service.shouldLoad === undefined || service.shouldLoad()
120137
)

packages/toggle/index.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import s from './style.module.css'
1010
export default function Toggle({
1111
appearance = 'light',
1212
enabled,
13-
onChange = () => {},
13+
onChange = () => { },
1414
disabled = false,
15+
ariaLabelledBy,
1516
}) {
1617
const [enabledState, setEnabledState] = useState(enabled || false)
1718

@@ -30,6 +31,13 @@ export default function Toggle({
3031
onChange(event.currentTarget.checked)
3132
}
3233

34+
const handleKey = (event) => {
35+
if (event.key === 'Enter') {
36+
setEnabledState(!event.currentTarget.checked)
37+
onChange(!event.currentTarget.checked)
38+
}
39+
}
40+
3341
return (
3442
<label
3543
className={classNames(
@@ -40,10 +48,12 @@ export default function Toggle({
4048
)}
4149
>
4250
<input
51+
aria-labelledby={ariaLabelledBy}
4352
type="checkbox"
4453
role="switch"
4554
checked={enabledState}
4655
onChange={handleChange}
56+
onKeyDown={handleKey}
4757
className={s.toggleInput}
4858
disabled={disabled}
4959
data-testid="react-toggle"

0 commit comments

Comments
 (0)