Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion crates/web/src/components/search/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
//! - `Esc` with a non-empty query clears it; `Esc` with an empty query
//! closes the surface (spec §Desktop — Escape contract);
//! - `aria-controls="search-results-list"` + `aria-autocomplete="list"`
//! + a placeholder that mirrors the active scope.
//! + a placeholder that mirrors the active scope;
//! - `ArrowUp` / `ArrowDown` / `Home` / `End` move
//! [`SearchUiState::active_index`](crate::state::SearchUiState::active_index)
//! so keyboard users can see which result row is the activation
//! target. The active row is also announced via
//! `aria-activedescendant`, which points at the row's
//! `id="search-row-{message_id}"` per WAI-ARIA listbox guidance.

use leptos::prelude::*;
use willow_client::SearchScope;
Expand Down Expand Up @@ -37,6 +43,27 @@ pub fn SearchInput(

let placeholder = move || placeholder_for(&state.search.scope.get());

// Length of the *flat, in-display-order* result list. Computed
// here (not in the keydown handler) so the bound stays current
// when results are streamed in.
let result_count = move || {
super::results::flat_ordered(
&state.search.results.get_untracked(),
&state.search.scope.get_untracked(),
)
.len()
};

// Active row's DOM id, or `None` when there are no results. Used
// for `aria-activedescendant` — per WAI-ARIA, the attribute must
// be omitted (not blank) when no option is active.
let active_descendant = Memo::new(move |_| {
let flat =
super::results::flat_ordered(&state.search.results.get(), &state.search.scope.get());
let i = state.search.active_index.get();
flat.get(i).map(|r| format!("search-row-{}", r.message_id))
});

let on_keydown = move |ev: web_sys::KeyboardEvent| match ev.key().as_str() {
"Escape" => {
ev.prevent_default();
Expand All @@ -50,6 +77,44 @@ pub fn SearchInput(
ev.prevent_default();
on_submit.run(state.search.query.get_untracked());
}
"ArrowDown" => {
let n = result_count();
if n == 0 {
return;
}
ev.prevent_default();
let cur = state.search.active_index.get_untracked();
// Wrap to top at the tail. Wrapping is the listbox-pattern
// default and matches how command palettes elsewhere in
// the app behave.
let next = if cur + 1 >= n { 0 } else { cur + 1 };
write.search.set_active_index.set(next);
}
"ArrowUp" => {
let n = result_count();
if n == 0 {
return;
}
ev.prevent_default();
let cur = state.search.active_index.get_untracked();
let next = if cur == 0 { n - 1 } else { cur - 1 };
write.search.set_active_index.set(next);
}
"Home" => {
if result_count() == 0 {
return;
}
ev.prevent_default();
write.search.set_active_index.set(0);
}
"End" => {
let n = result_count();
if n == 0 {
return;
}
ev.prevent_default();
write.search.set_active_index.set(n - 1);
}
_ => {}
};

Expand All @@ -73,6 +138,7 @@ pub fn SearchInput(
aria-label="local search input"
aria-autocomplete="list"
aria-controls="search-results-list"
aria-activedescendant=move || active_descendant.get()
prop:value=move || state.search.query.get()
on:input=move |ev| write.search.set_query.set(event_target_value(&ev))
on:keydown=on_keydown
Expand Down
51 changes: 45 additions & 6 deletions crates/web/src/components/search/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,37 @@ use willow_client::{SearchIndexBuildStatus, SearchResult, SearchScope};
use super::row::ResultRow;
use crate::state::AppState;

/// Compute the cumulative flat-index offset of every group in
/// `groups`. Returns a vector of the same length whose `i`th entry is
/// the count of rows that appear *before* group `i` in the rendered
/// listbox. Combined with the row's intra-group index, this yields the
/// row's global flat index — the unit `active_index` is expressed in.
fn group_offsets(groups: &[(String, Vec<SearchResult>)]) -> Vec<usize> {
let mut offsets = Vec::with_capacity(groups.len());
let mut running = 0usize;
for (_, items) in groups {
offsets.push(running);
running += items.len();
}
offsets
}

/// Flatten grouped results into the order rows appear in the listbox.
/// `active_index` indexes into this vector, so keyboard navigation in
/// `<SearchInput>` and `aria-selected` rendering here agree on what
/// "row N" means.
pub(super) fn flat_ordered(rows: &[SearchResult], scope: &SearchScope) -> Vec<SearchResult> {
group_results(rows, scope)
.into_iter()
.flat_map(|(_, items)| items)
.collect()
}

/// Group results per the spec's scope-dependent rules.
fn group_results(rows: &[SearchResult], scope: &SearchScope) -> Vec<(String, Vec<SearchResult>)> {
pub(super) fn group_results(
rows: &[SearchResult],
scope: &SearchScope,
) -> Vec<(String, Vec<SearchResult>)> {
match scope {
SearchScope::ThisChannel(_) | SearchScope::ThisLetter(_) => {
vec![(String::new(), rows.to_vec())]
Expand Down Expand Up @@ -69,12 +98,15 @@ pub fn ResultsList(

let groups =
Memo::new(move |_| group_results(&state.search.results.get(), &state.search.scope.get()));
let active_index = state.search.active_index;

let sections = move || {
groups
.get()
let groups_now = groups.get();
let offsets = group_offsets(&groups_now);
groups_now
.into_iter()
.map(|(label, items)| {
.enumerate()
.map(|(group_idx, (label, items))| {
let header = if label.is_empty() {
None
} else {
Expand All @@ -87,13 +119,20 @@ pub fn ResultsList(
</div>
})
};
let base = offsets[group_idx];
let rows: Vec<AnyView> = items
.into_iter()
.map(|r| {
.enumerate()
.map(|(intra, r)| {
// Flat (in-display-order) index of this row.
// The `active_index` signal is expressed in the
// same units so a row's `selected` derives from
// a single equality check.
let flat = base + intra;
view! {
<ResultRow
result=r
selected=Signal::derive(|| false)
selected=Signal::derive(move || active_index.get() == flat)
on_select=on_select
/>
}
Expand Down
11 changes: 11 additions & 0 deletions crates/web/src/components/search/surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ pub fn SearchSurface(
let state = use_context::<AppState>().expect("AppState");
let write = use_context::<AppWriteSignals>().expect("AppWriteSignals");

// Whenever the result set or scope changes, snap keyboard focus
// back to the first row. Without this, an `active_index` from a
// prior result set could outlive its data and point past the new
// tail (or at a different message entirely), breaking
// `aria-activedescendant` and `aria-selected`.
Effect::new(move |_| {
let _ = state.search.results.get();
let _ = state.search.scope.get();
write.search.set_active_index.set(0);
});

// Debounced query driver: 120 ms after the last keystroke, parse
// the query and run against the index under the current scope.
//
Expand Down
11 changes: 11 additions & 0 deletions crates/web/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ pub struct SearchUiState {
/// True while a 120 ms debounce timer is outstanding — UI dims the
/// stale results row by 15 % per spec §Performance envelope.
pub debouncing: ReadSignal<bool>,
/// Index of the keyboard-active result row in the flat (in-display-
/// order) results vector. Drives `aria-selected` per row and
/// `aria-activedescendant` on the search input. Reset to `0` when
/// the result set or scope changes. When `results` is empty, the
/// value is meaningless and consumers must not render
/// `aria-activedescendant`.
pub active_index: ReadSignal<usize>,
}

/// Tightened connection state companion to `NetworkState::connection_status`.
Expand Down Expand Up @@ -323,6 +330,7 @@ pub struct SearchUiWriteSignals {
pub set_status: WriteSignal<SearchIndexBuildStatus>,
pub set_recents: WriteSignal<Vec<RecentQuery>>,
pub set_debouncing: WriteSignal<bool>,
pub set_active_index: WriteSignal<usize>,
}

#[derive(Clone, Copy)]
Expand Down Expand Up @@ -561,6 +569,7 @@ pub fn create_signals() -> InitialSignals {
let (search_status, set_search_status) = signal(SearchIndexBuildStatus::default());
let (search_recents, set_search_recents) = signal(Vec::<RecentQuery>::new());
let (search_debouncing, set_search_debouncing) = signal(false);
let (search_active_index, set_search_active_index) = signal(0usize);

// Persist scope on every change so the user's preference survives
// a reload. Run on wasm only — native tests don't mount this state.
Expand Down Expand Up @@ -660,6 +669,7 @@ pub fn create_signals() -> InitialSignals {
status: search_status,
recents: search_recents,
debouncing: search_debouncing,
active_index: search_active_index,
},
queue: QueueUiState {
view: queue_view,
Expand Down Expand Up @@ -746,6 +756,7 @@ pub fn create_signals() -> InitialSignals {
set_status: set_search_status,
set_recents: set_search_recents,
set_debouncing: set_search_debouncing,
set_active_index: set_search_active_index,
},
queue: QueueWriteSignals {
set_view: set_queue_view,
Expand Down
Loading