Skip to main content

ui_storybook/components/library/
library_sidebar.rs

1//! `LibrarySidebar` + storage meter (M-UI.14 / AUT-134) — left rail of
2//! the library screen. Nav items + sections (`SPACES`, `TAGS`) + a
3//! bottom storage quota meter.
4//!
5//! Pure presentational composition over UI-01 / UI-04 primitives. The
6//! selected nav id + counts + storage values flow in as props.
7
8use leptos::prelude::*;
9
10/// One row in the sidebar.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LibraryNavItemView {
13    /// Stable id ("new", "all", "starred", "shared", "inbox", or a
14    /// space/tag id).
15    pub id: &'static str,
16    /// Display label.
17    pub label: &'static str,
18    /// Leading glyph.
19    pub icon: &'static str,
20    /// Optional N-recordings count shown muted on the right.
21    pub count: Option<u32>,
22    /// Optional unread/notification badge ("3" on Inbox).
23    pub badge: Option<u32>,
24    /// `true` when this row is the active selection.
25    pub selected: bool,
26    /// `true` to render dimmed and non-interactive.
27    pub disabled: bool,
28}
29
30/// One section in the sidebar (`SPACES`, `TAGS`).
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct LibrarySectionView {
33    /// Uppercase section header (`"SPACES"`).
34    pub heading: &'static str,
35    /// Rows in display order.
36    pub items: Vec<LibraryNavItemView>,
37}
38
39/// Storage quota meter shown at the bottom of the sidebar.
40#[derive(Debug, Clone, PartialEq)]
41pub struct StorageMeterView {
42    /// Pre-formatted used label ("12.4 GB").
43    pub used_bytes_label: String,
44    /// Pre-formatted quota label ("50 GB").
45    pub quota_label: String,
46    /// `[0, 1]` ratio. The renderer clamps and converts to percent.
47    pub percent_used: f32,
48    /// Optional plan name ("Free", "Pro").
49    pub plan_label: Option<&'static str>,
50}
51
52/// Composite sidebar view-model.
53#[derive(Debug, Clone, PartialEq)]
54pub struct LibrarySidebarView {
55    /// Top section (New / All / Starred / Shared / Inbox). Rendered
56    /// without a section heading.
57    pub primary: Vec<LibraryNavItemView>,
58    /// Additional sections in display order (`SPACES`, `TAGS`).
59    pub sections: Vec<LibrarySectionView>,
60    /// Bottom storage meter.
61    pub storage: StorageMeterView,
62}
63
64/// Clamp a `0.0..=1.0` fraction to a `0..=100` integer percent.
65#[must_use]
66#[allow(
67    clippy::cast_possible_truncation,
68    clippy::cast_sign_loss,
69    reason = "percent is clamped to [0, 100] before cast"
70)]
71pub fn storage_percent(fraction: f32) -> u8 {
72    (fraction.clamp(0.0, 1.0) * 100.0).round() as u8
73}
74
75#[component]
76pub fn LibrarySidebar(view: LibrarySidebarView) -> impl IntoView {
77    let primary_rows: Vec<_> = view.primary.into_iter().map(render_nav_row).collect();
78    let sections: Vec<_> = view
79        .sections
80        .into_iter()
81        .map(|s| {
82            let rows: Vec<_> = s.items.into_iter().map(render_nav_row).collect();
83            view! {
84                <section class="library-section">
85                    <span class="library-section-heading">{s.heading}</span>
86                    <ul class="library-section-rows" role="group" aria-label=s.heading>{rows}</ul>
87                </section>
88            }
89        })
90        .collect();
91    view! {
92        <aside class="library-sidebar" role="navigation" aria-label="Library">
93            <ul class="library-primary" role="list">{primary_rows}</ul>
94            {sections}
95            <StorageMeter view=view.storage />
96        </aside>
97    }
98}
99
100/// Bottom storage meter (also used standalone in `StorageMeter`
101/// stories so the slider is reviewable without the rest of the
102/// sidebar).
103#[component]
104pub fn StorageMeter(view: StorageMeterView) -> impl IntoView {
105    let pct = storage_percent(view.percent_used);
106    let fill_style = format!("width: {pct}%;");
107    let warn_class = if pct >= 85 { " storage-meter-warn" } else { "" };
108    let class = format!("storage-meter{warn_class}");
109    view! {
110        <div class=class>
111            <div class="storage-meter-bar" role="progressbar" aria-valuenow=pct aria-valuemin="0" aria-valuemax="100">
112                <span class="storage-meter-fill" style=fill_style></span>
113            </div>
114            <div class="storage-meter-labels">
115                <span class="storage-meter-used">{view.used_bytes_label} " / " {view.quota_label}</span>
116                {view.plan_label.map(|p| view! {
117                    <span class="storage-meter-plan">{p}</span>
118                })}
119            </div>
120        </div>
121    }
122}
123
124fn render_nav_row(item: LibraryNavItemView) -> impl IntoView {
125    let mut class = String::from("library-nav-row");
126    if item.selected {
127        class.push_str(" library-nav-row-selected");
128    }
129    if item.disabled {
130        class.push_str(" library-nav-row-disabled");
131    }
132    view! {
133        <li class=class data-id=item.id aria-current=item.selected.then_some("page")>
134            <span class="library-nav-icon" aria-hidden="true">{item.icon}</span>
135            <span class="library-nav-label">{item.label}</span>
136            {item.badge.map(|b| view! {
137                <span class="library-nav-badge" aria-label=format!("{b} unread")>{b}</span>
138            })}
139            {item.count.map(|c| view! {
140                <span class="library-nav-count">{c}</span>
141            })}
142        </li>
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn storage_percent_clamps() {
152        assert_eq!(storage_percent(-0.5), 0);
153        assert_eq!(storage_percent(0.0), 0);
154        assert_eq!(storage_percent(0.5), 50);
155        assert_eq!(storage_percent(0.85), 85);
156        assert_eq!(storage_percent(1.0), 100);
157        assert_eq!(storage_percent(2.0), 100);
158    }
159}