Skip to main content

hypersig_ui/
listbox.rs

1//! Listboxes: a list of options of which one is selected.
2
3use hypersig::html::{El, Event, Orientation, div};
4use hypersig::style::value::{
5    AlignItems, BorderStyle, Cursor, Display, JustifyContent, Opacity, px, rem,
6};
7use hypersig::style::{PseudoClass, Style};
8use hypersig::{Expr, Sig, if_else};
9use serde::Serialize;
10
11use crate::look::{self, icon};
12use crate::token;
13
14/// A list named `label` whose selected option is the signal `selected`.
15///
16/// It follows the WAI-ARIA listbox pattern for a single selection. The list
17/// has `role="listbox"` and each option `role="option"` with
18/// `aria-selected`. Tab enters the list at the selected option, or at the
19/// first where none is selected, and the arrow keys, Home, and End move
20/// between options through [`El::roving_focus`](hypersig::html::El::roving_focus),
21/// selecting each as it gains focus. The ids are derived from the signal's
22/// path and the option's position.
23///
24/// ```
25/// use hypersig::Signals;
26/// use hypersig_ui::Listbox;
27/// use serde::{Deserialize, Serialize};
28///
29/// #[derive(Serialize, Deserialize, Clone, Copy, PartialEq)]
30/// enum Plan {
31///     Hobby,
32///     Pro,
33/// }
34///
35/// #[derive(Signals, Serialize, Deserialize)]
36/// struct Billing {
37///     plan: Plan,
38/// }
39///
40/// let plans = Listbox::new("Plan", Billing::plan())
41///     .option(Plan::Hobby, "Hobby")
42///     .option(Plan::Pro, "Pro");
43/// let html = plans.render().render();
44/// assert!(html.contains(r#"role="listbox""#));
45/// assert!(html.contains(r#"id="ui-plan-option-1""#));
46/// ```
47#[derive(Debug)]
48pub struct Listbox<S, T> {
49    label: String,
50    selected: Sig<S, T>,
51    options: Vec<(T, String)>,
52}
53
54impl<S, T: Serialize + PartialEq> Listbox<S, T> {
55    /// An empty list named `label` for assistive technology.
56    #[must_use]
57    pub fn new(label: impl Into<String>, selected: Sig<S, T>) -> Self {
58        Self {
59            label: label.into(),
60            selected,
61            options: Vec::new(),
62        }
63    }
64
65    /// Adds an option labeled `title` that selects `value`.
66    #[must_use]
67    pub fn option(mut self, value: T, title: impl Into<String>) -> Self {
68        self.options.push((value, title.into()));
69        self
70    }
71
72    /// The list and its options.
73    #[must_use]
74    pub fn render(self) -> El<S> {
75        let selected = self.selected;
76        // True while no option holds the selected value, when the first
77        // option takes the tab stop so that Tab can still reach the list.
78        let none: Expr<S, bool> = self
79            .options
80            .iter()
81            .fold(Expr::lit(&true), |none, (value, _)| {
82                none.and(selected.not_equals(value))
83            });
84        div()
85            .attr("role", "listbox")
86            .attr("aria-label", self.label)
87            .roving_focus(Orientation::Vertical)
88            .css(list_style())
89            .children(
90                self.options
91                    .into_iter()
92                    .enumerate()
93                    .map(|(index, (value, title))| {
94                        let chosen = selected.equals(&value);
95                        let stop = if index == 0 {
96                            chosen.clone().or(none.clone())
97                        } else {
98                            chosen.clone()
99                        };
100                        div()
101                            .attr("role", "option")
102                            .id(crate::id::of(selected, &format!("option-{index}")))
103                            .bind_attr("aria-selected", if_else(chosen.clone(), "true", "false"))
104                            .bind_attr("tabindex", if_else(stop, "0", "-1"))
105                            .on(Event::Focus, selected.set(&value))
106                            .on_click(selected.set(&value))
107                            .css(option_style())
108                            .text(title)
109                            .child(
110                                icon(look::icon::CHECK, rem(1))
111                                    .css(Style::new().opacity(Opacity::Number(0.0)))
112                                    .css_when(Style::new().opacity(Opacity::Number(1.0)), chosen),
113                            )
114                    }),
115            )
116    }
117}
118
119impl<S, T: Serialize + PartialEq> From<Listbox<S, T>> for El<S> {
120    fn from(listbox: Listbox<S, T>) -> Self {
121        listbox.render()
122    }
123}
124
125impl<S, T: Serialize + PartialEq> From<Listbox<S, T>> for hypersig::html::Node<S> {
126    fn from(listbox: Listbox<S, T>) -> Self {
127        listbox.render().into()
128    }
129}
130
131/// The box around the options of a listbox, also used by the combobox's
132/// drop-down.
133pub(crate) fn list_style() -> Style {
134    Style::new()
135        .display(Display::Flex)
136        .flex_direction(hypersig::style::value::FlexDirection::Column)
137        .padding(rem(0.25))
138        .border_width(px(1))
139        .border_style(BorderStyle::Solid)
140        .border_color(token::border().var())
141        .border_radius(px(6))
142        .background_color(token::popover().var())
143        .color(token::popover_foreground().var())
144}
145
146/// One option: a row that is highlighted while hovered or focused.
147pub(crate) fn option_style() -> Style {
148    Style::new()
149        .display(Display::Flex)
150        .align_items(AlignItems::Center)
151        .justify_content(JustifyContent::SpaceBetween)
152        .gap(rem(0.5))
153        .padding_block(rem(0.375))
154        .padding_inline(rem(0.5))
155        .border_radius(px(4))
156        .cursor(Cursor::Pointer)
157        .merge(look::small_text())
158        .on(
159            PseudoClass::Hover,
160            Style::new().background_color(token::accent().var()),
161        )
162        .on(
163            PseudoClass::Focus,
164            Style::new().background_color(token::accent().var()),
165        )
166        .on(PseudoClass::FocusVisible, look::ring(px(-2)))
167}