Skip to main content

tablo_test/protocol/
tabs.rs

1//! Reads a page's `Tabs` blocks: `[role=tab]` for each trigger, `aria-selected` for the active
2//! one, `aria-controls` for its `[role=tabpanel]`, `hidden` on a panel the page hides,
3//! `data-tab-error` on the trigger of a tab holding a field error, and `[data-tab-badge]` for the
4//! badge closing a trigger.
5
6use super::dom::{attr_value, strip_text, tags_where};
7
8/// One tab of a `Tabs` block.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Tab {
11    /// The trigger's text, without its badge.
12    pub label: String,
13    /// The text of the trigger's badge, if it shows one.
14    pub badge: Option<String>,
15    /// Whether the page opens on this tab: its trigger is selected and its panel shows.
16    pub active: bool,
17    /// Whether the trigger marks a field error in the tab.
18    pub errored: bool,
19    /// The keys the controls in the tab's panel post, in document order, each once.
20    pub keys: Vec<String>,
21}
22
23/// The tabs of every `Tabs` block `html` renders, in document order: a nested block's after the
24/// tab holding it, whose `keys` hold the nested block's too.
25pub fn tabs(html: &str) -> Vec<Tab> {
26    tags_where(html, |tag, attrs| {
27        tag == "button" && attr_value(attrs, "role").as_deref() == Some("tab")
28    })
29    .into_iter()
30    .map(|(_, attrs, inner)| {
31        let panel = attr_value(&attrs, "aria-controls").and_then(|id| {
32            tags_where(html, |_, attrs| {
33                attr_value(attrs, "id").as_deref() == Some(id.as_str())
34            })
35            .into_iter()
36            .next()
37        });
38        let shown = panel
39            .as_ref()
40            .is_some_and(|(_, attrs, _)| !attrs.iter().any(|(name, _)| name == "hidden"));
41        let mut keys: Vec<String> = Vec::new();
42        if let Some((_, _, panel)) = &panel {
43            for (_, attrs, _) in tags_where(panel, |_, attrs| attr_value(attrs, "name").is_some()) {
44                let key = attr_value(&attrs, "name").unwrap_or_default();
45                if !keys.contains(&key) {
46                    keys.push(key);
47                }
48            }
49        }
50        // The badge closes the trigger: the label is what comes before it.
51        let (label, badge) = match inner.find("data-tab-badge") {
52            Some(at) => {
53                let start = inner[..at].rfind('<').unwrap_or(at);
54                (&inner[..start], Some(strip_text(&inner[start..])))
55            }
56            None => (inner.as_str(), None),
57        };
58        Tab {
59            label: strip_text(label),
60            badge,
61            active: shown && attr_value(&attrs, "aria-selected").as_deref() == Some("true"),
62            errored: attrs.iter().any(|(name, _)| name == "data-tab-error"),
63            keys,
64        }
65    })
66    .collect()
67}