ui_storybook/components/library/
library_sidebar.rs1use leptos::prelude::*;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LibraryNavItemView {
13 pub id: &'static str,
16 pub label: &'static str,
18 pub icon: &'static str,
20 pub count: Option<u32>,
22 pub badge: Option<u32>,
24 pub selected: bool,
26 pub disabled: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct LibrarySectionView {
33 pub heading: &'static str,
35 pub items: Vec<LibraryNavItemView>,
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct StorageMeterView {
42 pub used_bytes_label: String,
44 pub quota_label: String,
46 pub percent_used: f32,
48 pub plan_label: Option<&'static str>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct LibrarySidebarView {
55 pub primary: Vec<LibraryNavItemView>,
58 pub sections: Vec<LibrarySectionView>,
60 pub storage: StorageMeterView,
62}
63
64#[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#[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}