Added toggle for the Folder nav and moved Save Note to the 'File ->' menu

This commit is contained in:
mrkmntal 2026-10-05 15:55:34 -04:00
commit 306c3c1e6e
4 changed files with 205 additions and 28 deletions

View file

@ -167,6 +167,7 @@ fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[Stri
button type="submit" { "New Note" }
}
}
li { button x-on:click="saveCurrentNote()" { "Save Note" } }
li { hr; }
li {
a href=(base.url("/export")) { "Export All Notes" }
@ -259,7 +260,6 @@ fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[Stri
span { "Edit" }
ul class="menu-dropdown" {
li { button x-on:click="focusEditor()" { "Focus Editor" } }
li { button x-on:click="saveCurrentNote()" { "Save Note" } }
}
}
li class="menu-item" tabindex="0"
@ -287,6 +287,17 @@ fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[Stri
"Toggle Preview"
}
}
// Available at every width. The folder nav is a sidebar
// sub-section, not a top-level pane, so collapsing it is
// useful on mobile too — the pull-tab button is the other
// entry point and shares this same state.
li {
button x-on:click="toggleFolderNav()"
x-bind:class="{ 'active-view': folderNavCollapsed }"
x-text="folderNavCollapsed ? 'Show Folder Nav' : 'Hide Folder Nav'" {
"Toggle Folder Nav"
}
}
}
}
li class="menu-item" tabindex="0"
@ -427,26 +438,36 @@ fn render_sidebar(
input type="text" name="name" placeholder="New folder name...";
button type="submit" { "+ New Folder" }
}
ul class="folder-nav" {
// An empty drop folder is the root: dropping a note on "All
// Notes" un-files it, which is the only way back out of a
// folder once every note has left it.
li class={"nav-item" (if active_folder.is_none() { " active" } else { "" })}
data-drop-folder="" {
a href=(base.url("/")) {
span class="nav-label" { "All Notes" }
@if show_nav_count(active_folder, None) {
span class="nav-count" { (notes.len()) }
div class="folder-nav-container"
x-bind:class="{ 'collapsed': folderNavCollapsed }" {
button type="button" class="folder-nav-toggle"
x-on:click="toggleFolderNav()"
x-bind:aria-expanded="(!folderNavCollapsed).toString()"
aria-controls="folder-nav-list"
aria-label="Toggle folder navigation" {
span class="folder-nav-toggle-icon" aria-hidden="true" { "▾" }
}
ul id="folder-nav-list" class="folder-nav" {
// An empty drop folder is the root: dropping a note on "All
// Notes" un-files it, which is the only way back out of a
// folder once every note has left it.
li class={"nav-item" (if active_folder.is_none() { " active" } else { "" })}
data-drop-folder="" {
a href=(base.url("/")) {
span class="nav-label" { "All Notes" }
@if show_nav_count(active_folder, None) {
span class="nav-count" { (notes.len()) }
}
}
}
}
@for folder in folders {
li class={"nav-item" (if active_folder == Some(folder.as_str()) { " active" } else { "" })}
data-drop-folder=(folder) {
a href=(folder_url(base, folder)) {
span class="nav-label" { (folder) }
@if show_nav_count(active_folder, Some(folder)) {
span class="nav-count" { (by_folder.get(folder).map(|v| v.len()).unwrap_or(0)) }
@for folder in folders {
li class={"nav-item" (if active_folder == Some(folder.as_str()) { " active" } else { "" })}
data-drop-folder=(folder) {
a href=(folder_url(base, folder)) {
span class="nav-label" { (folder) }
@if show_nav_count(active_folder, Some(folder)) {
span class="nav-count" { (by_folder.get(folder).map(|v| v.len()).unwrap_or(0)) }
}
}
}
}

View file

@ -18,6 +18,9 @@ function app() {
// the highlight follows the pointer instead of accumulating on every item
// the cursor sweeps across.
_dropTarget: null,
// Whether the folder navigation is collapsed. Shared between the mobile
// pull-tab button and the View menu toggle so they stay in sync.
folderNavCollapsed: false,
init() {
// Close menus on any click outside the menu bar. Alpine's `.outside`
@ -258,6 +261,10 @@ function app() {
this.editorVisible = !this.editorVisible;
},
toggleFolderNav() {
this.folderNavCollapsed = !this.folderNavCollapsed;
},
focusEditor() {
const editor = document.getElementById('editor');
if (editor) editor.focus();

View file

@ -505,14 +505,7 @@ button, input, select, textarea {
Needs its own background because it now overlays the scrolling note items.
The z-index is scoped to the sidebar and safe under the stacking-context
rule in AGENTS.md §7: no menu dropdowns live in here to trap. */
.folder-nav {
list-style: none;
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
border-bottom: 1px solid var(--ui-border);
.folder-nav-container {
position: sticky;
top: 0;
z-index: 2;
@ -523,6 +516,52 @@ button, input, select, textarea {
flex-shrink: 0;
}
.folder-nav {
list-style: none;
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
border-bottom: 1px solid var(--ui-border);
}
/* Pull-tab toggle — hidden on desktop, shown on mobile */
.folder-nav-toggle {
display: none;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: center;
background: var(--app-bg);
border: none;
border-bottom: 1px solid var(--ui-border);
color: var(--app-fg);
opacity: 0.6;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s ease;
}
.folder-nav-toggle:hover {
opacity: 0.8;
}
.folder-nav-toggle-icon {
font-size: 18px;
line-height: 1;
transition: transform 0.15s ease;
}
/* Collapsed state — hides the folder list but keeps the toggle visible */
.folder-nav-container.collapsed .folder-nav {
display: none;
}
.folder-nav-container.collapsed .folder-nav-toggle-icon {
transform: rotate(180deg);
}
.nav-item a {
display: flex;
align-items: center;
@ -1327,6 +1366,10 @@ button, input, select, textarea {
min-height: 44px;
}
.folder-nav-toggle {
display: flex;
}
.folder-nav {
padding: 8px;
}

View file

@ -1274,6 +1274,108 @@ async fn sidebar_shows_folder_navigation() {
assert!(body.contains("nav-count"), "should show note counts");
}
/// The folder nav is wrapped in `.folder-nav-container` with a pull-tab toggle
/// button. The toggle must be present in the markup at every width (CSS hides
/// it on desktop) so mobile users can collapse/expand the folder list.
#[tokio::test]
async fn folder_nav_container_has_toggle_button() {
let f = Fixture::provisioned("folder-nav-toggle");
f.post_authed("/folders", "name=Alpha").await;
let (status, body) = f.get_authed("/").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("class=\"folder-nav-container\""),
"the folder nav should be wrapped in .folder-nav-container"
);
assert!(
body.contains("class=\"folder-nav-toggle\""),
"the pull-tab toggle button should be in the markup"
);
assert!(
body.contains("toggleFolderNav()"),
"the toggle button should call toggleFolderNav()"
);
assert!(
body.contains("aria-controls=\"folder-nav-list\""),
"the toggle should reference the folder nav list via aria-controls"
);
}
/// The View menu has a Hide/Show Folder Nav toggle that shares the same state
/// as the pull-tab button.
#[tokio::test]
async fn view_menu_has_folder_nav_toggle() {
let f = Fixture::provisioned("view-menu-folder-nav");
let (status, body) = f.get_authed("/").await;
assert_eq!(status, StatusCode::OK);
// Find the View menu dropdown and check it contains the folder nav toggle
let view_start = body
.find("toggleMenu('view')")
.expect("the View menu should exist");
let view_end = body[view_start..]
.find("</ul>")
.map(|i| view_start + i)
.expect("the View menu dropdown should close");
let view_menu = &body[view_start..view_end];
assert!(
view_menu.contains("toggleFolderNav()"),
"the View menu should have a folder nav toggle"
);
assert!(
view_menu.contains("Folder Nav"),
"the View menu toggle should mention Folder Nav"
);
}
/// Save Note lives in the File menu, not the Edit menu.
#[tokio::test]
async fn save_note_is_in_file_menu() {
let f = Fixture::provisioned("save-note-file-menu");
let id = f.create_note().await;
let (status, body) = f.get_authed(&format!("/notes/{id}")).await;
assert_eq!(status, StatusCode::OK);
// Find the File menu dropdown
let file_start = body
.find("toggleMenu('file')")
.expect("the File menu should exist");
let file_end = body[file_start..]
.find("</ul>")
.map(|i| file_start + i)
.expect("the File menu dropdown should close");
let file_menu = &body[file_start..file_end];
assert!(
file_menu.contains("saveCurrentNote()"),
"Save Note should be in the File menu"
);
assert!(
file_menu.contains("Save Note"),
"Save Note label should be in the File menu"
);
// Find the Edit menu dropdown
let edit_start = body
.find("toggleMenu('edit')")
.expect("the Edit menu should exist");
let edit_end = body[edit_start..]
.find("</ul>")
.map(|i| edit_start + i)
.expect("the Edit menu dropdown should close");
let edit_menu = &body[edit_start..edit_end];
assert!(
!edit_menu.contains("saveCurrentNote()"),
"Save Note should NOT be in the Edit menu"
);
}
/// The `folder-nav` list, and nothing else.
///
/// Scoped deliberately: `.nav-count` also appears in the inlined stylesheet,
@ -1282,9 +1384,13 @@ async fn sidebar_shows_folder_navigation() {
/// to pass while showing the wrong thing.
fn folder_nav_region(body: &str) -> &str {
let start = body
.find("<ul class=\"folder-nav\">")
.find("id=\"folder-nav-list\"")
.expect("the folder nav should be present");
let rest = &body[start..];
// Back up to the opening <ul tag
let ul_start = body[..start]
.rfind("<ul")
.expect("the folder nav should have a <ul> tag");
let rest = &body[ul_start..];
let end = rest.find("</ul>").expect("the folder nav should be closed");
&rest[..end]
}