a11y-lint 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c17375bf978e252c281fec08a28a33d0fc22642e49d8aeb5af721454dc967f1
4
- data.tar.gz: 9c89ac25d4163cb4807b31c396114bac42a153ecd7309effb4c77fd19c292c91
3
+ metadata.gz: 17ce590d5fc702bdcf3feb01606767c2aaa0938ca6a40b3f456737d7e9f519f2
4
+ data.tar.gz: b1ec19b360ccbc592fd42a9848cfdb351b924f3752b3041afa063f64bfd3e380
5
5
  SHA512:
6
- metadata.gz: 637000bab2f93eacb3a7a799a22e8844c450f7a0da6226cada99866c57accc4bb60a4dfc36bbf7355fa35aaaf155a88bf593caef26c159d8451b0053b83f5105
7
- data.tar.gz: ae04ef4e64f360df93fae63aaa502f08462958f29c3fa5e1f9afe89b263dd0d1a8ec2f0212e35507bb30ddfab7804ae50f88e9278b3e565ca591c889beb739dd
6
+ metadata.gz: fa1db2cd985c446515c75808512e3663859af93d254949e51fb3638e0ee1fe7cf2172a55686f66b937476e8739a9f24910b8662ef03e285445dfab9079983be3
7
+ data.tar.gz: 045c3ec7a7e4442a47517020aa2cf3da15496a3e304a1768e8e067d7767f2cd0693db025711f25c05e02736c6d3f0095378e9dfb68ed1a3b19ebcb3e8232a49e
@@ -0,0 +1,37 @@
1
+ ---
2
+ description: Capture a free-form idea as a GitHub issue in the current repo
3
+ argument-hint: <idea>
4
+ allowed-tools: Bash(gh issue create:*), Bash(gh repo view:*), Bash(grep:*), Bash(rg:*), Bash(ls:*), Bash(find:*), Read, Glob, Grep
5
+ ---
6
+
7
+ Take the free-form idea below and turn it into a well-formed GitHub issue in the current repo.
8
+
9
+ Idea: $ARGUMENTS
10
+
11
+ ## What to do
12
+
13
+ 1. **Refine** the idea into:
14
+ - A clear **title** (≤ ~70 chars, sentence case, no trailing period)
15
+ - A **body** with these sections (omit any that don't apply):
16
+ - **Problem / idea** — restate the idea clearly in 1–3 sentences
17
+ - **Proposed approach** — only include if the approach is obvious from the idea
18
+ - **Files likely involved** — only include if you can quickly identify them from the repo (e.g. a new rule idea should point at the right WCAG principle directory under `lib/a11y/lint/rules/`)
19
+
20
+ 2. **Clarify only if necessary** — one round, max. Voice input drops context, but too many questions defeat frictionless capture. Skip clarification when the idea is unambiguous. Typical clarifications: bug vs. feature, which rule/file, which template pipeline (Slim/ERB/Phlex).
21
+
22
+ 3. **Create the issue** with `gh issue create --title "..." --body "$(cat <<'EOF' ... EOF)"` against the current repo. Do **not** pass `--repo`. Do **not** add labels.
23
+
24
+ 4. **Return the issue URL** that `gh issue create` prints. Keep your final reply to one line — just the URL (and a one-line summary if helpful).
25
+
26
+ ## Out of scope
27
+
28
+ - Auto-suggesting labels
29
+ - Cross-repo support
30
+ - Duplicate detection / linking related issues
31
+ - Editing or closing existing issues
32
+
33
+ ## Style notes
34
+
35
+ - Bias toward **fast capture** over thoroughness — the issue can always be edited on GitHub.
36
+ - Don't pad the body with boilerplate. If "Proposed approach" or "Files likely involved" isn't obvious, leave it out.
37
+ - Match the tone of recent issues in this repo if you can quickly check one.
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.1] - 2026-05-03
11
+
12
+ ### Fixed
13
+
14
+ - `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags whose accessible text comes from a phlex-rails value helper (`t`, `translate`, `l`, `localize`, `pluralize`, `truncate`, the `number_to_*` / `number_with_*` helpers, `highlight`, `excerpt`) or Phlex's built-in `text`. Previously only `plain` was recognized
15
+
16
+ ## [0.14.0] - 2026-04-28
17
+
18
+ ### Changed
19
+
20
+ - **Breaking:** `SimpleFormSelectMissingAccessibleName` is now `SimpleFormInputMissingAccessibleName` and flags any Simple Form `form.input` with `label: false`/`label: ""` that has no `aria-label` or `aria-labelledby` in `input_html`, regardless of input type (WCAG 4.1.2). `as: :hidden` is skipped. Projects that pinned the rule by name in `.a11y-lint.yml` need to update the key
21
+
10
22
  ## [0.13.0] - 2026-04-28
11
23
 
12
24
  ### Added
@@ -0,0 +1,35 @@
1
+ # Contributing to the a11y-lint docs site
2
+
3
+ ## Authoring rule reference pages
4
+
5
+ Rule pages live in `src/rules/`, one Markdown file per WCAG concept. Each rule class is an anchor section within that page.
6
+
7
+ ### Showing example code in multiple template languages
8
+
9
+ a11y-lint supports ERB, Slim, and Phlex. When a rule page shows example code, render it through the `Shared::CodeTabs` component so all three pipelines collapse into one tabbed block instead of three stacked code blocks.
10
+
11
+ ERB is required and is the default tab. Slim and Phlex are optional — omit either kwarg and the tab won't render.
12
+
13
+ ```erb
14
+ <%= render Shared::CodeTabs.new(
15
+ erb: <<~ERB,
16
+ <img src="hero.jpg" alt="Team celebrating after a product launch">
17
+ ERB
18
+ slim: <<~SLIM,
19
+ img src="hero.jpg" alt="Team celebrating after a product launch"
20
+ SLIM
21
+ phlex: <<~PHLEX,
22
+ img(src: "hero.jpg", alt: "Team celebrating after a product launch")
23
+ PHLEX
24
+ ) %>
25
+ ```
26
+
27
+ The component takes care of:
28
+
29
+ - Rouge syntax highlighting per tab (using the `erb`, `slim`, and `ruby` lexers).
30
+ - WAI-ARIA tab pattern: `role="tablist"` / `role="tab"` / `role="tabpanel"` with full `aria-*` wiring.
31
+ - Keyboard navigation: Left/Right arrows move between tabs, Home/End jump to the first/last tab, Enter/Space activate, Tab moves into the panel.
32
+ - A no-JavaScript fallback that shows the ERB panel only.
33
+ - `prefers-reduced-motion`.
34
+
35
+ Multiple instances on the same page get unique IDs automatically.
@@ -0,0 +1,57 @@
1
+ .code-tabs {
2
+ margin: 1.5rem 0;
3
+ }
4
+
5
+ .code-tabs__tablist {
6
+ display: none;
7
+ gap: 0.25rem;
8
+ border-bottom: 1px solid #ddd;
9
+ }
10
+
11
+ .code-tabs.is-enhanced .code-tabs__tablist {
12
+ display: flex;
13
+ }
14
+
15
+ .code-tabs__tab {
16
+ background: transparent;
17
+ border: 1px solid transparent;
18
+ border-bottom: none;
19
+ border-radius: 4px 4px 0 0;
20
+ padding: 0.5rem 1rem;
21
+ font: inherit;
22
+ font-weight: 600;
23
+ color: var(--body-color);
24
+ cursor: pointer;
25
+ margin-bottom: -1px;
26
+ }
27
+
28
+ .code-tabs__tab[aria-selected="true"] {
29
+ background: white;
30
+ border-color: #ddd;
31
+ color: var(--heading-color);
32
+ }
33
+
34
+ .code-tabs__tab:focus-visible,
35
+ .code-tabs__panel:focus-visible {
36
+ outline: 2px solid var(--action-color);
37
+ outline-offset: 2px;
38
+ }
39
+
40
+ .code-tabs__panel {
41
+ border: 1px solid #ddd;
42
+ border-radius: 0 4px 4px 4px;
43
+ }
44
+
45
+ .code-tabs.is-enhanced .code-tabs__panel {
46
+ border-top-left-radius: 0;
47
+ }
48
+
49
+ .code-tabs__panel pre {
50
+ margin: 0;
51
+ }
52
+
53
+ @media (prefers-reduced-motion: reduce) {
54
+ .code-tabs__tab {
55
+ transition: none;
56
+ }
57
+ }
@@ -0,0 +1,30 @@
1
+ <div class="code-tabs">
2
+ <div class="code-tabs__tablist" role="tablist" aria-label="Template language">
3
+ <% tabs.each_with_index do |tab, i| %>
4
+ <button
5
+ type="button"
6
+ role="tab"
7
+ id="<%= tab_id(tab[:key]) %>"
8
+ aria-controls="<%= panel_id(tab[:key]) %>"
9
+ aria-selected="<%= i.zero? %>"
10
+ tabindex="<%= i.zero? ? 0 : -1 %>"
11
+ class="code-tabs__tab"
12
+ >
13
+ <%= tab[:label] %>
14
+ </button>
15
+ <% end %>
16
+ </div>
17
+
18
+ <% tabs.each_with_index do |tab, i| %>
19
+ <div
20
+ role="tabpanel"
21
+ id="<%= panel_id(tab[:key]) %>"
22
+ aria-labelledby="<%= tab_id(tab[:key]) %>"
23
+ class="code-tabs__panel"
24
+ tabindex="0"
25
+ <%= "hidden" unless i.zero? %>
26
+ >
27
+ <%= raw highlighted(tab[:key], tab[:lexer]) %>
28
+ </div>
29
+ <% end %>
30
+ </div>
@@ -0,0 +1,54 @@
1
+ function initCodeTabs(root) {
2
+ if (root.classList.contains("is-enhanced")) return
3
+
4
+ const tabs = Array.from(root.querySelectorAll('[role="tab"]'))
5
+ const panels = Array.from(root.querySelectorAll('[role="tabpanel"]'))
6
+ if (tabs.length === 0) return
7
+
8
+ root.classList.add("is-enhanced")
9
+
10
+ function activate(index, { focus = true } = {}) {
11
+ tabs.forEach((tab, i) => {
12
+ const selected = i === index
13
+ tab.setAttribute("aria-selected", String(selected))
14
+ tab.setAttribute("tabindex", selected ? "0" : "-1")
15
+ panels[i].hidden = !selected
16
+ })
17
+ if (focus) tabs[index].focus()
18
+ }
19
+
20
+ tabs.forEach((tab, i) => {
21
+ tab.addEventListener("click", () => activate(i))
22
+ tab.addEventListener("keydown", (event) => {
23
+ let next = null
24
+ switch (event.key) {
25
+ case "ArrowRight":
26
+ next = (i + 1) % tabs.length
27
+ break
28
+ case "ArrowLeft":
29
+ next = (i - 1 + tabs.length) % tabs.length
30
+ break
31
+ case "Home":
32
+ next = 0
33
+ break
34
+ case "End":
35
+ next = tabs.length - 1
36
+ break
37
+ default:
38
+ return
39
+ }
40
+ event.preventDefault()
41
+ activate(next)
42
+ })
43
+ })
44
+ }
45
+
46
+ function initAllCodeTabs() {
47
+ document.querySelectorAll(".code-tabs").forEach(initCodeTabs)
48
+ }
49
+
50
+ if (document.readyState === "loading") {
51
+ document.addEventListener("DOMContentLoaded", initAllCodeTabs)
52
+ } else {
53
+ initAllCodeTabs()
54
+ }
@@ -0,0 +1,36 @@
1
+ require "rouge"
2
+ require "securerandom"
3
+
4
+ class Shared::CodeTabs < Bridgetown::Component
5
+ TABS = [
6
+ { key: :erb, label: "ERB", lexer: "erb" },
7
+ { key: :slim, label: "Slim", lexer: "slim" },
8
+ { key: :phlex, label: "Phlex", lexer: "ruby" }
9
+ ].freeze
10
+
11
+ attr_reader :id
12
+
13
+ def initialize(erb:, slim: nil, phlex: nil)
14
+ @samples = { erb: erb, slim: slim, phlex: phlex }
15
+ @id = "code-tabs-#{SecureRandom.hex(4)}"
16
+ end
17
+
18
+ def tabs
19
+ TABS.select { |tab| @samples[tab[:key]] }
20
+ end
21
+
22
+ def tab_id(key)
23
+ "#{id}-tab-#{key}"
24
+ end
25
+
26
+ def panel_id(key)
27
+ "#{id}-panel-#{key}"
28
+ end
29
+
30
+ def highlighted(key, lexer_name)
31
+ formatter = Rouge::Formatters::HTML.new
32
+ lexer = Rouge::Lexer.find(lexer_name) || Rouge::Lexers::PlainText.new
33
+ inner = formatter.format(lexer.lex(@samples[key].to_s))
34
+ %(<pre class="highlight"><code>#{inner}</code></pre>)
35
+ end
36
+ end
@@ -29,15 +29,19 @@ In Simple Form, both go inside `input_html`:
29
29
 
30
30
  A placeholder is **not** a label. It disappears as soon as the user types, browsers don't reliably expose it as the accessible name, and it fails users who rely on the prompt staying visible while they fill the field.
31
31
 
32
- ## SimpleFormSelectMissingAccessibleName
33
- {:#simple-form-select-missing-accessible-name}
32
+ ## SimpleFormInputMissingAccessibleName
33
+ {:#simple-form-input-missing-accessible-name}
34
34
 
35
- Applies to: Simple Form `form.input` calls that render a `<select>`either `collection:` is passed, or `as: :select` is set when `label: false` or `label: ""` hides the visible label.
35
+ Applies to: Simple Form `form.input` calls with `label: false` or `label: ""` regardless of input type. `as: :hidden` is skipped, since hidden inputs don't render a visible control.
36
36
 
37
37
  The rule passes when `input_html` provides `aria-label` or `aria-labelledby`. Both hash and string keys are accepted (`aria: { label: "..." }` and `"aria-label" => "..."`).
38
38
 
39
39
  ### Bad
40
40
 
41
+ ```erb
42
+ <%%= form.input :name, label: false %>
43
+ ```
44
+
41
45
  ```erb
42
46
  <%%= form.input :sort_by, collection: opts, label: false %>
43
47
  ```
@@ -48,17 +52,18 @@ The rule passes when `input_html` provides `aria-label` or `aria-labelledby`. Bo
48
52
 
49
53
  ### Good
50
54
 
51
- Render a visible label by removing `label: false`:
55
+ Render a visible label by removing `label: false` and letting Simple Form generate one from the attribute name, or by passing an explicit string:
52
56
 
53
57
  ```erb
58
+ <%%= form.input :name %>
54
59
  <%%= form.input :sort_by, collection: opts, label: "Sort by" %>
55
60
  ```
56
61
 
57
62
  Or, when there's no visible label, supply `aria-label`:
58
63
 
59
64
  ```erb
60
- <%%= form.input :sort_by, collection: opts, label: false,
61
- input_html: { aria: { label: "Sort by" } } %>
65
+ <%%= form.input :name, label: false,
66
+ input_html: { aria: { label: "Name" } } %>
62
67
  ```
63
68
 
64
69
  Or `aria-labelledby` pointing to existing visible text:
@@ -69,19 +74,25 @@ Or `aria-labelledby` pointing to existing visible text:
69
74
  input_html: { aria: { labelledby: "sort-label" } } %>
70
75
  ```
71
76
 
77
+ `as: :hidden` doesn't render a visible control, so it's exempt:
78
+
79
+ ```erb
80
+ <%%= form.input :secret, as: :hidden, label: false %>
81
+ ```
82
+
72
83
  ### Slim equivalent
73
84
 
74
85
  ```slim
75
- = form.input :sort_by, collection: opts, label: false, input_html: { aria: { label: "Sort by" } }
86
+ = form.input :name, label: false, input_html: { aria: { label: "Name" } }
76
87
  ```
77
88
 
78
89
  ## What this rule doesn't catch
79
90
 
80
91
  This rule has a deliberately narrow scope. Things it doesn't flag:
81
92
 
82
- - **Plain `<select>` elements.** The rule targets Simple Form's `form.input` helper. A bare `<select>` with no associated `<label>` is not reported.
93
+ - **Plain HTML form controls.** The rule targets Simple Form's `form.input` helper. A bare `<input>`, `<select>`, or `<textarea>` with no associated `<label>` is not reported.
83
94
  - **Other Simple Form helpers.** `form.select`, `form.association`, and similar helpers aren't checked — only `form.input`.
84
- - **Other input types.** `form.input` rendering as text, email, checkbox, radio, etc. is not checked. Selects are flagged because the `label: false` + missing aria pattern is unusually common for sort and filter dropdowns.
95
+ - **Inputs without `label: false`.** Simple Form auto-generates a label from the attribute name, so `form.input :name` is treated as having a visible label. The rule only kicks in once the label is explicitly suppressed.
85
96
  - **Phlex views.** The rule is a no-op in Phlex by design. The Phlex pipeline only walks receiverless calls, so `form.input(...)` isn't surfaced as a candidate node. Simple Form code embedded inside a Phlex component won't be checked.
86
97
  - **Bad accessible name content.** `aria: { label: "" }` will pass the rule check above (an empty aria-label is its own problem, but not this rule's). `aria: { label: "select" }` will pass. The linter checks that an aria attribute is present, not that the value is useful.
87
98
  - **Dynamic accessible names.** `aria: { label: variable }` satisfies the rule even if `variable` is `nil` at runtime.
@@ -67,15 +67,31 @@ Applies to: HTML `<img>` elements.
67
67
 
68
68
  ### Bad
69
69
 
70
- ```erb
71
- <img src="hero.jpg">
72
- ```
70
+ <%= render Shared::CodeTabs.new(
71
+ erb: <<~ERB,
72
+ <img src="hero.jpg">
73
+ ERB
74
+ slim: <<~SLIM,
75
+ img src="hero.jpg"
76
+ SLIM
77
+ phlex: <<~PHLEX,
78
+ img(src: "hero.jpg")
79
+ PHLEX
80
+ ) %>
73
81
 
74
82
  ### Good
75
83
 
76
- ```erb
77
- <img src="hero.jpg" alt="Team celebrating after a product launch">
78
- ```
84
+ <%= render Shared::CodeTabs.new(
85
+ erb: <<~ERB,
86
+ <img src="hero.jpg" alt="Team celebrating after a product launch">
87
+ ERB
88
+ slim: <<~SLIM,
89
+ img src="hero.jpg" alt="Team celebrating after a product launch"
90
+ SLIM
91
+ phlex: <<~PHLEX,
92
+ img(src: "hero.jpg", alt: "Team celebrating after a product launch")
93
+ PHLEX
94
+ ) %>
79
95
 
80
96
  For a decorative image, use an empty `alt` so assistive tech skips it:
81
97
 
@@ -9,6 +9,19 @@ module A11y
9
9
  class PhlexRunner
10
10
  PHLEX_PATTERN = /\bdef\s+view_template\b/
11
11
 
12
+ # Phlex auto-emits the return value of these calls into the document:
13
+ # `plain` / `text` are built-in; the rest are registered as value
14
+ # helpers by phlex-rails via `register_value_helper`.
15
+ TEXT_CALLS = %w[
16
+ plain text
17
+ t translate l localize
18
+ pluralize truncate
19
+ number_to_currency number_to_human number_to_human_size
20
+ number_to_percentage number_to_phone
21
+ number_with_delimiter number_with_precision
22
+ highlight excerpt
23
+ ].to_set.freeze
24
+
12
25
  def initialize(rules = nil, configuration: Configuration.new)
13
26
  @rules = rules || configuration.enabled_rules
14
27
  @configuration = configuration
@@ -161,7 +174,7 @@ module A11y
161
174
  end
162
175
 
163
176
  def text_call?(node)
164
- receiverless_call?(node) && node.name.to_s == "plain"
177
+ receiverless_call?(node) && TEXT_CALLS.include?(node.name.to_s)
165
178
  end
166
179
 
167
180
  def receiverless_call?(node)
@@ -3,11 +3,11 @@
3
3
  module A11y
4
4
  module Lint
5
5
  module Rules
6
- # Checks that Simple Form `form.input` calls rendering a select
7
- # (via `collection:` or `as: :select`) with `label: false` /
6
+ # Checks that Simple Form `form.input` calls with `label: false` /
8
7
  # `label: ""` provide an accessible name through `input_html:`
9
- # `aria-label` or `aria-labelledby` (WCAG 4.1.2).
10
- class SimpleFormSelectMissingAccessibleName < NodeRule
8
+ # `aria-label` or `aria-labelledby` (WCAG 4.1.2). Skips
9
+ # `as: :hidden`, which doesn't render a visible control.
10
+ class SimpleFormInputMissingAccessibleName < NodeRule
11
11
  def check
12
12
  return if no_offense?
13
13
 
@@ -18,14 +18,13 @@ module A11y
18
18
 
19
19
  def no_offense?
20
20
  !helper_call ||
21
- !select_like? ||
22
21
  !helper_call.label_hidden? ||
22
+ hidden_input? ||
23
23
  aria_label_in_input_html?
24
24
  end
25
25
 
26
- def select_like?
27
- helper_call.keyword?(:collection) ||
28
- helper_call.keyword_symbol?(:as, :select)
26
+ def hidden_input?
27
+ helper_call.keyword_symbol?(:as, :hidden)
29
28
  end
30
29
 
31
30
  def aria_label_in_input_html?
@@ -53,7 +52,7 @@ module A11y
53
52
 
54
53
  def offense_message
55
54
  <<~MSG.strip
56
- form.input select missing an accessible name \
55
+ form.input missing an accessible name \
57
56
  requires aria-label or aria-labelledby in input_html (WCAG 4.1.2)
58
57
  MSG
59
58
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.13.0"
5
+ VERSION = "0.14.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: a11y-lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -45,6 +45,7 @@ executables:
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
+ - ".claude/commands/task.md"
48
49
  - ".claude/rules/testing.md"
49
50
  - ".rubocop.yml"
50
51
  - CHANGELOG.md
@@ -55,6 +56,7 @@ files:
55
56
  - Rakefile
56
57
  - docs/.gitignore
57
58
  - docs/.ruby-version
59
+ - docs/CONTRIBUTING.md
58
60
  - docs/Gemfile
59
61
  - docs/Gemfile.lock
60
62
  - docs/README.md
@@ -80,6 +82,10 @@ files:
80
82
  - docs/src/404.html
81
83
  - docs/src/500.html
82
84
  - docs/src/CNAME
85
+ - docs/src/_components/shared/code_tabs.css
86
+ - docs/src/_components/shared/code_tabs.erb
87
+ - docs/src/_components/shared/code_tabs.js
88
+ - docs/src/_components/shared/code_tabs.rb
83
89
  - docs/src/_components/shared/navbar.erb
84
90
  - docs/src/_components/shared/navbar.rb
85
91
  - docs/src/_data/site_metadata.yml
@@ -124,7 +130,7 @@ files:
124
130
  - lib/a11y/lint/rules/robust/button_missing_accessible_name.rb
125
131
  - lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb
126
132
  - lib/a11y/lint/rules/robust/link_to_missing_accessible_name.rb
127
- - lib/a11y/lint/rules/robust/simple_form_select_missing_accessible_name.rb
133
+ - lib/a11y/lint/rules/robust/simple_form_input_missing_accessible_name.rb
128
134
  - lib/a11y/lint/slim_node.rb
129
135
  - lib/a11y/lint/slim_runner.rb
130
136
  - lib/a11y/lint/version.rb