///|
/// Accessible Name and Description Computation
/// Based on WAI-ARIA Accessible Name and Description Computation 1.2
/// https://www.w3.org/TR/accname-1.2/
// =============================================================================
// Accessible Name Computation
// =============================================================================
///|
/// Context for name computation (tracks visited nodes to avoid cycles)
priv struct NameComputationContext {
visited : Map[String, Bool]
is_referencing : Bool // true when following aria-labelledby/describedby
}
///|
fn NameComputationContext::new() -> NameComputationContext {
{ visited: {}, is_referencing: false }
}
///|
fn NameComputationContext::with_referencing(
self : NameComputationContext,
is_referencing : Bool,
) -> NameComputationContext {
{ ..self, is_referencing, }
}
///|
fn NameComputationContext::mark_visited(
self : NameComputationContext,
id : String,
) -> NameComputationContext {
let new_visited = self.visited
new_visited[id] = true
{ ..self, visited: new_visited }
}
///|
fn NameComputationContext::is_visited(
self : NameComputationContext,
id : String,
) -> Bool {
self.visited.contains(id)
}
// =============================================================================
// Main Entry Point
// =============================================================================
///|
/// Compute the accessible name for an HTML element
/// Follows the accname-1.2 algorithm
pub fn compute_accessible_name(
element : @html.Element,
find_by_id : (String) -> @html.Element?,
) -> String? {
compute_accessible_name_with_label(element, find_by_id, fn(_id) { None })
}
///|
/// Compute the accessible name with label lookup support
pub fn compute_accessible_name_with_label(
element : @html.Element,
find_by_id : (String) -> @html.Element?,
find_label_for_id : (String) -> @html.Element?,
) -> String? {
compute_accessible_name_full(element, find_by_id, find_label_for_id, None)
}
///|
/// Compute the accessible name with full label support (including encapsulation)
pub fn compute_accessible_name_full(
element : @html.Element,
find_by_id : (String) -> @html.Element?,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
) -> String? {
compute_accessible_name_with_hidden_subtree_check(
element,
find_by_id,
find_label_for_id,
encapsulating_label,
fn(_element) { false },
)
}
///|
fn compute_accessible_name_with_hidden_subtree_check(
element : @html.Element,
find_by_id : (String) -> @html.Element?,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
is_in_hidden_subtree : (@html.Element) -> Bool,
) -> String? {
let ctx = NameComputationContext::new()
let name = compute_name_step(
element, ctx, find_by_id, find_label_for_id, encapsulating_label, is_in_hidden_subtree,
)
let trimmed = name.trim().to_owned()
if trimmed.is_empty() {
Option::None
} else {
// Apply text-transform from element's style
let transformed = apply_text_transform(trimmed, element)
Some(transformed)
}
}
///|
/// Compute the accessible description for an HTML element
pub fn compute_accessible_description(
element : @html.Element,
find_by_id : (String) -> @html.Element?,
) -> String? {
compute_accessible_description_with_label(element, find_by_id, fn(_id) {
None
})
}
///|
/// Compute the accessible description with label lookup support
pub fn compute_accessible_description_with_label(
element : @html.Element,
find_by_id : (String) -> @html.Element?,
find_label_for_id : (String) -> @html.Element?,
) -> String? {
// aria-describedby takes precedence
match element.attributes.get("aria-describedby") {
Some(ids) => {
let ctx = NameComputationContext::new().with_referencing(true)
let parts : Array[String] = []
for id in ids.split(" ") {
let id_str = id.to_owned().trim().to_owned()
if !id_str.is_empty() {
match find_by_id(id_str) {
Some(ref_elem) => {
let text = compute_name_step(
ref_elem,
ctx,
find_by_id,
find_label_for_id,
None,
fn(_element) { false },
)
if !text.is_empty() {
parts.push(text)
}
}
Option::None => ()
}
}
}
if parts.is_empty() {
Option::None
} else {
Some(parts.join(" "))
}
}
Option::None =>
// Fall back to title attribute
match element.attributes.get("title") {
Some(title) if !title.is_empty() => Some(title)
_ => Option::None
}
}
}
// =============================================================================
// Name Computation Steps (accname-1.2 Algorithm)
// =============================================================================
///|
/// Main computation step following accname specification
fn compute_name_step(
element : @html.Element,
ctx : NameComputationContext,
find_by_id : (String) -> @html.Element?,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
is_in_hidden_subtree : (@html.Element) -> Bool,
) -> String {
// Step 1: Skip hidden elements (unless referenced)
if !ctx.is_referencing && is_hidden(element) {
return ""
}
// Avoid cycles
match element.id {
Some(id) if ctx.is_visited(id) => {
// Self-reference case: skip aria-labelledby but continue with other steps
// Per accname spec, when aria-labelledby references itself, skip step 2A
// but still check aria-label, native alternatives, etc.
if ctx.is_referencing {
return compute_name_steps_skip_labelledby(
element, ctx, find_by_id, find_label_for_id, encapsulating_label, is_in_hidden_subtree,
)
}
return ""
}
Some(id) => {
let new_ctx = ctx.mark_visited(id)
return compute_name_steps(
element, new_ctx, find_by_id, find_label_for_id, encapsulating_label, is_in_hidden_subtree,
)
}
Option::None =>
compute_name_steps(
element, ctx, find_by_id, find_label_for_id, encapsulating_label, is_in_hidden_subtree,
)
}
}
///|
fn compute_name_steps(
element : @html.Element,
ctx : NameComputationContext,
find_by_id : (String) -> @html.Element?,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
is_in_hidden_subtree : (@html.Element) -> Bool,
) -> String {
// Step 2A: aria-labelledby
match element.attributes.get("aria-labelledby") {
Some(ids) if !ctx.is_referencing => {
let ref_ctx = ctx.with_referencing(true)
let parts : Array[String] = []
for id in ids.split(" ") {
let id_str = id.to_owned().trim().to_owned()
if !id_str.is_empty() {
match find_by_id(id_str) {
Some(ref_elem) => {
let text = compute_name_step(
ref_elem,
ref_ctx,
find_by_id,
find_label_for_id,
None,
is_in_hidden_subtree,
)
if !text.is_empty() {
parts.push(text)
}
}
Option::None => ()
}
}
}
if !parts.is_empty() {
return parts.join(" ")
}
}
_ => ()
}
// Step 2B: aria-label
match element.attributes.get("aria-label") {
Some(label) if !label.trim().is_empty() => return label.trim().to_owned()
_ => ()
}
// Step 2C: Native text alternatives (depends on element type)
let native_text = compute_native_text_alternative(
element, find_label_for_id, encapsulating_label,
)
if !native_text.is_empty() {
return native_text
}
// Step 2C.5: Name from heading for dialog-like roles (tentative feature)
let role = compute_role(element, RoleMappingContext::default())
if role_gets_name_from_heading(role) {
match find_first_heading(element) {
Some(heading) => {
let heading_text = extract_all_text(heading)
if !heading_text.is_empty() {
return heading_text
}
}
None => ()
}
}
// Step 2D: Name from content (if role allows it)
if role_supports_name_from_content(role) || ctx.is_referencing {
let content = compute_text_content(
element, ctx, find_by_id, find_label_for_id, is_in_hidden_subtree,
)
if !content.is_empty() {
return content
}
}
// Step 2E: Tooltip (title attribute)
match element.attributes.get("title") {
Some(title) if !title.is_empty() => return title
_ => ()
}
// Special case: select element without label or title - use selected option
if element.tag.to_lower() == "select" {
let option_text = find_selected_option_text(element)
if !option_text.is_empty() {
return option_text
}
}
""
}
///|
/// Compute name steps but skip aria-labelledby (for self-reference case)
fn compute_name_steps_skip_labelledby(
element : @html.Element,
ctx : NameComputationContext,
find_by_id : (String) -> @html.Element?,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
is_in_hidden_subtree : (@html.Element) -> Bool,
) -> String {
// Step 2B: aria-label (skip step 2A)
match element.attributes.get("aria-label") {
Some(label) if !label.trim().is_empty() => return label.trim().to_owned()
_ => ()
}
// Step 2C: Native text alternatives
let native_text = compute_native_text_alternative(
element, find_label_for_id, encapsulating_label,
)
if !native_text.is_empty() {
return native_text
}
// Step 2D: Name from content
let role = compute_role(element, RoleMappingContext::default())
if role_supports_name_from_content(role) || ctx.is_referencing {
let content = compute_text_content(
element, ctx, find_by_id, find_label_for_id, is_in_hidden_subtree,
)
if !content.is_empty() {
return content
}
}
// Step 2E: Tooltip
match element.attributes.get("title") {
Some(title) if !title.is_empty() => return title
_ => ()
}
""
}
// =============================================================================
// Native Text Alternatives
// =============================================================================
///|
/// Compute native text alternative based on element type
fn compute_native_text_alternative(
element : @html.Element,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
) -> String {
let tag = element.tag.to_lower()
match tag {
// Images: alt attribute
"img" | "area" =>
match element.attributes.get("alt") {
Some(alt) => alt
Option::None => ""
}
// Input elements: depends on type
"input" =>
compute_input_name(element, find_label_for_id, encapsulating_label)
// Button: value attribute for certain types
"button" => "" // Falls through to content
// Textarea: associated label
"textarea" =>
find_label_text(element, find_label_for_id, encapsulating_label)
// Select: associated label only (selected option handled in Step 2E fallback)
"select" => find_label_text(element, find_label_for_id, encapsulating_label)
// Table: caption
"table" => find_child_text(element, "caption")
// Fieldset: legend
"fieldset" => find_child_text(element, "legend")
// Figure: figcaption
"figure" => find_child_text(element, "figcaption")
// Details: does not get name from summary per html-aam
"details" => ""
_ => ""
}
}
///|
/// Compute name for input elements
fn compute_input_name(
element : @html.Element,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
) -> String {
let input_type = match element.attributes.get("type") {
Some(t) => t.to_lower()
Option::None => "text"
}
match input_type {
// Button types: use value
"button" | "submit" | "reset" =>
match element.attributes.get("value") {
Some(value) if !value.is_empty() => value
_ =>
// Default labels for submit/reset
match input_type {
"submit" => "Submit"
"reset" => "Reset"
_ => ""
}
}
// Image type: alt, then value
"image" =>
match element.attributes.get("alt") {
Some(alt) if !alt.is_empty() => alt
_ =>
match element.attributes.get("value") {
Some(value) if !value.is_empty() => value
_ => "Submit" // default for image input
}
}
// Text-like inputs: look for associated label
_ => find_label_text(element, find_label_for_id, encapsulating_label)
}
}
///|
/// Find text from an associated label element
fn find_label_text(
element : @html.Element,
find_label_for_id : (String) -> @html.Element?,
encapsulating_label : @html.Element?,
) -> String {
// Check for aria-labelledby first (handled in main algorithm)
// Here we handle HTML label association
// 1. Check for label with matching 'for' attribute
match element.id {
Some(id) =>
match find_label_for_id(id) {
Some(label_elem) => return extract_all_text(label_elem)
Option::None => ()
}
Option::None => ()
}
// 2. Check for encapsulating label (label wrapping the input)
match encapsulating_label {
Some(label_elem) =>
extract_label_text_excluding_control(label_elem, element)
Option::None => ""
}
}
///|
/// Extract text from a label element, excluding the control element itself
fn extract_label_text_excluding_control(
label : @html.Element,
control : @html.Element,
) -> String {
let buf = StringBuilder::new()
extract_text_excluding_control_recursive(label, control, buf)
normalize_whitespace(buf.to_string())
}
///|
fn extract_text_excluding_control_recursive(
element : @html.Element,
control : @html.Element,
buf : StringBuilder,
) -> Unit {
for child in element.children {
match child {
@html.Text(text) => buf.write_string(text)
@html.Element(child_elem) => {
// Skip the control element itself (the one being labeled)
if is_same_element(child_elem, control) {
continue
}
// For embedded form controls, extract their value
// Check by tag name (native controls)
let tag = child_elem.tag.to_lower()
let is_native_control = tag == "input" ||
tag == "select" ||
tag == "textarea" ||
tag == "button"
// Also check by role for ARIA widgets
let role = compute_role(child_elem, RoleMappingContext::default())
let is_aria_widget = match role {
Textbox | SearchBox | Combobox | Listbox | Slider | SpinButton => true
_ => false
}
if is_native_control || is_aria_widget {
// Get embedded control value (accname Step 2F)
match get_embedded_control_value(child_elem) {
Some(value) => buf.write_string(value)
None => ()
}
continue
}
extract_text_excluding_control_recursive(child_elem, control, buf)
}
}
}
}
///|
fn is_same_element(a : @html.Element, b : @html.Element) -> Bool {
// Compare by id if available
match (a.id, b.id) {
(Some(id_a), Some(id_b)) => return id_a == id_b
_ => ()
}
// Compare by tag and key attributes for form elements
if a.tag.to_lower() != b.tag.to_lower() {
return false
}
// For inputs, compare by type and name
let tag = a.tag.to_lower()
if tag == "input" || tag == "select" || tag == "textarea" || tag == "button" {
let a_type = a.attributes.get("type").unwrap_or("text")
let b_type = b.attributes.get("type").unwrap_or("text")
let a_name = a.attributes.get("name").unwrap_or("")
let b_name = b.attributes.get("name").unwrap_or("")
return a_type == b_type && a_name == b_name
}
false
}
///|
/// Find text content of a specific child element
fn find_child_text(element : @html.Element, child_tag : String) -> String {
for child in element.children {
match child {
@html.Element(child_elem) =>
if child_elem.tag.to_lower() == child_tag {
return extract_all_text(child_elem)
}
@html.Text(_) => ()
}
}
""
}
///|
/// Find text of selected option in a select element
fn find_selected_option_text(element : @html.Element) -> String {
// First try to find an option with "selected" attribute
for child in element.children {
match child {
@html.Element(option) =>
if option.tag.to_lower() == "option" &&
option.attributes.contains("selected") {
return extract_all_text(option)
}
_ => ()
}
}
// If no selected option, return first option's text
for child in element.children {
match child {
@html.Element(option) =>
if option.tag.to_lower() == "option" {
return extract_all_text(option)
}
_ => ()
}
}
""
}
// =============================================================================
// Embedded Control Value (accname Step 2F)
// =============================================================================
///|
/// Get the value of an embedded control for accessible name computation
/// Returns Some(value) if the element is an embedded control, None otherwise
fn get_embedded_control_value(element : @html.Element) -> String? {
let tag = element.tag.to_lower()
let role = compute_role(element, RoleMappingContext::default())
// Check if this is an embedded control
match role {
// Textbox: use value attribute
Textbox | SearchBox =>
match element.attributes.get("value") {
Some(value) if !value.is_empty() => Some(value)
_ => None
}
// Combobox/Listbox: use selected option text for select, value for input
Combobox | Listbox =>
if tag == "select" {
// Find the selected option
for child in element.children {
match child {
@html.Element(option) =>
if option.tag.to_lower() == "option" &&
option.attributes.contains("selected") {
return Some(extract_all_text(option))
}
_ => ()
}
}
// If no selected, use first option
for child in element.children {
match child {
@html.Element(option) =>
if option.tag.to_lower() == "option" {
return Some(extract_all_text(option))
}
_ => ()
}
}
None
} else {
// For non-select combobox (input, div, span with role="combobox")
// Use value attribute, or text content for non-input elements
match element.attributes.get("value") {
Some(value) if !value.is_empty() => Some(value)
_ =>
// For span/div with role="combobox", use text content
if tag != "input" {
let text = extract_all_text(element)
if !text.is_empty() {
Some(text)
} else {
None
}
} else {
None
}
}
}
// Slider/Spinbutton: use aria-valuetext, aria-valuenow, or value attribute
Slider | SpinButton =>
match element.attributes.get("aria-valuetext") {
Some(text) if !text.is_empty() => Some(text)
_ =>
match element.attributes.get("aria-valuenow") {
Some(value) if !value.is_empty() => Some(value)
_ =>
// For native range/number inputs, use value attribute
match element.attributes.get("value") {
Some(value) if !value.is_empty() => Some(value)
_ => None
}
}
}
// Other form controls
_ => None
}
}
// =============================================================================
// Text Content Extraction
// =============================================================================
///|
/// Compute text content from element and children
/// When extracting text content for name computation, we need to:
/// 1. Get text from embedded controls (Step 2F)
/// 2. Recursively extract text from all other elements
/// 3. Include aria-owns referenced elements (Step 2D)
fn compute_text_content(
element : @html.Element,
_ctx : NameComputationContext,
find_by_id : (String) -> @html.Element?,
_find_label_for_id : (String) -> @html.Element?,
is_in_hidden_subtree : (@html.Element) -> Bool,
) -> String {
let buf = StringBuilder::new()
compute_text_content_recursive(element, buf, false)
// Step 2D: Include aria-owns referenced elements
// Per ARIA spec: ignore aria-owns when target is hidden from all users
// or when target has an ancestor that is hidden from all users
match element.attributes.get("aria-owns") {
Some(ids) =>
for id in ids.split(" ") {
let id_str = id.to_owned().trim().to_owned()
if !id_str.is_empty() {
match find_by_id(id_str) {
Some(owned_elem) => {
// Skip if the owned element is hidden from all users
if is_hidden(owned_elem) {
continue
}
if is_in_hidden_subtree(owned_elem) {
continue
}
// Get text from owned element - add space before aria-owns content
let owned_text = extract_all_text_raw(owned_elem)
if !owned_text.is_empty() {
buf.write_char(' ')
buf.write_string(owned_text)
}
}
Option::None => ()
}
}
}
Option::None => ()
}
normalize_whitespace(buf.to_string())
}
///|
/// Recursively extract text content, handling embedded controls
/// Uses StringBuilder to preserve original whitespace, then normalizes at the end
fn compute_text_content_recursive(
element : @html.Element,
buf : StringBuilder,
in_hidden_visibility : Bool,
) -> Unit {
for child in element.children {
match child {
@html.Text(text) =>
// Skip text if we're in a visibility:hidden context
if !in_hidden_visibility {
// Write text as-is, preserving original whitespace
buf.write_string(text)
}
@html.Element(child_elem) => {
// Skip completely hidden elements (display:none, aria-hidden, hidden)
if is_hidden(child_elem) {
continue
}
// Check visibility state for this element
let child_hidden_visibility = if has_visibility_visible(child_elem) {
// visibility:visible overrides parent's visibility:hidden
false
} else if has_visibility_hidden(child_elem) {
// This element is visibility:hidden
true
} else {
// Inherit from parent
in_hidden_visibility
}
// Handle image elements - use alt text (only if visible)
let tag = child_elem.tag.to_lower()
if (tag == "img" || tag == "area") && !child_hidden_visibility {
match child_elem.attributes.get("alt") {
Some(alt) if !alt.is_empty() => {
// Add space before image alt if buffer not empty and doesn't end with space
ensure_space_separator(buf)
buf.write_string(alt)
}
_ => ()
}
continue
}
// Step 2F: Check for embedded control value first (only if visible)
if !child_hidden_visibility {
match get_embedded_control_value(child_elem) {
Some(value) => {
ensure_space_separator(buf)
buf.write_string(value)
continue
}
None => ()
}
}
// Per accname spec: when computing text content from descendants,
// if a child element has aria-label, use that instead of its content
if !child_hidden_visibility {
match child_elem.attributes.get("aria-label") {
Some(label) if !label.trim().is_empty() => {
ensure_space_separator(buf)
buf.write_string(label.trim().to_owned())
continue
}
_ => ()
}
}
// Recursively extract text from child elements
// Track buffer length to know if child added content
let len_before = buf.to_string().length()
// Pass visibility state - children may override with visibility:visible
compute_text_content_recursive(child_elem, buf, child_hidden_visibility)
// Add space after child element content only if child had content
// This ensures "one" + "two" + "three" becomes "one two three"
// but "buttonlabel" stays "buttonlabel"
let len_after = buf.to_string().length()
if len_after > len_before {
ensure_space_separator(buf)
}
}
}
}
}
///|
/// Ensure there's a space separator before adding new content
/// Does nothing if buffer is empty or already ends with whitespace
fn ensure_space_separator(buf : StringBuilder) -> Unit {
let s = buf.to_string()
if s.is_empty() {
return
}
// Check if last char is whitespace
let chars = s.to_array()
if chars.length() > 0 {
let last = chars[chars.length() - 1]
if last != ' ' && last != '\t' && last != '\n' && last != '\r' {
buf.write_char(' ')
}
}
}
///|
/// Extract all text content from element (simple, non-recursive aware)
/// Normalizes whitespace in the result
fn extract_all_text(element : @html.Element) -> String {
normalize_whitespace(extract_all_text_raw(element))
}
///|
/// Extract all text content from element without normalizing
fn extract_all_text_raw(element : @html.Element) -> String {
let buf = StringBuilder::new()
extract_text_recursive(element, buf)
buf.to_string()
}
///|
fn extract_text_recursive(element : @html.Element, buf : StringBuilder) -> Unit {
for child in element.children {
match child {
@html.Text(text) => buf.write_string(text)
@html.Element(child_elem) => {
// Handle image elements - use alt text
let tag = child_elem.tag.to_lower()
if tag == "img" || tag == "area" {
match child_elem.attributes.get("alt") {
Some(alt) if !alt.is_empty() => buf.write_string(alt)
_ => ()
}
} else {
extract_text_recursive(child_elem, buf)
}
}
}
}
}
///|
/// Normalize whitespace: collapse consecutive whitespace to single space, trim
fn normalize_whitespace(s : String) -> String {
let chars : Array[Char] = []
let mut last_was_space = true // Start true to skip leading whitespace
for c in s {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
if !last_was_space {
chars.push(' ')
last_was_space = true
}
} else {
chars.push(c)
last_was_space = false
}
}
// Trim trailing space
while chars.length() > 0 && chars[chars.length() - 1] == ' ' {
let _ = chars.pop()
}
String::from_array(chars)
}
// =============================================================================
// Hidden State Detection
// =============================================================================
///|
/// Check if an element is completely hidden from accessibility tree
/// (display:none, aria-hidden, hidden attribute - these hide element AND children)
fn is_hidden(element : @html.Element) -> Bool {
// aria-hidden="true"
match element.attributes.get("aria-hidden") {
Some("true") => return true
_ => ()
}
// hidden attribute
if element.attributes.contains("hidden") {
return true
}
// Check inline style for display:none (hides element and all children)
match element.style {
Some(style) => {
let style_lower = style.to_lower()
if style_lower.contains("display") && style_lower.contains("none") {
return true
}
}
Option::None => ()
}
false
}
///|
/// Check if an element hides descendants from all users regardless of aria-owns.
/// This intentionally excludes aria-hidden ancestors, because reassigned nodes
/// no longer inherit that ancestor state.
fn hides_descendants_from_all_users(element : @html.Element) -> Bool {
if element.attributes.contains("hidden") {
return true
}
match element.style {
Some(style) => {
let style_lower = style.to_lower()
if style_lower.contains("display") && style_lower.contains("none") {
return true
}
}
Option::None => ()
}
false
}
///|
/// Check if element has visibility:hidden (children can override with visibility:visible)
fn has_visibility_hidden(element : @html.Element) -> Bool {
match element.style {
Some(style) => {
let style_lower = style.to_lower()
style_lower.contains("visibility") && style_lower.contains("hidden")
}
Option::None => false
}
}
///|
/// Check if element has visibility:visible (overrides parent's visibility:hidden)
fn has_visibility_visible(element : @html.Element) -> Bool {
match element.style {
Some(style) => {
let style_lower = style.to_lower()
style_lower.contains("visibility") && style_lower.contains("visible")
}
Option::None => false
}
}
///|
/// Find the first heading inside an element (h1-h6 or role="heading")
/// Used for dialog-like roles that get name from first heading (tentative feature)
fn find_first_heading(element : @html.Element) -> @html.Element? {
// Depth-first search for first heading
for child in element.children {
match child {
@html.Element(child_elem) => {
let tag = child_elem.tag.to_lower()
// Check for heading elements (h1-h6)
if tag == "h1" ||
tag == "h2" ||
tag == "h3" ||
tag == "h4" ||
tag == "h5" ||
tag == "h6" {
return Some(child_elem)
}
// Check for role="heading"
match child_elem.attributes.get("role") {
Some(r) if r.to_lower() == "heading" => return Some(child_elem)
_ => ()
}
// Recurse into children
match find_first_heading(child_elem) {
Some(h) => return Some(h)
None => ()
}
}
_ => ()
}
}
None
}
///|
/// Check if role should get name from first heading (dialog-like roles)
fn role_gets_name_from_heading(role : Role) -> Bool {
match role {
AlertDialog | Dialog | Article => true
_ => false
}
}
// =============================================================================
// Text Transform
// =============================================================================
///|
/// Apply CSS text-transform to a string based on element's style
fn apply_text_transform(text : String, element : @html.Element) -> String {
let transform = get_text_transform(element)
match transform {
"uppercase" => text.to_upper()
"lowercase" => text.to_lower()
"capitalize" => capitalize_words(text)
_ => text
}
}
///|
/// Extract text-transform value from element's inline style
fn get_text_transform(element : @html.Element) -> String {
match element.style {
Some(style) => {
let style_lower = style.to_lower()
if style_lower.contains("text-transform") {
// Parse text-transform value from style string
// e.g. "text-transform:uppercase;" or "text-transform: uppercase"
for part in style.split(";") {
let part_str = part.to_owned().trim().to_owned()
let part_lower = part_str.to_lower()
if part_lower.contains("text-transform") {
match part_str.split(":").collect().get(1) {
Some(value) =>
return value.to_owned().trim().to_owned().to_lower()
None => ()
}
}
}
}
""
}
None => ""
}
}
///|
/// Capitalize first letter of each word
fn capitalize_words(text : String) -> String {
let result : Array[Char] = []
let mut capitalize_next = true
for c in text {
if c == ' ' || c == '\t' || c == '\n' {
result.push(c)
capitalize_next = true
} else if capitalize_next {
result.push(char_to_upper(c))
capitalize_next = false
} else {
result.push(c)
}
}
String::from_array(result)
}
///|
/// Convert a single character to uppercase
fn char_to_upper(c : Char) -> Char {
if c >= 'a' && c <= 'z' {
(c.to_int() - 32).unsafe_to_char()
} else {
c
}
}