Skip to main content

nightshade_api/web/
toolbar.rs

1//! Toolbar, activity-bar, and menu-bar chrome components.
2
3use leptos::prelude::*;
4
5/// A horizontal toolbar container with `role="toolbar"`; `class` is appended for styling.
6#[component]
7pub fn Toolbar(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
8    view! { <div class=format!("nightshade-toolbar {class}") role="toolbar">{children()}</div> }
9}
10
11/// Groups related toolbar controls so they cluster together visually.
12#[component]
13pub fn ToolbarGroup(children: Children) -> impl IntoView {
14    view! { <div class="nightshade-toolbar-group">{children()}</div> }
15}
16
17/// A flexible gap that pushes surrounding toolbar groups apart.
18#[component]
19pub fn ToolbarSpacer() -> impl IntoView {
20    view! { <div class="nightshade-toolbar-spacer"></div> }
21}
22
23/// A toolbar button that fires `on_click` unless `disabled`, reflecting `active` as a pressed state and using `title` as its tooltip.
24#[component]
25pub fn ToolButton(
26    #[prop(into, optional)] class: String,
27    #[prop(into, optional)] active: Signal<bool>,
28    #[prop(into, optional)] disabled: Signal<bool>,
29    #[prop(into, optional)] title: String,
30    #[prop(optional)] on_click: Option<Callback<web_sys::MouseEvent>>,
31    children: Children,
32) -> impl IntoView {
33    let handle = move |event: web_sys::MouseEvent| {
34        if !disabled.get_untracked()
35            && let Some(callback) = on_click
36        {
37            callback.run(event);
38        }
39    };
40    view! {
41        <button
42            class=format!("nightshade-tool-button {class}")
43            class:active=move || active.get()
44            title=title
45            aria-pressed=move || active.get().to_string()
46            disabled=move || disabled.get()
47            on:click=handle
48        >
49            {children()}
50        </button>
51    }
52}
53
54/// One entry in an [`ActivityBar`]: an `id`, an `icon` glyph, and a `label` tooltip.
55#[derive(Clone)]
56pub struct ActivityItem {
57    /// Stable identifier matched against the bar's `active` signal.
58    pub id: String,
59    /// Icon glyph or short text rendered inside the button.
60    pub icon: String,
61    /// Accessible label used as the button's tooltip.
62    pub label: String,
63}
64
65impl ActivityItem {
66    /// Creates an activity item from its id, icon, and label.
67    pub fn new(id: impl Into<String>, icon: impl Into<String>, label: impl Into<String>) -> Self {
68        Self {
69            id: id.into(),
70            icon: icon.into(),
71            label: label.into(),
72        }
73    }
74}
75
76/// A vertical strip of icon buttons; clicking one sets `active` to its id and invokes the optional `on_select` callback.
77#[component]
78pub fn ActivityBar(
79    items: Vec<ActivityItem>,
80    active: RwSignal<String>,
81    #[prop(optional)] on_select: Option<Callback<String>>,
82) -> impl IntoView {
83    view! {
84        <div class="nightshade-activity-bar" role="tablist">
85            {items
86                .into_iter()
87                .map(|item| {
88                    let id_click = item.id.clone();
89                    let id_active = StoredValue::new(item.id.clone());
90                    let is_active = move || active.get() == id_active.get_value();
91                    view! {
92                        <button
93                            class="nightshade-activity-item"
94                            class:active=is_active
95                            role="tab"
96                            title=item.label
97                            aria-selected=move || is_active().to_string()
98                            on:click=move |_| {
99                                active.set(id_click.clone());
100                                if let Some(callback) = on_select {
101                                    callback.run(id_click.clone());
102                                }
103                            }
104                        >
105                            {item.icon}
106                        </button>
107                    }
108                })
109                .collect_view()}
110        </div>
111    }
112}
113
114#[derive(Clone, Copy)]
115struct MenuBarContext(RwSignal<Option<String>>);
116
117/// An application menu bar that coordinates its [`MenuBarMenu`] children so only one is open at a time and hover switches between them once one is open.
118#[component]
119pub fn MenuBar(children: Children) -> impl IntoView {
120    let active = RwSignal::new(None::<String>);
121    provide_context(MenuBarContext(active));
122    view! {
123        <div
124            class="nightshade-menu-bar"
125            role="menubar"
126            on:pointerleave=move |_| active.set(None)
127        >
128            {children()}
129        </div>
130    }
131}
132
133/// A single top-level menu within a [`MenuBar`], keyed by `id`, showing a `label` trigger that toggles its `children` dropdown and coordinates open state through the bar's context.
134#[component]
135pub fn MenuBarMenu(
136    #[prop(into)] id: String,
137    #[prop(into)] label: String,
138    children: ChildrenFn,
139) -> impl IntoView {
140    let active = use_context::<MenuBarContext>()
141        .map(|context| context.0)
142        .unwrap_or_else(|| RwSignal::new(None));
143    let id = StoredValue::new(id);
144    let children = StoredValue::new(children);
145    let is_open = move || active.get().as_deref() == Some(id.get_value().as_str());
146    let toggle = move |_: web_sys::MouseEvent| {
147        let key = id.get_value();
148        active.update(|current| {
149            *current = if current.as_deref() == Some(key.as_str()) {
150                None
151            } else {
152                Some(key)
153            };
154        });
155    };
156    let hover = move |_: web_sys::PointerEvent| {
157        if active.get_untracked().is_some() {
158            active.set(Some(id.get_value()));
159        }
160    };
161    view! {
162        <div class="nightshade-menu-bar-item">
163            <button
164                class="nightshade-menu-bar-trigger"
165                class:active=is_open
166                aria-haspopup="menu"
167                aria-expanded=move || is_open().to_string()
168                on:click=toggle
169                on:pointerenter=hover
170            >
171                {label}
172            </button>
173            <Show when=is_open fallback=|| ()>
174                <div class="nightshade-menu-list" role="menu" on:click=move |_| active.set(None)>
175                    {children.with_value(|render| render())}
176                </div>
177            </Show>
178        </div>
179    }
180}