Web client: scroll the keyboard cursor back into view

Moving the library or queue cursor with the keyboard (j/k, page keys,
first/last) updated the selection but never scrolled the list, so the
selected row could slide out of the scroll box and disappear.

Each pane now runs an effect that re-scrolls its `.selected` row into
view whenever the cursor moves. The queue cursor is its own signal, so
this fires on moves but not on every stream-driven queue refresh; the
library cursor lives in the library pane signal. The scroll is deferred
to the next animation frame (the freshly-rendered row must be in the
DOM) and uses `block: nearest`, so an already-visible row does not jump.
Click/drag selection needs no help -- the pointer is already on-screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 18:36:09 +02:00
parent 42c5e9dbc2
commit b6f1275d6b
2 changed files with 38 additions and 0 deletions

View File

@ -25,6 +25,8 @@ web-sys = { workspace = true, features = [
"KeyboardEvent",
"Location",
"Performance",
"ScrollIntoViewOptions",
"ScrollLogicalPosition",
"Storage",
"Window",
] }

View File

@ -65,6 +65,27 @@ fn apply_theme(theme: &str) {
}
}
/// Scrolls the selected row of a pane back into view after a keyboard move,
/// so the cursor never drifts out of the scroll box (the click handlers
/// need no help — the pointer is already on-screen). `pane_selector` picks
/// the pane (`.pane.library` / `.pane.queue`); we then find its `.selected`
/// row. Deferred to the next animation frame so the freshly-rendered row is
/// in the DOM. `block: nearest` scrolls the minimum amount — an already
/// visible row does not move.
fn scroll_selected_into_view(pane_selector: &'static str) {
request_animation_frame(move || {
let selector = format!("{pane_selector} .list li.selected");
if let Some(el) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector(&selector).ok().flatten())
{
let opts = web_sys::ScrollIntoViewOptions::new();
opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
el.scroll_into_view_with_scroll_into_view_options(&opts);
}
});
}
// ---- the store ---------------------------------------------------------
/// Every signal the components share. `Copy` so closures capture it
@ -733,6 +754,13 @@ fn LibraryView(store: Store) -> impl IntoView {
let focused = move || store.focus.get() == Focus::Library;
let library = store.library;
// Keep the keyboard cursor visible: whenever the library cursor (or its
// listing) changes, scroll the selected row back into the scroll box.
Effect::new(move |_| {
let _ = library.with(|p| p.selected);
scroll_selected_into_view(".pane.library");
});
let toolbar = move || {
let pane = library.get();
let selection = pane.selected_item();
@ -888,6 +916,14 @@ fn LibraryView(store: Store) -> impl IntoView {
#[component]
fn QueueView(store: Store) -> impl IntoView {
let focused = move || store.focus.get() == Focus::Queue;
// Keep the keyboard cursor visible. The cursor is its own signal, so this
// fires on moves but not on every stream-driven queue refresh.
Effect::new(move |_| {
let _ = store.queue_cursor.get();
scroll_selected_into_view(".pane.queue");
});
view! {
<section
class="pane queue"