finally have a proper scroll bar

This commit is contained in:
Connor Johnstone
2026-04-01 12:25:59 -04:00
parent 07aa9908e8
commit 61225158f0
3 changed files with 202 additions and 64 deletions
+182 -41
View File
@@ -35,6 +35,111 @@ pub fn library_page() -> Html {
});
}
// Measure DOM heights after render and set spacer flex-grow values directly.
// Must be called before any early returns to maintain consistent hook order.
{
let artist_count = artists.as_ref().map(|a| a.len()).unwrap_or(0);
use_effect_with(artist_count, move |_| {
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
// a. height of the first row
let row_h = doc
.query_selector("#library-table tbody tr")
.ok()
.flatten()
.and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
.map(|el| el.offset_height() as f64)
.unwrap_or(0.0);
// b. px from top of page to first artist row
let header_h = doc
.query_selector("[id^='letter-']")
.map(|el| {
el.map(|el| {
let rect = el.get_bounding_client_rect();
rect.top() + window.scroll_y().unwrap_or(0.0)
})
.unwrap_or(0.0)
})
.unwrap_or(0.0);
// c. footer = distance from last row bottom to document bottom
let doc_h = doc
.document_element()
.map(|el| el.scroll_height() as f64)
.unwrap_or(0.0);
let last_row_bottom = doc
.query_selector("#library-table tbody tr:last-child")
.ok()
.flatten()
.map(|el| {
let rect = el.get_bounding_client_rect();
rect.bottom() + window.scroll_y().unwrap_or(0.0)
})
.unwrap_or(doc_h);
let footer_h = (doc_h - last_row_bottom).max(0.0);
// d. Compute percentage heights for all track elements.
// Total page = header + table rows + footer
let num_rows = artist_count.max(1) as f64;
let table_h = num_rows * row_h;
let total_h = header_h + table_h + footer_h;
if total_h <= 0.0 {
return;
}
let header_pct = header_h / total_h * 100.0;
let footer_pct = footer_h / total_h * 100.0;
let table_pct = table_h / total_h * 100.0;
// Set header spacer height
if let Some(el) = doc.get_element_by_id("scroll-track-header-spacer") {
if let Ok(el) = el.dyn_into::<web_sys::HtmlElement>() {
let _ = el
.style()
.set_property("height", &format!("{header_pct:.2}%"));
}
}
// Set footer spacer height
if let Some(el) = doc.get_element_by_id("scroll-track-footer-spacer") {
if let Ok(el) = el.dyn_into::<web_sys::HtmlElement>() {
let _ = el
.style()
.set_property("height", &format!("{footer_pct:.2}%"));
}
}
// Set each letter's height (letter_fraction * table_pct)
let track_h = window
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(800.0);
if let Ok(letters) = doc.query_selector_all(".scroll-track-letter") {
for i in 0..letters.length() {
if let Some(el) = letters.item(i) {
if let Ok(el) = el.dyn_into::<web_sys::HtmlElement>() {
if let Some(grow_str) = el.dataset().get("grow") {
if let Ok(grow) = grow_str.parse::<f64>() {
let pct = grow * table_pct;
let px = pct / 100.0 * track_h;
let _ = el
.style()
.set_property("height", &format!("{pct:.2}%"));
// Hide text if cell is too short to fit it
if px < 18.0 {
let _ = el.style().set_property("font-size", "0");
}
}
}
}
}
}
}
}
}
});
}
if let Some(ref err) = *error {
return html! { <div class="error">{ format!("Error: {err}") }</div> };
}
@@ -61,13 +166,33 @@ pub fn library_page() -> Html {
}
})
.collect();
let active_letters: HashSet<char> = first_of_letter.values().copied().collect();
// Build scroll track letter data
let letters: Vec<char> = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let scroll_track_items: Vec<(String, bool)> = letters
// Count artists per letter for proportional sizing
let mut letter_counts: HashMap<char, usize> = HashMap::new();
for a in artists.iter() {
let first = a.name.chars().next().unwrap_or('#').to_ascii_uppercase();
let letter = if first.is_ascii_alphabetic() {
first
} else {
'#'
};
*letter_counts.entry(letter).or_default() += 1;
}
let total_artists = artists.len().max(1);
let all_letters: Vec<char> = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let scroll_track_items: Vec<(String, f64)> = all_letters
.iter()
.map(|&c| (c.to_string(), active_letters.contains(&c)))
.filter_map(|&c| {
let count = letter_counts.get(&c).copied().unwrap_or(0);
if count > 0 {
// Percentage of total artists — will be scaled to percentage of track
// by the effect that measures header/footer
Some((c.to_string(), count as f64 / total_artists as f64))
} else {
None
}
})
.collect();
html! {
@@ -80,29 +205,7 @@ pub fn library_page() -> Html {
if artists.is_empty() {
<p class="text-muted">{ "No artists in library. Use Search to add some!" }</p>
} else {
<div class="scroll-track">
{ for scroll_track_items.iter().map(|(letter, active)| {
let letter_c = letter.clone();
let active = *active;
let class = if active { "scroll-track-letter has-artists" } else { "scroll-track-letter" };
let onclick = Callback::from(move |_: MouseEvent| {
if active {
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
if let Some(el) = doc.get_element_by_id(&format!("letter-{}", letter_c)) {
let _ = el.dyn_into::<web_sys::HtmlElement>()
.map(|el| el.scroll_into_view());
}
}
}
}
});
html! {
<div class={class} onclick={onclick}>{ letter }</div>
}
})}
</div>
<table>
<table id="library-table">
<thead>
<tr>
<th>{ "Name" }</th>
@@ -114,12 +217,14 @@ pub fn library_page() -> Html {
</thead>
<tbody>
{ for artists.iter().map(|a| {
let anchor_id = first_of_letter.get(&a.id).map(|c| format!("letter-{c}"));
let anchor_id =
first_of_letter.get(&a.id).map(|c| format!("letter-{c}"));
let artist_id = a.id;
let artist_name = a.name.clone();
let artist_mbid = a.musicbrainz_id.clone();
let is_watched = a.total_watched > 0;
let is_fully_watched = a.enriched && a.total_watched >= a.total_items && a.total_items > 0;
let is_fully_watched =
a.enriched && a.total_watched >= a.total_items && a.total_items > 0;
let is_monitored = a.monitored;
let on_remove = {
@@ -178,7 +283,9 @@ pub fn library_page() -> Html {
let error = error.clone();
let fetch = fetch.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::set_artist_monitored(artist_id, !is_monitored).await {
match api::set_artist_monitored(artist_id, !is_monitored)
.await
{
Ok(_) => fetch.emit(()),
Err(e) => error.set(Some(e.0)),
}
@@ -192,22 +299,29 @@ pub fn library_page() -> Html {
} else {
0
};
let owned_color = if a.total_owned >= a.total_watched && a.total_watched > 0 {
"var(--success)"
} else if a.total_owned > 0 {
"var(--warning)"
} else {
"var(--text-muted)"
};
let owned_bar_style = format!("width:{owned_pct}%;background:{owned_color};");
let owned_color =
if a.total_owned >= a.total_watched && a.total_watched > 0 {
"var(--success)"
} else if a.total_owned > 0 {
"var(--warning)"
} else {
"var(--text-muted)"
};
let owned_bar_style =
format!("width:{owned_pct}%;background:{owned_color};");
let watched_pct = if a.total_items > 0 {
(a.total_watched as f64 / a.total_items as f64 * 100.0) as u32
} else {
0
};
let watched_color = if a.total_watched > 0 { "var(--accent)" } else { "var(--text-muted)" };
let watched_bar_style = format!("width:{watched_pct}%;background:{watched_color};");
let watched_color = if a.total_watched > 0 {
"var(--accent)"
} else {
"var(--text-muted)"
};
let watched_bar_style =
format!("width:{watched_pct}%;background:{watched_color};");
html! {
<tr id={anchor_id}>
@@ -263,6 +377,33 @@ pub fn library_page() -> Html {
})}
</tbody>
</table>
<div class="scroll-track">
<div id="scroll-track-header-spacer"></div>
{ for scroll_track_items.iter().map(|(letter, grow)| {
let letter_c = letter.clone();
let data_grow = format!("{grow:.6}");
let onclick = Callback::from(move |_: MouseEvent| {
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
if let Some(el) = doc.get_element_by_id(&format!("letter-{}", letter_c)) {
let rect = el.get_bounding_client_rect();
let scroll_y = window.scroll_y().unwrap_or(0.0);
window.scroll_to_with_x_and_y(
0.0,
rect.top() + scroll_y - 60.0,
);
}
}
}
});
html! {
<div class="scroll-track-letter" data-grow={data_grow} {onclick}>
{ letter }
</div>
}
})}
<div id="scroll-track-footer-spacer"></div>
</div>
}
</div>
}