Module: Dommy::Internal::AriaRole

Defined in:
lib/dommy/internal/aria_role.rb

Overview

Computes the WAI-ARIA computed role of an element — the value WPT's test_driver.get_computed_role returns and Testing Library's getByRole relies on. An explicit, valid role attribute wins; otherwise the implicit role is derived from the HTML element (HTML-AAM), with the common context-sensitive cases (header/footer landmarks, named section/form, input/select by type) handled. Layout- or tree-dependent subtleties (heading levels, th scope/row context) are intentionally simplified.

Constant Summary collapse

ABSTRACT =

Abstract roles can never be a computed role; an explicit one is ignored.

%w[
  command composite input landmark range roletype section sectionhead
  select structure widget window
].freeze
CONCRETE =

Concrete ARIA 1.2 roles accepted as an explicit role token.

%w[
  alert alertdialog application article banner blockquote button caption
  cell checkbox code columnheader combobox comment complementary
  contentinfo definition deletion dialog directory document emphasis
  feed figure form generic grid gridcell group heading img insertion link
  list listbox listitem log main mark marquee math menu menubar menuitem
  menuitemcheckbox menuitemradio meter navigation none note option
  paragraph presentation progressbar radio radiogroup region row rowgroup
  rowheader scrollbar search searchbox separator slider spinbutton status
  strong subscript suggestion superscript switch tab table tablist tabpanel
  term textbox time timer toolbar tooltip tree treegrid treeitem
].freeze
SYNONYMS =

Deprecated role synonyms normalized to their canonical computed role.

{ "directory" => "list", "image" => "img", "presentation" => "none" }.freeze
NAME_REQUIRED =

Roles that require an author-provided accessible name; without one the token is skipped (role fallback continues to the next token, else the implicit role). Per WPT get_computed_role; note Chromium's ariaSnapshot differs for an unnamed terminal role="region"/"form" (it keeps the role).

%w[form region].freeze
SECTIONING =

Sectioning content under which

/
are generic rather than banner/contentinfo landmarks.

%w[article aside main nav section].freeze
INPUT_ROLES =
{
  "button" => "button", "submit" => "button", "reset" => "button",
  "image" => "button", "file" => "button", "checkbox" => "checkbox", "radio" => "radio",
  "range" => "slider", "number" => "spinbutton", "email" => "textbox",
  "tel" => "textbox", "text" => "textbox", "url" => "textbox", "search" => "searchbox",
  # type=password has no ARIA role per HTML-AAM (to avoid exposing the
  # value) but Chromium / Playwright expose it as a textbox. The
  # date/time family and color likewise expose as a textbox.
  "password" => "textbox", "color" => "textbox", "date" => "textbox",
  "datetime-local" => "textbox", "month" => "textbox", "week" => "textbox",
  "time" => "textbox"
}.freeze

Class Method Summary collapse

Class Method Details

.auto_th_role(element) ⇒ Object



161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/dommy/internal/aria_role.rb', line 161

def auto_th_role(element)
  row = element.respond_to?(:parent_element) ? element.parent_element : nil
  return "columnheader" unless row

  cells = row.children.select { |cell| %w[td th].include?(cell.local_name.to_s.downcase) }
  index = cells.index { |cell| same_node?(cell, element) }
  return "columnheader" unless index

  previous = index.zero? ? nil : cells[index - 1]
  neighbors = [previous, cells[index + 1]].compact
  neighbors.any? { |cell| cell.local_name.to_s.casecmp?("td") } ? "rowheader" : "columnheader"
end

.compute(element) ⇒ Object

The computed role string, or "" when the element maps to no role.



49
50
51
# File 'lib/dommy/internal/aria_role.rb', line 49

def compute(element)
  explicit_role(element) || implicit_role(element) || ""
end

.explicit_role(element) ⇒ Object



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/dommy/internal/aria_role.rb', line 64

def explicit_role(element)
  raw = element.get_attribute("role").to_s.strip
  return nil if raw.empty?

  raw.split(/\s+/).each do |token|
    role = SYNONYMS.fetch(token.downcase, token.downcase)
    next unless CONCRETE.include?(role) && !ABSTRACT.include?(role)
    # region / form need an author-supplied name; otherwise fall through to
    # the next token (or the implicit role).
    next if NAME_REQUIRED.include?(role) && !named?(element)
    # A presentational role yields to the implicit role when the element is
    # focusable or carries a global ARIA state/property (conflict
    # resolution); otherwise it stands.
    return nil if role == "none" && presentation_conflict?(element)

    return role
  end
  nil
end

.heading_level(element) ⇒ Object

The heading level for a heading element: an explicit positive aria-level, else the hN tag's number, else nil. The role itself stays "heading" (level is a separate ARIA property the a11y tree emits).



56
57
58
59
60
61
62
# File 'lib/dommy/internal/aria_role.rb', line 56

def heading_level(element)
  aria = element.get_attribute("aria-level").to_s
  return aria.to_i if aria.match?(/\A\d+\z/) && aria.to_i.positive?

  tag = element.local_name.to_s.downcase
  tag.match?(/\Ah[1-6]\z/) ? tag[1].to_i : nil
end

.img_role(element) ⇒ Object

--- helpers --------------------------------------------------------



141
142
143
144
145
146
147
# File 'lib/dommy/internal/aria_role.rb', line 141

def img_role(element)
  alt = element.get_attribute("alt")
  # An explicitly empty alt makes the image presentational. Return the
  # canonical "none" (not its "presentation" synonym) so the accessibility
  # tree flattens it like any other presentational node.
  alt == "" ? "none" : "img"
end

.implicit_role(element) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/dommy/internal/aria_role.rb', line 84

def implicit_role(element)
  case element.tag_name.to_s.downcase
  when "a", "area" then element.has_attribute?("href") ? "link" : (element.tag_name.casecmp?("a") ? "generic" : nil)
  when "article" then "article"
  when "aside" then "complementary"
  when "b", "bdi", "bdo", "data", "i", "q", "samp", "small", "span", "u", "div" then "generic"
  when "blockquote" then "blockquote"
  when "button" then "button"
  when "caption" then "caption"
  when "code" then "code"
  when "del" then "deletion"
  when "dialog" then "dialog"
  when "dd" then "definition"
  when "details" then "group"
  when "dfn" then "term"
  when "dt" then "term"
  when "em" then "emphasis"
  when "fieldset" then "group"
  when "figure" then "figure"
  when "footer" then landmark_or_generic(element, "contentinfo")
  when "form" then named?(element) ? "form" : "generic"
  when "h1", "h2", "h3", "h4", "h5", "h6" then "heading"
  when "header" then landmark_or_generic(element, "banner")
  when "hr" then "separator"
  when "img" then img_role(element)
  when "input" then input_role(element)
  when "ins" then "insertion"
  when "li" then "listitem"
  when "main" then "main"
  when "mark" then "mark"
  when "math" then "math"
  when "menu", "ol", "ul" then "list"
  when "meter" then "meter"
  when "nav" then "navigation"
  when "optgroup" then "group"
  when "option" then "option"
  when "output" then "status"
  when "p" then "paragraph"
  when "progress" then "progressbar"
  when "search" then "search"
  when "section" then named?(element) ? "region" : "generic"
  when "select" then select_role(element)
  when "strong" then "strong"
  when "sub" then "subscript"
  when "sup" then "superscript"
  when "table" then "table"
  when "tbody", "tfoot", "thead" then "rowgroup"
  when "td" then "cell"
  when "textarea" then "textbox"
  when "th" then th_role(element)
  when "time" then "time"
  when "tr" then "row"
  end
end

.input_role(element) ⇒ Object



191
192
193
194
195
196
197
198
# File 'lib/dommy/internal/aria_role.rb', line 191

def input_role(element)
  type = element.get_attribute("type").to_s.downcase
  return "textbox" if type.empty?

  # type=search is searchbox only without a suggestions list; the common
  # case has no list, so map to searchbox.
  INPUT_ROLES[type]
end

.landmark_or_generic(element, landmark) ⇒ Object

/
are landmarks only when not scoped to sectioning content; otherwise generic.


208
209
210
211
212
213
214
215
216
# File 'lib/dommy/internal/aria_role.rb', line 208

def landmark_or_generic(element, landmark)
  node = element.respond_to?(:__dommy_backend_node__) ? element.__dommy_backend_node__&.parent : nil
  while node && node.respond_to?(:name)
    return "generic" if SECTIONING.include?(node.name.to_s.downcase)

    node = node.parent
  end
  landmark
end

.named?(element) ⇒ Boolean

Returns:

  • (Boolean)


218
219
220
221
222
223
# File 'lib/dommy/internal/aria_role.rb', line 218

def named?(element)
  return true if present?(element.get_attribute("aria-label"))
  return true if present?(element.get_attribute("aria-labelledby"))

  present?(element.get_attribute("title"))
end

.present?(value) ⇒ Boolean

Returns:

  • (Boolean)


225
226
227
# File 'lib/dommy/internal/aria_role.rb', line 225

def present?(value)
  !value.nil? && !value.to_s.empty?
end

.presentation_conflict?(element) ⇒ Boolean

Presentation conflict resolution (simplified): a focusable element or one with a global ARIA attribute keeps its implicit role.

Returns:

  • (Boolean)


231
232
233
234
235
# File 'lib/dommy/internal/aria_role.rb', line 231

def presentation_conflict?(element)
  return true if focusable?(element)

  %w[aria-label aria-labelledby aria-describedby].any? { |a| present?(element.get_attribute(a)) }
end

.same_node?(first, second) ⇒ Boolean

Returns:

  • (Boolean)


174
175
176
# File 'lib/dommy/internal/aria_role.rb', line 174

def same_node?(first, second)
  first && second && first.__dommy_backend_node__ == second.__dommy_backend_node__
end

.select_role(element) ⇒ Object



200
201
202
203
204
# File 'lib/dommy/internal/aria_role.rb', line 200

def select_role(element)
  multiple = element.has_attribute?("multiple")
  size = element.get_attribute("size").to_s.to_i
  multiple || size > 1 ? "listbox" : "combobox"
end

.th_role(element) ⇒ Object

A is a column or row header. An explicit scope wins; otherwise the auto algorithm: a header cell that borders a data cell () within its row heads that row, so it is a row header; a header cell with only header neighbors heads its column.



153
154
155
156
157
158
159
# File 'lib/dommy/internal/aria_role.rb', line 153

def th_role(element)
  case element.get_attribute("scope").to_s.downcase
  when "row", "rowgroup" then "rowheader"
  when "col", "colgroup" then "columnheader"
  else auto_th_role(element)
  end
end