Skip to content

Commit 0523974

Browse files
authored
Merge pull request #3269 from atmire/w2p-117544_support-for-disabled-elements-for-screen-readers-8.0
[Port dspace-8_x] support for disabled elements for screen readers
2 parents 0efa4a6 + e9e3d86 commit 0523974

File tree

188 files changed

+866
-237
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

188 files changed

+866
-237
lines changed

.eslintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,8 @@
293293
],
294294
"rules": {
295295
// Custom DSpace Angular rules
296-
"dspace-angular-html/themed-component-usages": "error"
296+
"dspace-angular-html/themed-component-usages": "error",
297+
"dspace-angular-html/no-disabled-attribute-on-button": "error"
297298
}
298299
},
299300
{

docs/lint/html/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
_______
33

44
- [`dspace-angular-html/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via the selector of their `ThemedComponent` wrapper class
5+
- [`dspace-angular-html/no-disabled-attribute-on-button`](./rules/no-disabled-attribute-on-button.md): Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
[DSpace ESLint plugins](../../../../lint/README.md) > [HTML rules](../index.md) > `dspace-angular-html/no-disabled-attribute-on-button`
2+
_______
3+
4+
Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute.
5+
This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled.
6+
The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present.
7+
8+
_______
9+
10+
[Source code](../../../../lint/src/rules/html/no-disabled-attribute-on-button.ts)
11+
12+
### Examples
13+
14+
15+
#### Valid code
16+
17+
##### should use [dsBtnDisabled] in HTML templates
18+
19+
```html
20+
<button [dsBtnDisabled]="true">Submit</button>
21+
```
22+
23+
##### disabled attribute is still valid on non-button elements
24+
25+
```html
26+
<input disabled>
27+
```
28+
29+
##### [disabled] attribute is still valid on non-button elements
30+
31+
```html
32+
<input [disabled]="true">
33+
```
34+
35+
##### angular dynamic attributes that use disabled are still valid
36+
37+
```html
38+
<button [class.disabled]="isDisabled">Submit</button>
39+
```
40+
41+
42+
43+
44+
#### Invalid code &amp; automatic fixes
45+
46+
##### should not use disabled attribute in HTML templates
47+
48+
```html
49+
<button disabled>Submit</button>
50+
```
51+
Will produce the following error(s):
52+
```
53+
Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.
54+
```
55+
56+
Result of `yarn lint --fix`:
57+
```html
58+
<button [dsBtnDisabled]="true">Submit</button>
59+
```
60+
61+
62+
##### should not use [disabled] attribute in HTML templates
63+
64+
```html
65+
<button [disabled]="true">Submit</button>
66+
```
67+
Will produce the following error(s):
68+
```
69+
Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.
70+
```
71+
72+
Result of `yarn lint --fix`:
73+
```html
74+
<button [dsBtnDisabled]="true">Submit</button>
75+
```
76+
77+
78+

lint/src/rules/html/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import {
1010
bundle,
1111
RuleExports,
1212
} from '../../util/structure';
13+
import * as noDisabledAttributeOnButton from './no-disabled-attribute-on-button';
1314
import * as themedComponentUsages from './themed-component-usages';
1415

1516
const index = [
1617
themedComponentUsages,
18+
noDisabledAttributeOnButton,
19+
1720
] as unknown as RuleExports[];
1821

1922
export = {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
TmplAstBoundAttribute,
3+
TmplAstTextAttribute,
4+
} from '@angular-eslint/bundled-angular-compiler';
5+
import { TemplateParserServices } from '@angular-eslint/utils';
6+
import {
7+
ESLintUtils,
8+
TSESLint,
9+
} from '@typescript-eslint/utils';
10+
11+
import {
12+
DSpaceESLintRuleInfo,
13+
NamedTests,
14+
} from '../../util/structure';
15+
import { getSourceCode } from '../../util/typescript';
16+
17+
export enum Message {
18+
USE_DSBTN_DISABLED = 'mustUseDsBtnDisabled',
19+
}
20+
21+
export const info = {
22+
name: 'no-disabled-attribute-on-button',
23+
meta: {
24+
docs: {
25+
description: `Buttons should use the \`dsBtnDisabled\` directive instead of the HTML \`disabled\` attribute.
26+
This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled.
27+
The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present.`,
28+
},
29+
type: 'problem',
30+
fixable: 'code',
31+
schema: [],
32+
messages: {
33+
[Message.USE_DSBTN_DISABLED]: 'Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.',
34+
},
35+
},
36+
defaultOptions: [],
37+
} as DSpaceESLintRuleInfo;
38+
39+
export const rule = ESLintUtils.RuleCreator.withoutDocs({
40+
...info,
41+
create(context: TSESLint.RuleContext<Message, unknown[]>) {
42+
const parserServices = getSourceCode(context).parserServices as TemplateParserServices;
43+
44+
/**
45+
* Some dynamic angular inputs will have disabled as name because of how Angular handles this internally (e.g [class.disabled]="isDisabled")
46+
* But these aren't actually the disabled attribute we're looking for, we can determine this by checking the details of the keySpan
47+
*/
48+
function isOtherAttributeDisabled(node: TmplAstBoundAttribute | TmplAstTextAttribute): boolean {
49+
// if the details are not null, and the details are not 'disabled', then it's not the disabled attribute we're looking for
50+
return node.keySpan?.details !== null && node.keySpan?.details !== 'disabled';
51+
}
52+
53+
/**
54+
* Replace the disabled text with [dsBtnDisabled] in the template
55+
*/
56+
function replaceDisabledText(text: string ): string {
57+
const hasBrackets = text.includes('[') && text.includes(']');
58+
const newDisabledText = hasBrackets ? 'dsBtnDisabled' : '[dsBtnDisabled]="true"';
59+
return text.replace('disabled', newDisabledText);
60+
}
61+
62+
function inputIsChildOfButton(node: any): boolean {
63+
return (node.parent?.tagName === 'button' || node.parent?.name === 'button');
64+
}
65+
66+
function reportAndFix(node: TmplAstBoundAttribute | TmplAstTextAttribute) {
67+
if (!inputIsChildOfButton(node) || isOtherAttributeDisabled(node)) {
68+
return;
69+
}
70+
71+
const sourceSpan = node.sourceSpan;
72+
context.report({
73+
messageId: Message.USE_DSBTN_DISABLED,
74+
loc: parserServices.convertNodeSourceSpanToLoc(sourceSpan),
75+
fix(fixer) {
76+
const templateText = sourceSpan.start.file.content;
77+
const disabledText = templateText.slice(sourceSpan.start.offset, sourceSpan.end.offset);
78+
const newText = replaceDisabledText(disabledText);
79+
return fixer.replaceTextRange([sourceSpan.start.offset, sourceSpan.end.offset], newText);
80+
},
81+
});
82+
}
83+
84+
return {
85+
'BoundAttribute[name="disabled"]'(node: TmplAstBoundAttribute) {
86+
reportAndFix(node);
87+
},
88+
'TextAttribute[name="disabled"]'(node: TmplAstTextAttribute) {
89+
reportAndFix(node);
90+
},
91+
};
92+
},
93+
});
94+
95+
export const tests = {
96+
plugin: info.name,
97+
valid: [
98+
{
99+
name: 'should use [dsBtnDisabled] in HTML templates',
100+
code: `
101+
<button [dsBtnDisabled]="true">Submit</button>
102+
`,
103+
},
104+
{
105+
name: 'disabled attribute is still valid on non-button elements',
106+
code: `
107+
<input disabled>
108+
`,
109+
},
110+
{
111+
name: '[disabled] attribute is still valid on non-button elements',
112+
code: `
113+
<input [disabled]="true">
114+
`,
115+
},
116+
{
117+
name: 'angular dynamic attributes that use disabled are still valid',
118+
code: `
119+
<button [class.disabled]="isDisabled">Submit</button>
120+
`,
121+
},
122+
],
123+
invalid: [
124+
{
125+
name: 'should not use disabled attribute in HTML templates',
126+
code: `
127+
<button disabled>Submit</button>
128+
`,
129+
errors: [{ messageId: Message.USE_DSBTN_DISABLED }],
130+
output: `
131+
<button [dsBtnDisabled]="true">Submit</button>
132+
`,
133+
},
134+
{
135+
name: 'should not use [disabled] attribute in HTML templates',
136+
code: `
137+
<button [disabled]="true">Submit</button>
138+
`,
139+
errors: [{ messageId: Message.USE_DSBTN_DISABLED }],
140+
output: `
141+
<button [dsBtnDisabled]="true">Submit</button>
142+
`,
143+
},
144+
],
145+
} as NamedTests;
146+
147+
export default rule;

src/app/access-control/bulk-access/bulk-access.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ <h1>{{ 'admin.access-control.bulk-access.title' | translate }}</h1>
1010
<button class="btn btn-outline-primary mr-3" (click)="reset()">
1111
{{ 'access-control-cancel' | translate }}
1212
</button>
13-
<button class="btn btn-primary" [disabled]="!canExport()" (click)="submit()">
13+
<button class="btn btn-primary" [dsBtnDisabled]="!canExport()" (click)="submit()">
1414
{{ 'access-control-execute' | translate }}
1515
</button>
1616
</div>

src/app/access-control/bulk-access/bulk-access.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from 'rxjs/operators';
1515

1616
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
17+
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
1718
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
1819
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
1920
import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component';
@@ -27,6 +28,7 @@ import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.com
2728
TranslateModule,
2829
BulkAccessSettingsComponent,
2930
BulkAccessBrowseComponent,
31+
BtnDisabledDirective,
3032
],
3133
standalone: true,
3234
})

src/app/access-control/epeople-registry/epeople-registry.component.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service';
4242
import { EPerson } from '../../core/eperson/models/eperson.model';
4343
import { PaginationService } from '../../core/pagination/pagination.service';
4444
import { PageInfo } from '../../core/shared/page-info.model';
45+
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
4546
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
4647
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
4748
import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock';
@@ -151,7 +152,7 @@ describe('EPeopleRegistryComponent', () => {
151152
paginationService = new PaginationServiceStub();
152153
TestBed.configureTestingModule({
153154
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule.withRoutes([]),
154-
TranslateModule.forRoot(), EPeopleRegistryComponent],
155+
TranslateModule.forRoot(), EPeopleRegistryComponent, BtnDisabledDirective],
155156
providers: [
156157
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
157158
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },

src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ <h1 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h1>
2525
</button>
2626
</div>
2727
<div *ngIf="displayResetPassword" between class="btn-group">
28-
<button class="btn btn-primary" [disabled]="(canReset$ | async) !== true" type="button" (click)="resetPassword()">
28+
<button class="btn btn-primary" [dsBtnDisabled]="(canReset$ | async) !== true" type="button" (click)="resetPassword()">
2929
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
3030
</button>
3131
</div>

src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { GroupDataService } from '../../../core/eperson/group-data.service';
4343
import { EPerson } from '../../../core/eperson/models/eperson.model';
4444
import { PaginationService } from '../../../core/pagination/pagination.service';
4545
import { PageInfo } from '../../../core/shared/page-info.model';
46+
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
4647
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
4748
import { FormComponent } from '../../../shared/form/form.component';
4849
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
@@ -221,7 +222,7 @@ describe('EPersonFormComponent', () => {
221222
route = new ActivatedRouteStub();
222223
router = new RouterStub();
223224
TestBed.configureTestingModule({
224-
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
225+
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BtnDisabledDirective, BrowserModule,
225226
RouterModule.forRoot([]),
226227
TranslateModule.forRoot(),
227228
EPersonFormComponent,
@@ -516,7 +517,8 @@ describe('EPersonFormComponent', () => {
516517
// ePersonDataServiceStub.activeEPerson = eperson;
517518
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(createSuccessfulRemoteDataObject$('No Content', 204));
518519
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
519-
expect(deleteButton.nativeElement.disabled).toBe(false);
520+
expect(deleteButton.nativeElement.getAttribute('aria-disabled')).toBeNull();
521+
expect(deleteButton.nativeElement.classList.contains('disabled')).toBeFalse();
520522
deleteButton.triggerEventHandler('click', null);
521523
fixture.detectChanges();
522524
expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson);

0 commit comments

Comments
 (0)