Skip to content

Commit 01583f9

Browse files
fix(button): accessible disabled button (VIV-2798) (#2517)
* chore: accessible disabled button * chore: adds new pending state tests * chore: update button disabled selector in styles * chore: update button disabled selector in styles * chore: updates guidelines with more info on disabled * chore: updates split-button and fab with a11y fix for disabled * chore: update select a11y test results * chore: updates dismiss class functionality * chore: linting * chore: moves click handlers to component class * Update metadata --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent b4406ec commit 01583f9

19 files changed

+274
-48
lines changed

libs/components/metadata.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,12 @@
14081408
"args": [],
14091409
"returnType": "boolean"
14101410
},
1411+
{
1412+
"name": "clickHandler",
1413+
"description": "Handles click events.\nPrevents interaction when disabled or pending.",
1414+
"args": [{ "name": "event", "type": "Event" }],
1415+
"returnType": "unknown"
1416+
},
14111417
{
14121418
"name": "reportValidity",
14131419
"description": "Return the current validity of the element.\nIf false, fires an invalid event at the element.",
@@ -3979,6 +3985,12 @@
39793985
"args": [],
39803986
"returnType": "boolean"
39813987
},
3988+
{
3989+
"name": "clickHandler",
3990+
"description": "Handles click events.\nPrevents interaction when disabled.",
3991+
"args": [{ "name": "event", "type": "Event" }],
3992+
"returnType": "unknown"
3993+
},
39823994
{
39833995
"name": "reportValidity",
39843996
"description": "Return the current validity of the element.\nIf false, fires an invalid event at the element.",
@@ -7740,7 +7752,20 @@
77407752
}
77417753
],
77427754
"vueModels": [],
7743-
"methods": [],
7755+
"methods": [
7756+
{
7757+
"name": "handleActionClick",
7758+
"description": "Handles action click events.\nPrevents interaction when disabled or pending.",
7759+
"args": [{ "name": "event", "type": "Event" }],
7760+
"returnType": "unknown"
7761+
},
7762+
{
7763+
"name": "handleIndicatorClick",
7764+
"description": "Handles indicator click events.\nPrevents interaction when disabled or pending.",
7765+
"args": [{ "name": "event", "type": "Event" }],
7766+
"returnType": "unknown"
7767+
}
7768+
],
77447769
"slots": [
77457770
{ "name": "default", "description": "Default slot." },
77467771
{

libs/components/src/lib/button/ACCESSIBILITY.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
- Always provide an `aria-label` for buttons that contain only an icon.
1111
- The label is announced by screen readers and communicates the button’s purpose.
1212

13+
### Disabled Buttons
14+
15+
When you set the `disabled` attribute on the Button component, the `aria-disabled` attribute on the button element. This allows the button to receive focus so that it can be announced (as a disabled button) by a screen reader.
16+
1317
## Best Practices
1418

15-
### Avoid Disabling Buttons
19+
### Never Put Tooltips/Toggletips on Disabled Buttons
1620

1721
- Disabled buttons cannot receive focus and don’t explain why they can’t be used.
1822
- Instead, consider keeping the button active and use validation or error messages to guide the user.

libs/components/src/lib/button/GUIDELINES.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ The smaller size buttons (`condensed` and `super-condensed`) are useful when use
582582
</docs-do>
583583
</docs-do-dont>
584584
585-
## Ghost buttons
585+
## Ghost Buttons
586586
587587
<docs-do-dont>
588588
<docs-do slot="description" headline="Use ghost buttons inside a container">
@@ -634,16 +634,26 @@ The smaller size buttons (`condensed` and `super-condensed`) are useful when use
634634
</docs-do>
635635
</docs-do-dont>
636636
637-
## Disabled
637+
## Disabled Buttons
638638
639-
<vwc-note connotation="warning" headline="Disabled buttons should be used with caution">
640-
<vwc-icon slot="icon" name="warning-line" label="Warning:"></vwc-icon>
639+
### Why Disabled Buttons Are Problematic
641640
642-
Try to use [progressive disclosure](https://www.nngroup.com/articles/progressive-disclosure/) instead of disabled buttons.
641+
1. **Cause deception**<br />The call to action text draws users to click, but nothing happens. This causes confusion, leading users to think that the interface is broken.
642+
2. **Insufficient contrast**<br />Even if the button is disabled, the button label is still crucial for understanding the interface.
643+
3. **No feedback**<br />Without feedback, users don't know what has gone wrong or how to fix the error.
644+
4. **System complexity**<br />Disabled buttons are often used to prevent wasteful clicks, but this puts pressure on the design / implementation to provide robust inline validation. The reality is, inline validation systems can fail, leaving the user stuck with a disabled button and no way to complete the form.
643645
644-
</vwc-note>
646+
### When Disabled Buttons Can Be Useful
647+
648+
While disabled buttons often create more problems than they solve, there are limited scenarios where they can improve the user experience if designed carefully:
649+
650+
1. **Preventing duplicate submissions**<br />After a critical action such as submitting a payment, booking, or form, the button can be temporarily disabled to avoid multiple clicks and duplicate transactions. This should always be paired with visible feedback (e.g., changing the label to “Processing…” and showing a spinner). We have the [`pending` feature](/components/button/#pending) for this situation.
651+
2. **Communicating temporary unavailability**<br/>Buttons may be disabled while content is loading or while a dependent process finishes (eg. waiting for a verification SMS). This should be short-lived, and the button state must be updated as soon as the action becomes available.
652+
653+
### Best Practices
645654
646-
Ensure that the user is able to understand why the action is disabled and what they need to do to enable it.
655+
- Always provide visible and accessible feedback when a button is disabled (e.g., helper text, inline validation, or an explanatory tooltip).
656+
- Prefer inline error messages and contextual feedback over disabling buttons—this often gives a clearer and more usable experience.
647657
648658
## Related Components
649659

libs/components/src/lib/button/VARIATIONS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,13 @@ The spinner is not displayed when using the `super-condensed` size.
333333

334334
The `disabled` attribute disables the buttons and indicates that the action is not available.
335335

336+
<vwc-note connotation="warning">
337+
<vwc-icon slot="icon" name="warning-line"></vwc-icon>
338+
339+
Disabled buttons should be used with caution. Read our [guidelines for when to disabled buttons](/components/button/guidelines/#disabled).
340+
341+
</vwc-note>
342+
336343
```html preview 72px
337344
<div class="container">
338345
<vwc-button appearance="filled" label="Disabled" disabled></vwc-button>

libs/components/src/lib/button/button.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,11 @@
6565
inline-size: 100%;
6666
}
6767

68-
&:not(:disabled) {
68+
&:not(.disabled) {
6969
cursor: pointer;
7070
}
7171

72-
&:disabled {
72+
&.disabled {
7373
cursor: not-allowed;
7474
}
7575

libs/components/src/lib/button/button.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,54 @@ describe('vwc-button', () => {
118118
const icon = element.shadowRoot?.querySelector(ICON_SELECTOR) as Icon;
119119
expect(icon).toBeInstanceOf(Icon);
120120
});
121+
122+
it('should not emit click when pending', async () => {
123+
const listener = vi.fn();
124+
element.addEventListener('click', listener);
125+
126+
element.pending = true;
127+
await elementUpdated(element);
128+
129+
const control = getControlElement(element);
130+
control?.click();
131+
132+
expect(listener).not.toHaveBeenCalled();
133+
});
134+
135+
it('should not emit keyboard activation when disabled (Enter)', async () => {
136+
const listener = vi.fn();
137+
element.addEventListener('click', listener);
138+
139+
element.pending = true;
140+
await elementUpdated(element);
141+
142+
const control = getControlElement(element);
143+
const event = new KeyboardEvent('keydown', {
144+
key: 'Enter',
145+
bubbles: true,
146+
});
147+
control?.dispatchEvent(event);
148+
149+
expect(listener).not.toHaveBeenCalled();
150+
});
151+
152+
it('should not emit keyboard activation when disabled (Space)', async () => {
153+
const listener = vi.fn();
154+
element.addEventListener('click', listener);
155+
156+
element.pending = true;
157+
await elementUpdated(element);
158+
159+
const control = getControlElement(element);
160+
const event = new KeyboardEvent('keydown', {
161+
key: ' ',
162+
code: 'Space',
163+
bubbles: true,
164+
});
165+
control?.dispatchEvent(event);
166+
167+
expect(listener).not.toHaveBeenCalled();
168+
});
121169
});
122170

123171
describe('dropdown-indicator', () => {
@@ -336,7 +384,29 @@ describe('vwc-button', () => {
336384
);
337385
expect(control).toBeInstanceOf(Element);
338386
});
387+
388+
it('should set aria-disabled on <button> when disabled', async () => {
389+
element.disabled = true;
390+
await elementUpdated(element);
391+
392+
const control = getControlElement(element);
393+
expect(control?.getAttribute('aria-disabled')).toBe('true');
394+
});
395+
396+
it('should not emit click when disabled', async () => {
397+
const listener = vi.fn();
398+
element.addEventListener('click', listener);
399+
400+
element.disabled = true;
401+
await elementUpdated(element);
402+
403+
const control = getControlElement(element);
404+
control?.click();
405+
406+
expect(listener).not.toHaveBeenCalled();
407+
});
339408
});
409+
340410
describe('title', function () {
341411
it('should set title on the button if set', async () => {
342412
const titleText = 'close';
@@ -364,7 +434,6 @@ describe('vwc-button', () => {
364434
'ariaAtomic',
365435
'ariaBusy',
366436
'ariaCurrent',
367-
'ariaDisabled',
368437
'ariaExpanded',
369438
'ariaHasPopup',
370439
'ariaHidden',

libs/components/src/lib/button/button.template.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,7 @@ import {
99
import { chevronTemplateFactory } from '../../shared/patterns/chevron';
1010
import type { VividElementDefinitionContext } from '../../shared/design-system/defineVividComponent';
1111
import { delegateAria } from '../../shared/aria/delegates-aria';
12-
import type { Button, ButtonAppearance, ButtonSize } from './button';
13-
14-
const getAppearanceClassName = (
15-
appearance: ButtonAppearance,
16-
disabled: boolean
17-
) => {
18-
let className = `appearance-${appearance}`;
19-
disabled && (className += ' disabled');
20-
return className;
21-
};
12+
import type { Button, ButtonSize } from './button';
2213

2314
const getClasses = ({
2415
connotation,
@@ -28,6 +19,7 @@ const getClasses = ({
2819
icon,
2920
label,
3021
disabled,
22+
pending,
3123
stacked,
3224
size,
3325
iconSlottedContent,
@@ -38,10 +30,8 @@ const getClasses = ({
3830
classNames(
3931
'control',
4032
[`connotation-${connotation}`, Boolean(connotation)],
41-
[
42-
getAppearanceClassName(appearance as ButtonAppearance, disabled),
43-
Boolean(appearance),
44-
],
33+
[`appearance-${appearance}`, Boolean(appearance)],
34+
['disabled', disabled || pending],
4535
[`shape-${shape}`, Boolean(shape)],
4636
[`size-${size}`, Boolean(size)],
4737
[
@@ -111,7 +101,6 @@ function renderButtonContent(context: VividElementDefinitionContext) {
111101
return html` <button
112102
class="${getClasses}"
113103
?autofocus="${(x) => x.autofocus}"
114-
?disabled="${(x) => x.disabled || x.pending}"
115104
form="${(x) => x.formId}"
116105
formaction="${(x) => x.formaction}"
117106
formenctype="${(x) => x.formenctype}"
@@ -124,8 +113,10 @@ function renderButtonContent(context: VividElementDefinitionContext) {
124113
title="${(x) => x.title}"
125114
${delegateAria({
126115
ariaLabel: null,
116+
ariaDisabled: (x) => x.disabled || x.pending,
127117
})}
128118
${ref('control')}
119+
@click="${(x, c) => x.clickHandler(c.event)}"
129120
>
130121
${buttonContent(context)}
131122
</button>`;

libs/components/src/lib/button/button.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,16 @@ export class Button extends AffixIconWithTrailing(
176176
super();
177177
this.title = '';
178178
}
179+
180+
/**
181+
* Handles click events.
182+
* Prevents interaction when disabled or pending.
183+
*/
184+
clickHandler(event: Event) {
185+
if (this.disabled || this.pending) {
186+
event.preventDefault();
187+
event.stopImmediatePropagation();
188+
return;
189+
}
190+
}
179191
}

libs/components/src/lib/fab/VARIATIONS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,15 @@ The `size` attribute controls the size of the Fab.
8282

8383
The `disabled` attribute disables the Fab and indicates that the action is not available.
8484

85+
<vwc-note connotation="warning">
86+
<vwc-icon slot="icon" name="warning-line"></vwc-icon>
87+
88+
Disabled buttons should be used with caution. Read our [guidelines for when to disabled buttons](/components/button/guidelines/#disabled).
89+
90+
</vwc-note>
91+
8592
```html preview
8693
<vwc-fab disabled>
87-
<vwc-icon slot="icon" name="store-line"></vwc-icon>
94+
<vwc-icon slot="icon" name="store-line" label="Home"></vwc-icon>
8895
</vwc-fab>
8996
```

libs/components/src/lib/fab/fab.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,13 @@
110110
#{variables.$icon-gap}: 10px;
111111
}
112112

113-
&:disabled {
113+
&.disabled {
114114
@include elevation.elevation(none);
115115

116116
cursor: not-allowed;
117117
}
118118

119-
&:not(:disabled) {
119+
&:not(.disabled) {
120120
@include elevation.elevation(4);
121121

122122
cursor: pointer;

0 commit comments

Comments
 (0)