1use std::fmt;
4use std::marker::PhantomData;
5
6use hypersig::html::{El, Key, Orientation, button as button_el, div};
7use hypersig::style::value::{
8 AlignItems, BorderStyle, Color, Cursor, Display, PositionTryFallbacks, TextAlign, px, raw, rem,
9};
10use hypersig::style::{PseudoClass, Style};
11use hypersig::{Expr, IntoExpr, IntoStmt, NodeRef, Stmt};
12
13use crate::button::{ButtonSize, ButtonVariant, button};
14use crate::id::check_name;
15use crate::look;
16use crate::token;
17
18pub struct Menu<S> {
55 name: &'static str,
56 marker: PhantomData<fn() -> S>,
57}
58
59impl<S> Menu<S> {
60 #[must_use]
67 pub const fn new(name: &'static str) -> Self {
68 check_name(name);
69 Self {
70 name,
71 marker: PhantomData,
72 }
73 }
74
75 fn trigger_id(self) -> String {
76 format!("{}-trigger", self.name)
77 }
78
79 fn anchor(self) -> String {
80 format!("--{}", self.name)
81 }
82
83 #[must_use]
85 pub fn trigger(self, variant: ButtonVariant, size: ButtonSize) -> El<S> {
86 button(variant, size)
87 .id(self.trigger_id())
88 .attr("aria-haspopup", "menu")
89 .attr("commandfor", self.name)
90 .attr("command", "toggle-popover")
91 .css(Style::new().anchor_name(raw(self.anchor())))
92 }
93
94 #[must_use]
96 pub fn menu(self, items: impl IntoIterator<Item = MenuItem<S>>) -> El<S> {
97 let node_ref = NodeRef::new(self.name);
98 div()
99 .id(self.name)
100 .attr("popover", "auto")
101 .attr("role", "menu")
102 .attr("aria-labelledby", self.trigger_id())
103 .node_ref(node_ref)
104 .roving_focus(Orientation::Vertical)
105 .on_key(Key::Tab, node_ref.hide_popover())
107 .on_key(Key::Tab.shift(), node_ref.hide_popover())
108 .css(menu_style(self.anchor()))
109 .children(items.into_iter().enumerate().map(|(index, item)| {
110 let mut action = button_el()
111 .attr("type", "button")
112 .attr("role", "menuitem")
113 .attr("tabindex", "-1")
114 .css(item_style(item.destructive))
115 .on_click(item.run.then(node_ref.hide_popover()))
119 .text(item.label);
120 if index == 0 {
123 action = action.attr("autofocus", "");
124 }
125 if let Some(disabled) = item.disabled {
126 action = action.disabled(disabled);
127 }
128 action
129 }))
130 }
131}
132
133impl<S> Clone for Menu<S> {
134 fn clone(&self) -> Self {
135 *self
136 }
137}
138
139impl<S> Copy for Menu<S> {}
140
141impl<S> fmt::Debug for Menu<S> {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 f.debug_tuple("Menu").field(&self.name).finish()
144 }
145}
146
147#[derive(Debug)]
149pub struct MenuItem<S> {
150 label: String,
151 run: Stmt<S>,
152 destructive: bool,
153 disabled: Option<Expr<S, bool>>,
154}
155
156impl<S> MenuItem<S> {
157 #[must_use]
159 pub fn new(label: impl Into<String>, run: impl IntoStmt<S>) -> Self {
160 Self {
161 label: label.into(),
162 run: run.into_stmt(),
163 destructive: false,
164 disabled: None,
165 }
166 }
167
168 #[must_use]
170 pub fn destructive(mut self) -> Self {
171 self.destructive = true;
172 self
173 }
174
175 #[must_use]
178 pub fn disabled(mut self, when: impl IntoExpr<S, bool>) -> Self {
179 self.disabled = Some(when.into_expr());
180 self
181 }
182}
183
184fn menu_style(anchor: String) -> Style {
185 Style::new()
188 .position_anchor(raw(anchor))
189 .position_area(raw("block-end span-inline-end"))
190 .position_try_fallbacks(PositionTryFallbacks::FlipBlock)
191 .inset(raw("auto"))
192 .margin(px(0))
193 .margin_top(rem(0.25))
194 .min_width(rem(8))
195 .padding(rem(0.25))
196 .border_width(px(1))
197 .border_style(BorderStyle::Solid)
198 .border_color(token::border().var())
199 .border_radius(px(6))
200 .background_color(token::popover().var())
201 .color(token::popover_foreground().var())
202 .box_shadow(raw(
203 "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
204 ))
205}
206
207fn item_style(destructive: bool) -> Style {
208 let text = if destructive {
209 token::destructive().var()
210 } else {
211 token::popover_foreground().var()
212 };
213 Style::new()
214 .display(Display::Flex)
215 .align_items(AlignItems::Center)
216 .gap(rem(0.5))
217 .width(hypersig::style::value::pct(100))
218 .padding_block(rem(0.375))
219 .padding_inline(rem(0.5))
220 .border_style(BorderStyle::None)
221 .border_radius(px(4))
222 .background_color(Color::TRANSPARENT)
223 .color(text)
224 .text_align(TextAlign::Start)
225 .cursor(Cursor::Pointer)
226 .merge(look::small_text())
227 .on(
228 PseudoClass::Hover,
229 Style::new().background_color(token::accent().var()),
230 )
231 .on(
232 PseudoClass::Focus,
233 Style::new().background_color(token::accent().var()),
234 )
235 .on(PseudoClass::FocusVisible, look::ring(px(-2)))
236 .merge(look::disabled())
237}
238
239#[cfg(test)]
240mod tests {
241 use hypersig::html::div;
242
243 use super::Menu;
244
245 struct Page;
246
247 const ACTIONS: Menu<Page> = Menu::new("actions");
248
249 #[test]
250 #[should_panic(expected = "`actions` where the runtime can reach both")]
251 fn two_menus_with_one_name_panic_when_rendered() {
252 let _ = div::<Page>()
253 .child(ACTIONS.menu([]))
254 .child(ACTIONS.menu([]))
255 .render();
256 }
257}