M8: polish — keyboard navigation, Escape-to-close, toasts, empty states
(README and favicon intentionally deferred.) Keyboard navigation - File/folder entries (grid tiles and list rows) are now focusable (tabindex) and open on Enter/Space: folders navigate in, files open their preview. Visible :focus-visible outline. Escape-to-close - Every overlay now closes on Escape: dialogs (prompt/confirm/conflict/ picker/download-format/info), the preview, the shares panel, and the editor. The editor respects its sub-overlays — Escape first dismisses the "Unsaved changes" prompt (staying open) and only closes a clean editor directly; the conflict dialog requires an explicit choice. - Prompt inputs autofocus when the dialog opens. Consistent toasts - "Download started" for single files and folder archives. - "Share deleted" on successful share removal. Empty states - A user with no assigned folders sees a "No folders available" message instead of a blank picker. Verified in browser: Tab+Enter navigation (folder + file preview), Escape closing each overlay, editor Escape dirty-guard flow, download and share-delete toasts, autofocus, and the zero-roots empty state. All configs compile clean; production single binary builds and serves the new UI. Co-Authored-By: Qwen3.8 27b
Mweb/app.css
@@ -1152,3 +1152,34 @@ button:disabled {
border-color: var(--accent);
color: #fff;
}
/* ------------------------------------------------------------------ */
/* Empty state + keyboard focus (milestone 8) */
/* ------------------------------------------------------------------ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 64px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
line-height: 1;
}
.empty-state h3 {
margin: 0;
font-size: 16px;
}
/* Visible keyboard focus on selectable file/folder entries. */
.tile:focus-visible,
.row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
Mweb/src/editor.rs
@@ -242,6 +242,26 @@ pub fn EditorModal(
});
}
// Escape: closes the unsaved-changes prompt if open, otherwise closes the
// editor (with the dirty guard). The conflict dialog is left alone — it
// requires an explicit choice.
{
let cd = confirm_discard;
let scd = set_confirm_discard;
let cf = conflict;
let rc = request_close.clone();
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if cd.get() {
scd.set(false);
} else if !cf.get() {
rc.run(());
}
});
}
view! {
<div class="modal-overlay editor-overlay" on:click=move |_| request_close.run(())>
<div class="modal editor-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
Mweb/src/preview.rs
@@ -78,6 +78,15 @@ pub fn PreviewModal(
_ => api::preview_url(t.root_id, &t.path),
};
let name = t.name.clone();
// Escape closes the preview.
{
let c = close;
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
c.run(());
}
});
}
view! {
<div class="modal-overlay preview-overlay" on:click=move |_| close.run(())>
<div class="modal preview-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
Mweb/src/views/browser.rs
@@ -160,8 +160,20 @@ fn root_picker(
<div class="crumbs">
<span class="crumb current">"Folders"</span>
</div>
<div class="root-picker">
{roots.iter().map(|r| {
{if roots.is_empty() {
view! {
<div class="empty-state">
<div class="empty-icon">"📂"</div>
<h3>"No folders available"</h3>
<p class="muted">"An administrator hasn't assigned you any folders yet."</p>
</div>
}
.into_view()
.into_any()
} else {
view! {
<div class="root-picker">
{roots.iter().map(|r| {
let id = r.id;
let name = r.name.clone();
let path = r.path.clone();
@@ -197,8 +209,12 @@ fn root_picker(
</span>
</div>
}
}).collect::<Vec<_>>()}
</div>
}).collect::<Vec<_>>()}
</div>
}
.into_view()
.into_any()
}}
}
}
@@ -417,7 +433,7 @@ fn grid_view(
let l = *loc;
let sp = *set_preview;
let name2 = name.clone();
let on_click = move |_| {
let open_cb: Callback<()> = Callback::new(move |_| {
if is_dir {
nav();
} else {
@@ -437,11 +453,20 @@ fn grid_view(
None => show(t, "No preview available — right-click to download"),
}
}
};
});
let oc1 = open_cb.clone();
let oc2 = open_cb.clone();
view! {
<div
class="tile"
on:click=on_click
tabindex=0
on:click=move |_| oc1.run(())
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
oc2.run(());
}
}
on:contextmenu=on_ctx
title=title
>
@@ -477,7 +502,7 @@ fn list_view(
let l = *loc;
let sp = *set_preview;
let name2 = name.clone();
let on_click = move |_| {
let open_cb: Callback<()> = Callback::new(move |_| {
if is_dir {
nav();
} else {
@@ -497,9 +522,23 @@ fn list_view(
None => show(t, "No preview available — right-click to download"),
}
}
};
});
let oc1 = open_cb.clone();
let oc2 = open_cb.clone();
view! {
<div class="row" on:click=on_click on:contextmenu=on_ctx title=title>
<div
class="row"
tabindex=0
on:click=move |_| oc1.run(())
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
oc2.run(());
}
}
on:contextmenu=on_ctx
title=title
>
<Icon name=icon class=Some("row-icon".into())/>
<span class="row-name">{name}</span>
<span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
@@ -603,9 +642,10 @@ fn CtxMenuView(
}
let e_dl = e2.clone();
let l_dl = loc;
let t_dl = toast;
v.push((
"Download".into(),
Some(action_download(&e_dl, eff_root_id, l_dl, set_dialog, owner.clone())),
Some(action_download(&e_dl, eff_root_id, l_dl, set_dialog, t_dl, owner.clone())),
None,
));
// Editing is only offered for text/code files.
@@ -1120,6 +1160,7 @@ fn action_download(
root_id: Option<i64>,
loc: ReadSignal<Location>,
set_dialog: WriteSignal<Option<Dialog>>,
toast: ToastMsg,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
@@ -1138,6 +1179,7 @@ fn action_download(
} else {
let url = api::download_url(root_id, &full, None);
api::trigger_download(&url, &name);
show(toast, "Download started");
}
})
})
Mweb/src/views/dialogs.rs
@@ -7,6 +7,7 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::{Icon, IconName};
use crate::components::toast::{show, ToastMsg};
/// Which mutating operation the destination picker is for.
#[derive(Clone, Copy, PartialEq)]
@@ -92,6 +93,16 @@ pub fn DialogView(
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
// Escape closes the active dialog.
{
let d = dialog;
let set_d = set_dialog;
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" && d.get().is_some() {
set_d.set(None);
}
});
}
view! {
{move || {
let Some(d) = dialog.get() else {
@@ -305,6 +316,7 @@ fn PromptDialog(
id=input_id
class="input"
value=initial
autofocus=true
on:keydown=move |e: web_sys::KeyboardEvent| {
if e.key() == "Enter" {
e.prevent_default();
@@ -637,6 +649,7 @@ fn DownloadFormatDialog(
path: String,
on_close: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let formats: [(&str, &str, &str); 4] = [
("zip", "Zip", ".zip"),
("tar", "Tar", ".tar"),
@@ -662,6 +675,7 @@ fn DownloadFormatDialog(
let url = api::download_url(root_id, &path, Some(id));
let fname = format!("{name}{ext}");
api::trigger_download(&url, &fname);
show(toast, "Download started");
on_close.run(());
}
>