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
roletoken. %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
/ %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
- .auto_th_role(element) ⇒ Object
-
.compute(element) ⇒ Object
The computed role string, or "" when the element maps to no role.
- .explicit_role(element) ⇒ Object
-
.focusable?(element) ⇒ Boolean
Whether the element is focusable — a tabindex, or a natively-focusable control (a/area with href, or a non-disabled form control).
-
.heading_level(element) ⇒ Object
The heading level for a heading element: an explicit positive
aria-level, else thehNtag's number, else nil. -
.img_role(element) ⇒ Object
--- helpers --------------------------------------------------------.
- .implicit_role(element) ⇒ Object
- .input_role(element) ⇒ Object
-
.landmark_or_generic(element, landmark) ⇒ Object
/ - .named?(element) ⇒ Boolean
- .present?(value) ⇒ Boolean
-
.presentation_conflict?(element) ⇒ Boolean
Presentation conflict resolution (simplified): a focusable element or one with a global ARIA attribute keeps its implicit role.
- .same_node?(first, second) ⇒ Boolean
- .select_role(element) ⇒ Object
-
.th_role(element) ⇒ Object
A
is a column or row header. 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
.focusable?(element) ⇒ Boolean
Whether the element is focusable — a tabindex, or a natively-focusable control (a/area with href, or a non-disabled form control). Native focusability matters: is still a link.
240 241 242 243 244 245 246 247 248 249
# File 'lib/dommy/internal/aria_role.rb', line 240 def focusable?(element) return true if present?(element.get_attribute("tabindex")) case element.local_name.to_s.downcase when "a", "area" then element.has_attribute?("href") when "button", "select", "textarea" then !element.has_attribute?("disabled") when "input" then element.get_attribute("type").to_s.downcase != "hidden" && !element.has_attribute?("disabled") else false end end
.heading_level(element) ⇒ Object
The heading level for a heading element: an explicit positive
aria-level, else thehNtag'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
/ 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
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
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.
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
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 scopewins; 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