Current section
Files
Jump to
Current section
Files
src/plushie/testing/element.gleam
//// Element: a query-friendly wrapper around a tree Node.
////
//// Elements provide convenient accessors for testing: find by ID,
//// extract text content, read props, and traverse children.
import gleam/dict
import gleam/list
import gleam/option.{type Option}
import gleam/string
import plushie/node.{type Node, type PropValue, DictVal, StringVal}
import plushie/tree
/// A test element wrapping a Node for convenient querying.
pub type Element {
Element(node: Node)
}
/// Wrap a Node as an Element.
pub fn from_node(node: Node) -> Element {
Element(node:)
}
/// Find an element by ID in a normalized tree.
pub fn find(in root: Node, id target: String) -> Option(Element) {
case tree.find(root, target) {
option.Some(node) -> option.Some(Element(node:))
option.None -> option.None
}
}
/// Extract text content from an element.
/// Checks props in order: "content", "label", "value", "placeholder".
pub fn text(element: Element) -> Option(String) {
let props = element.node.props
let keys = ["content", "label", "value", "placeholder"]
find_first_string(props, keys)
}
fn find_first_string(
props: dict.Dict(String, PropValue),
keys: List(String),
) -> Option(String) {
case keys {
[] -> option.None
[key, ..rest] ->
case dict.get(props, key) {
Ok(StringVal(s)) -> option.Some(s)
_ -> find_first_string(props, rest)
}
}
}
/// Get a prop value by key.
pub fn prop(element: Element, key: String) -> Option(PropValue) {
case dict.get(element.node.props, key) {
Ok(value) -> option.Some(value)
Error(_) -> option.None
}
}
/// Get the element's ID.
pub fn id(element: Element) -> String {
element.node.id
}
/// Get the element's kind (widget type).
pub fn kind(element: Element) -> String {
element.node.kind
}
/// Get the element's children as Elements.
pub fn children(element: Element) -> List(Element) {
list.map(element.node.children, from_node)
}
/// Check if the element has any children.
pub fn has_children(element: Element) -> Bool {
!list.is_empty(element.node.children)
}
/// Get a child element by index.
pub fn child_at(element: Element, index: Int) -> Option(Element) {
case list.drop(element.node.children, index) {
[child, ..] -> option.Some(from_node(child))
[] -> option.None
}
}
/// Find a descendant element by ID within this element's subtree.
pub fn find_within(element: Element, target: String) -> Option(Element) {
find(in: element.node, id: target)
}
/// Collect all descendant elements matching a predicate.
pub fn find_all(
element: Element,
predicate: fn(Element) -> Bool,
) -> List(Element) {
tree.find_all(element.node, fn(node) { predicate(from_node(node)) })
|> list.map(from_node)
}
/// Get the a11y props dict from an element.
///
/// Returns an empty dict if no `a11y` key is set.
pub fn a11y(element: Element) -> dict.Dict(String, PropValue) {
case dict.get(element.node.props, "a11y") {
Ok(DictVal(m)) -> m
_ -> dict.new()
}
}
/// Return the resolved a11y dict for this element.
///
/// Layers render-pipeline inference (placeholder -> description for
/// text-entry widgets, alt -> label for media widgets) on top of the
/// normalized `a11y` prop. Kept aligned with the Rust SDK's
/// `resolve_a11y_for_node` so cross-SDK parity holds.
pub fn resolved_a11y(element: Element) -> dict.Dict(String, PropValue) {
let explicit = a11y(element)
let inferred = infer_a11y(element.node.kind, element.node.props)
// Explicit wins on key conflicts. dict.merge(base, override) folds
// override on top of base.
dict.merge(inferred, explicit)
}
fn infer_a11y(
kind: String,
props: dict.Dict(String, PropValue),
) -> dict.Dict(String, PropValue) {
case kind {
"text_input" | "text_editor" | "combo_box" | "pick_list" ->
case dict.get(props, "placeholder") {
Ok(StringVal(s)) if s != "" ->
dict.from_list([#("description", StringVal(s))])
_ -> dict.new()
}
"image" | "svg" | "qr_code" ->
case dict.get(props, "alt") {
Ok(StringVal(s)) if s != "" ->
dict.from_list([#("label", StringVal(s))])
_ -> dict.new()
}
_ -> dict.new()
}
}
/// Get the local ID (last segment after "/" and "#").
pub fn local_id(element: Element) -> String {
// Strip window# prefix first
let path = case string.split_once(element.node.id, "#") {
Ok(#(_, after)) -> after
Error(_) -> element.node.id
}
case string.split(path, "/") {
[] -> path
segments ->
case list.last(segments) {
Ok(last) -> last
Error(_) -> path
}
}
}