From 8f824f82867e36e84358aaeb60b575cf3a2c3928 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 24 Apr 2026 08:41:14 -0600 Subject: [PATCH 1/4] Linter: Implement `a11y-disabled-attribute` rule Implement the `a11y-disabled-attribute` linter rule from erblint-github's `GitHub::Accessibility::DisabledAttribute`. The `disabled` HTML attribute is only valid on: `button`, `input`, `textarea`, `option`, `select`, `fieldset`, `optgroup`, and `task-lists`. This rule flags any other element that uses the `disabled` attribute, since it has no native browser behavior on those elements and can be misleading to users and assistive technologies. - Rule source: `src/rules/a11y-disabled-attribute.ts` - Tests: 16 test cases covering valid elements, elements without disabled, and invalid elements (a, div, span, p, section, multiple) - Docs: `docs/rules/a11y-disabled-attribute.md` - Registered in `rules.ts` and `rules/index.ts` - Default config: disabled, severity warning Closes marcoroth/herb#1219 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/rules/a11y-disabled-attribute.md | 47 ++++++++++ javascript/packages/linter/src/rules.ts | 2 + .../src/rules/a11y-disabled-attribute.ts | 50 +++++++++++ javascript/packages/linter/src/rules/index.ts | 1 + .../rules/a11y-disabled-attribute.test.ts | 89 +++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 javascript/packages/linter/docs/rules/a11y-disabled-attribute.md create mode 100644 javascript/packages/linter/src/rules/a11y-disabled-attribute.ts create mode 100644 javascript/packages/linter/test/rules/a11y-disabled-attribute.test.ts diff --git a/javascript/packages/linter/docs/rules/a11y-disabled-attribute.md b/javascript/packages/linter/docs/rules/a11y-disabled-attribute.md new file mode 100644 index 000000000..d3d31c4f4 --- /dev/null +++ b/javascript/packages/linter/docs/rules/a11y-disabled-attribute.md @@ -0,0 +1,47 @@ +# Linter Rule: Disabled Attribute + +**Rule:** `a11y-disabled-attribute` + +## Description + +The `disabled` attribute is only valid on certain HTML elements. Using it on other elements has no effect and may confuse users or assistive technologies. + +## Rationale + +The HTML `disabled` attribute is a boolean attribute that only applies to form-related elements: `button`, `input`, `textarea`, `option`, `select`, `fieldset`, `optgroup`, and `task-lists`. Applying it to other elements like ``, `
`, or `` has no native browser behavior and can be misleading. + +## Examples + +### ✅ Good + +```erb + +``` + +```erb + +``` + +```erb + +``` + +### 🚫 Bad + +```erb +Go to GitHub +``` + +```erb +
Content
+``` + +```erb +Text +``` + +## References + +- [erblint-github: `DisabledAttribute`](https://github.com/github/erblint-github/blob/main/lib/erblint-github/linters/github/accessibility/disabled_attribute.rb) +- [erblint-github docs](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/disabled-attribute.md) +- [MDN: HTML attribute `disabled`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled) diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 7a4f935b3..23a543a69 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -1,5 +1,6 @@ import type { RuleClass } from "./types.js" +import { A11yDisabledAttributeRule } from "./rules/a11y-disabled-attribute.js" import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribute.js" import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js" import { A11yNoAutofocusAttributeRule } from "./rules/a11y-no-autofocus-attribute.js" @@ -103,6 +104,7 @@ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalizatio import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js" export const rules: RuleClass[] = [ + A11yDisabledAttributeRule, A11yNoAccesskeyAttributeRule, A11yNoAriaUnsupportedElementsRule, A11yNoAutofocusAttributeRule, diff --git a/javascript/packages/linter/src/rules/a11y-disabled-attribute.ts b/javascript/packages/linter/src/rules/a11y-disabled-attribute.ts new file mode 100644 index 000000000..80aca1ed3 --- /dev/null +++ b/javascript/packages/linter/src/rules/a11y-disabled-attribute.ts @@ -0,0 +1,50 @@ +import { hasAttribute, getTagLocalName } from "@herb-tools/core" + +import { BaseRuleVisitor } from "./rule-utils.js" +import { ParserRule } from "../types.js" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" +import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core" + +const VALID_DISABLED_TAGS = new Set([ + "button", "fieldset", "input", "optgroup", "option", "select", "textarea", "task-lists" +]) + +class DisabledAttributeVisitor extends BaseRuleVisitor { + visitHTMLOpenTagNode(node: HTMLOpenTagNode): void { + this.checkDisabledAttribute(node) + super.visitHTMLOpenTagNode(node) + } + + private checkDisabledAttribute(node: HTMLOpenTagNode): void { + if (!hasAttribute(node, "disabled")) return + + const tagName = getTagLocalName(node) + if (!tagName) return + if (VALID_DISABLED_TAGS.has(tagName)) return + + this.addOffense( + `The \`disabled\` attribute is only valid on ${[...VALID_DISABLED_TAGS].join(", ")}.`, + node.tag_name!.location, + ) + } +} + +export class A11yDisabledAttributeRule extends ParserRule { + static ruleName = "a11y-disabled-attribute" + static introducedIn = "unreleased" + + get defaultConfig(): FullRuleConfig { + return { + enabled: false, + severity: "warning" + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new DisabledAttributeVisitor(this.ruleName, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index 815093506..4a0554b21 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -1,3 +1,4 @@ +export * from "./a11y-disabled-attribute.js" export * from "./a11y-no-accesskey-attribute.js" export * from "./a11y-no-aria-unsupported-elements.js" export * from "./a11y-no-autofocus-attribute.js" diff --git a/javascript/packages/linter/test/rules/a11y-disabled-attribute.test.ts b/javascript/packages/linter/test/rules/a11y-disabled-attribute.test.ts new file mode 100644 index 000000000..f66a7674f --- /dev/null +++ b/javascript/packages/linter/test/rules/a11y-disabled-attribute.test.ts @@ -0,0 +1,89 @@ +import { describe, test } from "vitest" +import { A11yDisabledAttributeRule } from "../../src/rules/a11y-disabled-attribute.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(A11yDisabledAttributeRule) + +const MESSAGE = "The `disabled` attribute is only valid on button, fieldset, input, optgroup, option, select, textarea, task-lists." + +describe("a11y-disabled-attribute", () => { + describe("valid elements with disabled", () => { + test("passes for button with disabled", () => { + expectNoOffenses(``) + }) + + test("passes for input with disabled", () => { + expectNoOffenses(``) + }) + + test("passes for textarea with disabled", () => { + expectNoOffenses(``) + }) + + test("passes for select with disabled", () => { + expectNoOffenses(``) + }) + + test("passes for option with disabled", () => { + expectNoOffenses(``) + }) + + test("passes for fieldset with disabled", () => { + expectNoOffenses(`
`) + }) + + test("passes for optgroup with disabled", () => { + expectNoOffenses(``) + }) + }) + + describe("elements without disabled attribute", () => { + test("passes for div without disabled", () => { + expectNoOffenses(`
Content
`) + }) + + test("passes for anchor without disabled", () => { + expectNoOffenses(`Go to GitHub`) + }) + + test("passes for span without disabled", () => { + expectNoOffenses(`Text`) + }) + }) + + describe("invalid elements with disabled", () => { + test("fails for anchor with disabled", () => { + expectWarning(MESSAGE) + assertOffenses(`Go to GitHub`) + }) + + test("fails for div with disabled", () => { + expectWarning(MESSAGE) + assertOffenses(`
Content
`) + }) + + test("fails for span with disabled", () => { + expectWarning(MESSAGE) + assertOffenses(`Text`) + }) + + test("fails for paragraph with disabled", () => { + expectWarning(MESSAGE) + assertOffenses(`

Text

`) + }) + + test("fails for section with disabled", () => { + expectWarning(MESSAGE) + assertOffenses(`
Content
`) + }) + + test("fails for multiple invalid elements", () => { + expectWarning(MESSAGE) + expectWarning(MESSAGE) + assertOffenses(` +
Content
+ Link + `) + }) + }) +}) From 8e33ba4214380c623f79860e4d19b2018f29320f Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 24 Apr 2026 08:56:56 -0600 Subject: [PATCH 2/4] Fix introducedIn type: use this.version() instead of plain string Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- javascript/packages/linter/src/rules/a11y-disabled-attribute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/javascript/packages/linter/src/rules/a11y-disabled-attribute.ts b/javascript/packages/linter/src/rules/a11y-disabled-attribute.ts index 80aca1ed3..4572f2a5c 100644 --- a/javascript/packages/linter/src/rules/a11y-disabled-attribute.ts +++ b/javascript/packages/linter/src/rules/a11y-disabled-attribute.ts @@ -31,7 +31,7 @@ class DisabledAttributeVisitor extends BaseRuleVisitor { export class A11yDisabledAttributeRule extends ParserRule { static ruleName = "a11y-disabled-attribute" - static introducedIn = "unreleased" + static introducedIn = this.version("unreleased") get defaultConfig(): FullRuleConfig { return { From dfd372156b89646b6cc266efcdcbccb71839f430 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 24 Apr 2026 09:25:56 -0600 Subject: [PATCH 3/4] Update CLI snapshots for new a11y-disabled-attribute rule The new rule increases the 'not enabled' count from 10 to 11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/__snapshots__/cli.test.ts.snap | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index 1b38e8234..04fb24b07 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -111,7 +111,7 @@ test/fixtures/ignored.html.erb:8:8 Offenses 5 errors | 2 warnings (7 offenses across 1 file) Note 3 additional offenses reported (would have been ignored) Fixable 7 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > Excluded Files > skips excluded file in subdirectory with README.md project indicator 1`] = ` @@ -177,7 +177,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = ` @@ -202,7 +202,7 @@ test/fixtures/no-trailing-newline.html.erb:1:29 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 1 offense | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = ` @@ -266,7 +266,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:4 Checked 1 file Offenses 4 errors | 0 warnings (4 offenses across 1 file) Fixable 4 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > Ignores disabled rules 1`] = ` @@ -307,7 +307,7 @@ test/fixtures/ignored.html.erb:6:14 Checked 1 file Offenses 2 errors | 0 warnings | 3 ignored (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > allows tag.attributes in attribute position 1`] = ` @@ -346,7 +346,7 @@ test/fixtures/tag-attributes.html.erb:5:0 Checked 1 file Offenses 2 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > diplays only parsers errors if one is present 1`] = ` @@ -372,7 +372,7 @@ test/fixtures/parser-errors.html.erb:2:16 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > displays most violated rules with multiple offenses 1`] = ` @@ -587,7 +587,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:2 Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > displays rule offenses when showing all rules 1`] = ` @@ -684,7 +684,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0 Checked 1 file Offenses 4 errors | 2 warnings (6 offenses across 1 file) Fixable 6 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for bad file 1`] = ` @@ -723,7 +723,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = ` @@ -735,7 +735,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = ` @@ -795,7 +795,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = ` @@ -834,7 +834,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] = ` @@ -1038,7 +1038,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats simple output correctly 1`] = ` @@ -1057,7 +1057,7 @@ test/fixtures/test-file-simple.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats simple output for bad-file correctly 1`] = ` @@ -1076,7 +1076,7 @@ test/fixtures/bad-file.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats success output correctly 1`] = ` @@ -1088,7 +1088,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > handles boolean attributes 1`] = ` @@ -1100,7 +1100,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > handles multiple errors correctly 1`] = ` @@ -1135,7 +1135,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 1`] = ` @@ -1267,7 +1267,7 @@ test/fixtures/disabled-1.html.erb:14:19 Checked 1 file Offenses 2 errors | 6 warnings | 8 ignored (8 offenses across 1 file) Fixable 8 offenses | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 2`] = ` @@ -1470,7 +1470,7 @@ test/fixtures/disabled-2.html.erb:2:44 Checked 1 file Offenses 6 errors | 7 warnings | 5 ignored (13 offenses across 1 file) Fixable 13 offenses | 6 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > uses GitHub Actions format by default when GITHUB_ACTIONS is true 1`] = ` @@ -1530,5 +1530,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; From 2ce986d7baffbe8573a84b641c27116093627fb8 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 8 May 2026 14:44:24 -0600 Subject: [PATCH 4/4] Add a11y-disabled-attribute to rules README --- javascript/packages/linter/docs/rules/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index a506bccaf..b379c03bb 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -6,6 +6,7 @@ This page contains documentation for all Herb Linter rules. #### Accessibility +- [`a11y-disabled-attribute`](./a11y-disabled-attribute.md) - Prevent usage of the `disabled` attribute on unsupported elements - [`a11y-no-accesskey-attribute`](./a11y-no-accesskey-attribute.md) - Prevent usage of the `accesskey` attribute - [`a11y-no-aria-unsupported-elements`](./a11y-no-aria-unsupported-elements.md) - Prevent usage of ARIA on unsupported elements - [`a11y-no-autofocus-attribute`](./a11y-no-autofocus-attribute.md) - Prevent usage of the `autofocus` attribute