Skip to main content

hypersig_ui/
accordion.rs

1//! Accordions: a stack of sections that each open and close.
2
3use hypersig::html::{El, Node, details, div, summary};
4use hypersig::style::value::{
5    AlignItems, BorderBottomStyle, Cursor, Display, FontWeight, JustifyContent, ListStyleType, px,
6    raw, rem,
7};
8use hypersig::style::{Condition, PseudoClass, Style};
9
10use crate::id::check_name;
11use crate::look::{self, icon};
12use crate::token;
13
14/// A stack of sections, each a native `<details>` whose `<summary>` opens
15/// and closes it.
16///
17/// The browser handles the keyboard and announces each summary as a button
18/// that is expanded or collapsed, and the find-in-page search opens a closed
19/// section that holds a match. An exclusive accordion gives its sections one
20/// `name`, so the browser closes the open one when another opens.
21///
22/// ```
23/// use hypersig::Signals;
24/// use hypersig::html::p;
25/// use hypersig_ui::Accordion;
26/// use serde::{Deserialize, Serialize};
27///
28/// #[derive(Signals, Serialize, Deserialize)]
29/// struct Faq {}
30///
31/// let faq = Accordion::<Faq>::exclusive("faq")
32///     .item("Is it accessible?", p().text("It follows the WAI-ARIA patterns."))
33///     .item("Is it styled?", p().text("It looks like shadcn/ui."));
34/// assert!(faq.render().render().contains(r#"name="faq""#));
35/// ```
36#[derive(Debug)]
37pub struct Accordion<S> {
38    name: Option<&'static str>,
39    items: Vec<(String, Node<S>)>,
40}
41
42impl<S> Accordion<S> {
43    /// An accordion whose sections open and close independently.
44    #[must_use]
45    pub fn new() -> Self {
46        Self {
47            name: None,
48            items: Vec::new(),
49        }
50    }
51
52    /// An accordion that keeps at most one section open, grouped by `name`.
53    ///
54    /// Sections with the same `name` close each other wherever they are on
55    /// the page, as radio buttons with one name do, and nothing checks that
56    /// each name is used once.
57    ///
58    /// # Panics
59    ///
60    /// Panics if `name` is empty or holds a character other than an ASCII
61    /// letter, digit, `-`, or `_`.
62    #[must_use]
63    pub fn exclusive(name: &'static str) -> Self {
64        check_name(name);
65        Self {
66            name: Some(name),
67            items: Vec::new(),
68        }
69    }
70
71    /// Adds a section titled `title` whose content is `content`.
72    #[must_use]
73    pub fn item(mut self, title: impl Into<String>, content: impl Into<Node<S>>) -> Self {
74        self.items.push((title.into(), content.into()));
75        self
76    }
77
78    /// The sections, in a `<div>`.
79    #[must_use]
80    pub fn render(self) -> El<S> {
81        let name = self.name;
82        div().children(self.items.into_iter().map(|(title, content)| {
83            let section = details().css(
84                Style::new()
85                    .border_bottom_width(px(1))
86                    .border_bottom_style(BorderBottomStyle::Solid)
87                    .border_bottom_color(token::border().var())
88                    .group(),
89            );
90            let section = match name {
91                Some(name) => section.attr("name", name),
92                None => section,
93            };
94            section
95                .child(
96                    summary().css(summary_style()).text(title).child(
97                        icon(look::icon::CHEVRON_DOWN, rem(1)).css(
98                            Style::new()
99                                .color(token::muted_foreground().var())
100                                .merge(look::transition())
101                                .on(
102                                    Condition::Group(PseudoClass::Open),
103                                    Style::new().rotate(raw("180deg")),
104                                ),
105                        ),
106                    ),
107                )
108                .child(
109                    div()
110                        .css(look::small_text().padding_bottom(rem(1)))
111                        .child(content),
112                )
113        }))
114    }
115}
116
117impl<S> Default for Accordion<S> {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl<S> From<Accordion<S>> for El<S> {
124    fn from(accordion: Accordion<S>) -> Self {
125        accordion.render()
126    }
127}
128
129impl<S> From<Accordion<S>> for Node<S> {
130    fn from(accordion: Accordion<S>) -> Self {
131        accordion.render().into()
132    }
133}
134
135fn summary_style() -> Style {
136    // A summary that is not `display: list-item` draws no disclosure marker.
137    Style::new()
138        .display(Display::Flex)
139        .align_items(AlignItems::Center)
140        .justify_content(JustifyContent::SpaceBetween)
141        .gap(rem(1))
142        .padding_block(rem(1))
143        .list_style_type(ListStyleType::None)
144        .font_weight(FontWeight::Number(500.0))
145        .cursor(Cursor::Pointer)
146        .border_radius(px(6))
147        .merge(look::small_text())
148        .merge(look::focus_ring())
149        .on(
150            PseudoClass::Hover,
151            Style::new()
152                .text_decoration_line(hypersig::style::value::TextDecorationLine::Underline),
153        )
154}