Skip to content

Commit e3656c5

Browse files
fix(a11y): default dangerDescription to empty string (#22121)
* fix(a11y): default dangerDescription to empty string * Update packages/web-components/src/components/button/button.ts Co-authored-by: Tom Brunet <thbrunet@us.ibm.com> * chore: yarn format * chore: update snaps * chore: update snaps * fix(button): only render aria-describedby when necessary * fix(button): target span for aria-describedby --------- Co-authored-by: Tom Brunet <thbrunet@us.ibm.com>
1 parent 4bb7d8d commit e3656c5

22 files changed

Lines changed: 294 additions & 135 deletions

File tree

packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6535,6 +6535,9 @@ Map {
65356535
"danger": {
65366536
"type": "bool",
65376537
},
6538+
"dangerDescription": {
6539+
"type": "string",
6540+
},
65386541
"decorator": {
65396542
"type": "node",
65406543
},
@@ -6705,6 +6708,9 @@ Map {
67056708
"danger": {
67066709
"type": "bool",
67076710
},
6711+
"dangerDescription": {
6712+
"type": "string",
6713+
},
67086714
"inputref": {
67096715
"args": [
67106716
[
@@ -12193,6 +12199,9 @@ Map {
1219312199
"danger": {
1219412200
"type": "bool",
1219512201
},
12202+
"dangerDescription": {
12203+
"type": "string",
12204+
},
1219612205
"loadingDescription": {
1219712206
"type": "string",
1219812207
},

packages/react/src/components/Button/ButtonBase.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const ButtonBase = React.forwardRef(function ButtonBase<
1818
as,
1919
children,
2020
className,
21-
dangerDescription = 'danger',
21+
dangerDescription = '',
2222
disabled = false,
2323
hasIconOnly = false,
2424
href,
@@ -72,6 +72,8 @@ const ButtonBase = React.forwardRef(function ButtonBase<
7272
);
7373

7474
const dangerButtonVariants = ['danger', 'danger--tertiary', 'danger--ghost'];
75+
const hasDangerDescription =
76+
dangerButtonVariants.includes(kind) && Boolean(dangerDescription);
7577

7678
let component: React.ElementType = 'button';
7779
const assistiveId = useId('danger-description');
@@ -80,7 +82,7 @@ const ButtonBase = React.forwardRef(function ButtonBase<
8082
let otherProps: Partial<ButtonBaseProps> = {
8183
disabled,
8284
type,
83-
'aria-describedby': dangerButtonVariants.includes(kind)
85+
'aria-describedby': hasDangerDescription
8486
? assistiveId
8587
: ariaDescribedBy || undefined,
8688
'aria-pressed':
@@ -91,7 +93,7 @@ const ButtonBase = React.forwardRef(function ButtonBase<
9193
};
9294

9395
let assistiveText: JSX.Element | null = null;
94-
if (dangerButtonVariants.includes(kind)) {
96+
if (hasDangerDescription) {
9597
assistiveText = (
9698
<span id={assistiveId} className={`${prefix}--visually-hidden`}>
9799
{dangerDescription}

packages/react/src/components/Button/__tests__/Button-test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ describe('Button', () => {
102102
}
103103
);
104104

105+
it('does not render danger assistive text when dangerDescription is empty', () => {
106+
render(
107+
<Button kind="danger" dangerDescription="">
108+
Delete
109+
</Button>
110+
);
111+
112+
expect(screen.getByRole('button', { name: 'Delete' })).not.toHaveAttribute(
113+
'aria-describedby'
114+
);
115+
});
116+
105117
it.each([
106118
['xs', 'cds--btn--xs'],
107119
['sm', 'cds--btn--sm'],

packages/react/src/components/ComposedModal/ModalFooter-test.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,21 @@ describe('ModalFooter', () => {
9696
);
9797

9898
expect(screen.getByText('Submit')).toHaveClass('cds--btn--danger');
99-
expect(screen.getByText('danger', { hidden: true })).toBeInTheDocument();
99+
expect(screen.getByText('Submit')).not.toHaveAttribute('aria-describedby');
100+
});
101+
102+
it('should allow a localized danger description for the primary button', () => {
103+
render(
104+
<ModalFooter
105+
secondaryButtonText="Cancel"
106+
primaryButtonText="Submit"
107+
danger
108+
dangerDescription="gefahr"
109+
/>
110+
);
111+
112+
expect(screen.getByText('Submit')).toHaveAttribute('aria-describedby');
113+
expect(screen.getByText('gefahr', { hidden: true })).toBeInTheDocument();
100114
});
101115

102116
it('should call onRequestClose when close requested', async () => {

packages/react/src/components/ComposedModal/ModalFooter.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ export interface ModalFooterProps {
128128
*/
129129
danger?: boolean;
130130

131+
/**
132+
* Specify the message read by screen readers for the danger primary button.
133+
* Defaults to an empty string; provide localized text to opt in.
134+
*/
135+
dangerDescription?: string;
136+
131137
/**
132138
* The `ref` callback for the primary button.
133139
*/
@@ -204,6 +210,7 @@ export const ModalFooter = React.forwardRef<HTMLElement, ModalFooterProps>(
204210
className: customClassName,
205211
closeModal = noopFn,
206212
danger,
213+
dangerDescription = '',
207214
inputref,
208215
onRequestClose = noopFn,
209216
onRequestSubmit = noopFn,
@@ -259,6 +266,7 @@ export const ModalFooter = React.forwardRef<HTMLElement, ModalFooterProps>(
259266
onClick={onRequestSubmit}
260267
className={primaryButtonClass}
261268
disabled={loadingActive || primaryButtonDisabled}
269+
dangerDescription={dangerDescription}
262270
kind={danger ? 'danger' : 'primary'}
263271
ref={inputref}>
264272
{loadingStatus === 'inactive' ? (
@@ -302,6 +310,12 @@ ModalFooter.propTypes = {
302310
*/
303311
danger: PropTypes.bool,
304312

313+
/**
314+
* Specify the message read by screen readers for the danger primary button.
315+
* Defaults to an empty string; provide localized text to opt in.
316+
*/
317+
dangerDescription: PropTypes.string,
318+
305319
/**
306320
* The `ref` callback for the primary button.
307321
*/

packages/react/src/components/Dialog/Dialog-test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,35 @@ describe('Dialog', () => {
334334
caf.mockRestore();
335335
});
336336

337+
it('does not add a default danger description to `DialogFooter` primary actions', () => {
338+
render(
339+
<Dialog open>
340+
<DialogFooter danger primaryButtonText="Delete" />
341+
</Dialog>
342+
);
343+
344+
expect(
345+
screen.getByRole('button', { name: 'Delete' })
346+
).not.toHaveAttribute('aria-describedby');
347+
});
348+
349+
it('allows a localized danger description for `DialogFooter` primary actions', () => {
350+
render(
351+
<Dialog open>
352+
<DialogFooter
353+
danger
354+
primaryButtonText="Delete"
355+
dangerDescription="gefahr"
356+
/>
357+
</Dialog>
358+
);
359+
360+
expect(
361+
screen.getByRole('button', { name: 'gefahr Delete' })
362+
).toHaveAttribute('aria-describedby');
363+
expect(screen.getByText('gefahr', { hidden: true })).toBeInTheDocument();
364+
});
365+
337366
it('prefers aria-label prop over deprecated ariaLabel prop', () => {
338367
render(<Dialog open aria-label="label" ariaLabel="deprecated label" />);
339368

packages/react/src/components/Dialog/Dialog.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,12 @@ interface DialogFooterProps extends HTMLAttributes<HTMLDivElement> {
791791
*/
792792
danger?: boolean;
793793

794+
/**
795+
* Specify the message read by screen readers for the danger primary button.
796+
* Defaults to an empty string; provide localized text to opt in.
797+
*/
798+
dangerDescription?: string;
799+
794800
/**
795801
* Specify loading status
796802
*/
@@ -830,6 +836,7 @@ const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
830836
loadingIconDescription,
831837
onLoadingSuccess = noopFn,
832838
danger = false,
839+
dangerDescription = '',
833840
...rest
834841
},
835842
ref
@@ -904,6 +911,7 @@ const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
904911
<Button
905912
className={primaryButtonClass}
906913
kind={danger ? 'danger' : 'primary'}
914+
dangerDescription={dangerDescription}
907915
disabled={loadingActive || primaryButtonDisabled}
908916
onClick={onRequestSubmit}
909917
ref={button}>
@@ -1004,6 +1012,12 @@ DialogFooter.propTypes = {
10041012
*/
10051013
danger: PropTypes.bool,
10061014

1015+
/**
1016+
* Specify the message read by screen readers for the danger primary button.
1017+
* Defaults to an empty string; provide localized text to opt in.
1018+
*/
1019+
dangerDescription: PropTypes.string,
1020+
10071021
/**
10081022
* Specify loading status
10091023
*/

packages/react/src/components/Menu/MenuItem.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const MenuItem = forwardRef<HTMLLIElement, MenuItemProps>(
9898
{
9999
children,
100100
className,
101-
dangerDescription = 'danger',
101+
dangerDescription = '',
102102
disabled,
103103
kind = 'default',
104104
label,
@@ -179,6 +179,7 @@ export const MenuItem = forwardRef<HTMLLIElement, MenuItemProps>(
179179

180180
const isDisabled = disabled && !hasChildren;
181181
const isDanger = kind === 'danger' && !hasChildren;
182+
const hasDangerDescription = isDanger && Boolean(dangerDescription);
182183

183184
function registerItem() {
184185
context.dispatch({
@@ -325,7 +326,7 @@ export const MenuItem = forwardRef<HTMLLIElement, MenuItemProps>(
325326
<Text as="div" className={`${prefix}--menu-item__label`}>
326327
{label}
327328
</Text>
328-
{isDanger && (
329+
{hasDangerDescription && (
329330
<span id={assistiveId} className={`${prefix}--visually-hidden`}>
330331
{dangerDescription}
331332
</span>

packages/react/src/components/Modal/Modal-test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,38 @@ describe.each([
381381
);
382382
});
383383

384+
it('does not add a default danger description to the primary action', () => {
385+
render(
386+
<Component
387+
danger
388+
primaryButtonText="Delete"
389+
data-testid="modal-danger-default"
390+
/>
391+
);
392+
393+
expect(screen.getByRole('button', { name: 'Delete' })).not.toHaveAttribute(
394+
'aria-describedby'
395+
);
396+
});
397+
398+
it('allows a localized danger description to be provided', () => {
399+
render(
400+
<Component
401+
danger
402+
dangerDescription="gefahr"
403+
primaryButtonText="Delete"
404+
data-testid="modal-danger-localized"
405+
/>
406+
);
407+
408+
const button = screen.getByRole('button', { name: 'gefahr Delete' });
409+
410+
expect(button).toHaveAttribute('aria-describedby');
411+
expect(screen.getByText('gefahr')).toHaveClass(
412+
`${prefix}--visually-hidden`
413+
);
414+
});
415+
384416
it('disables buttons when inline loading status is active', () => {
385417
render(
386418
<Component

packages/react/src/components/Modal/Modal.stories.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export default {
5757
modalLabel: {
5858
control: 'text',
5959
},
60+
dangerDescription: {
61+
control: 'text',
62+
},
6063
numberOfButtons: {
6164
description: 'Count of Footer Buttons',
6265
options: Object.keys(buttons),

0 commit comments

Comments
 (0)