Skip to content

Commit 7ade453

Browse files
author
Jens Vannerum
committed
lint rule with autofix to disallow the disabled input on button elements
1 parent d3e87c6 commit 7ade453

File tree

3 files changed

+149
-1
lines changed

3 files changed

+149
-1
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-attr": "error"
297298
}
298299
},
299300
{

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 noDisabledAttr from './no-disabled-attr';
1314
import * as themedComponentUsages from './themed-component-usages';
1415

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

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

0 commit comments

Comments
 (0)