1use 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#[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 #[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 #[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 #[must_use]
74 pub fn render(self) -> El<S> {
75 let selected = self.selected;
76 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
131pub(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
146pub(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}