From 306c3c1e6ea7b826a73fd4b788a5269fdd0d8679 Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Mon, 5 Oct 2026 15:55:34 -0400 Subject: [PATCH] Added toggle for the Folder nav and moved Save Note to the 'File ->' menu --- src/pages.rs | 59 +++++++++++++++-------- static/app.js | 7 +++ static/style.css | 59 +++++++++++++++++++---- tests/auth_gating.rs | 110 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 206 insertions(+), 29 deletions(-) diff --git a/src/pages.rs b/src/pages.rs index 462f7ed..166ec16 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -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)) } + } } } } diff --git a/static/app.js b/static/app.js index 8418748..5555f58 100644 --- a/static/app.js +++ b/static/app.js @@ -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(); diff --git a/static/style.css b/static/style.css index e0b7119..439b71f 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/auth_gating.rs b/tests/auth_gating.rs index 658bdf4..64905d6 100644 --- a/tests/auth_gating.rs +++ b/tests/auth_gating.rs @@ -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("") + .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("") + .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("") + .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("