feat: add sidebar view switcher dropdown in fullscreen mode (task-10)

- HTML: Replace .sidebar-title in sidebar-header with a view dropdown
  containing #sidebar-view-dropdown-trigger and #sidebar-view-dropdown-menu
- JS: Add renderSidebarViewDropdown() for populating sidebar menu (no action buttons)
- JS: Add toggleSidebarViewDropdown() for open/close with aria-expanded
- JS: Update switchView() to also update #sidebar-view-label in sidebar
- JS: Bind sidebar trigger click and delegated menu click in bindStaticEventListeners()
- CSS: Add .sidebar-view-dropdown, .sidebar-view-trigger, .sidebar-view-trigger:hover,
  and #sidebar-view-dropdown-menu position overrides
- Tests: Add test_sidebar_view_dropdown_exists and test_sidebar_view_dropdown_menu_exists
- Tests: Update test_html_session_sidebar_structure to reflect new dropdown structure
This commit is contained in:
Brian Krabach
2026-04-15 18:43:50 -07:00
parent 11326764e7
commit 9c2abe73a1
4 changed files with 155 additions and 8 deletions
+87
View File
@@ -898,6 +898,62 @@ function closeViewDropdown() {
if (trigger) trigger.setAttribute('aria-expanded', 'false'); if (trigger) trigger.setAttribute('aria-expanded', 'false');
} }
/**
* Render the sidebar view dropdown menu (same data as the header dropdown,
* but no action buttons — navigation only).
*/
function renderSidebarViewDropdown() {
var menu = $('sidebar-view-dropdown-menu');
if (!menu) return;
var views = (_serverSettings && _serverSettings.views) || [];
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
var hiddenCount = hidden.length;
var html = '';
// — All Sessions (always first)
var allActive = _activeView === 'all' ? ' view-dropdown-item--active' : '';
html += '<button class="view-dropdown-item' + allActive + '" data-view="all"><span class="view-dropdown-shortcut">1</span> All Sessions</button>';
// — User views
if (views.length > 0) {
html += '<div class="view-dropdown-separator"></div>';
for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown-item--active' : '';
html += '<button class="view-dropdown-item' + vActive + '" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown-shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
}
}
// — Hidden (N) (always last system view)
html += '<div class="view-dropdown-separator"></div>';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : '';
html += '<button class="view-dropdown-item' + hiddenActive + '" data-view="hidden"><span class="view-dropdown-shortcut">9</span> Hidden <span class="view-dropdown-badge">' + hiddenCount + '</span></button>';
menu.innerHTML = html;
}
/**
* Toggle the sidebar view dropdown open/closed.
* Calls renderSidebarViewDropdown() when opening to ensure fresh content.
*/
function toggleSidebarViewDropdown() {
var menu = $('sidebar-view-dropdown-menu');
var trigger = $('sidebar-view-dropdown-trigger');
if (!menu) return;
var isOpen = !menu.classList.contains('hidden');
if (isOpen) {
menu.classList.add('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
} else {
menu.classList.remove('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'true');
renderSidebarViewDropdown();
}
}
/** /**
* Show an inline text input inside the view dropdown for creating a new view. * Show an inline text input inside the view dropdown for creating a new view.
* Replaces the '+ New View' button with a text input inside the dropdown menu. * Replaces the '+ New View' button with a text input inside the dropdown menu.
@@ -1235,6 +1291,17 @@ function switchView(viewName) {
renderGrid(_currentSessions || []); renderGrid(_currentSessions || []);
renderSidebar(_currentSessions || [], _viewingSession); renderSidebar(_currentSessions || [], _viewingSession);
renderViewDropdown(); renderViewDropdown();
// Update sidebar view label to match the active view
var sidebarLabel = $('sidebar-view-label');
if (sidebarLabel) {
if (viewName === 'all') {
sidebarLabel.textContent = 'All Sessions';
} else if (viewName === 'hidden') {
sidebarLabel.textContent = 'Hidden';
} else {
sidebarLabel.textContent = viewName;
}
}
// Persist active view — fire and forget // Persist active view — fire and forget
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); api('PATCH', '/api/state', { active_view: viewName }).catch(function() {});
} }
@@ -2777,6 +2844,23 @@ function bindStaticEventListeners() {
}); });
} }
// Sidebar view dropdown — trigger opens/closes, delegated item clicks switch view
var sidebarViewTrigger = $('sidebar-view-dropdown-trigger');
if (sidebarViewTrigger) on(sidebarViewTrigger, 'click', toggleSidebarViewDropdown);
var sidebarViewMenu = $('sidebar-view-dropdown-menu');
if (sidebarViewMenu) {
sidebarViewMenu.addEventListener('click', function(e) {
var item = e.target.closest('[data-view]');
if (item) {
switchView(item.dataset.view);
// Close sidebar dropdown after selection
sidebarViewMenu.classList.add('hidden');
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
}
});
}
// Click-outside closes the dropdown // Click-outside closes the dropdown
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var dropdown = $('view-dropdown-menu'); var dropdown = $('view-dropdown-menu');
@@ -3204,6 +3288,9 @@ if (typeof module !== 'undefined' && module.exports) {
closeViewDropdown, closeViewDropdown,
showNewViewInput, showNewViewInput,
switchView, switchView,
// Sidebar view dropdown
renderSidebarViewDropdown,
toggleSidebarViewDropdown,
// Manage Views settings tab // Manage Views settings tab
renderViewsSettingsTab, renderViewsSettingsTab,
_saveViewsAndRerender, _saveViewsAndRerender,
+7 -1
View File
@@ -49,7 +49,13 @@
<div class="view-body"> <div class="view-body">
<div id="session-sidebar" class="session-sidebar"> <div id="session-sidebar" class="session-sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<span class="sidebar-title">Sessions</span> <div class="sidebar-view-dropdown" id="sidebar-view-dropdown">
<button id="sidebar-view-dropdown-trigger" class="sidebar-view-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="sidebar-view-dropdown-menu">
<span id="sidebar-view-label">All Sessions</span>
<span class="view-dropdown__caret" aria-hidden="true">&#9662;</span>
</button>
<div id="sidebar-view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
</div>
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list">&#8249;</button> <button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list">&#8249;</button>
</div> </div>
<div id="sidebar-list" class="sidebar-list"></div> <div id="sidebar-list" class="sidebar-list"></div>
+35
View File
@@ -487,6 +487,41 @@ body {
color: var(--text-muted); color: var(--text-muted);
} }
/* Sidebar view switcher dropdown */
.sidebar-view-dropdown {
position: relative;
flex: 1;
min-width: 0;
}
.sidebar-view-trigger {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 13px;
font-family: var(--font-ui);
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.sidebar-view-trigger:hover {
color: var(--accent);
}
#sidebar-view-dropdown-menu {
left: 0;
top: calc(100% + 2px);
min-width: 180px;
}
.sidebar-list { .sidebar-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+26 -7
View File
@@ -195,19 +195,16 @@ def test_html_reconnect_overlay_outside_view_body() -> None:
def test_html_session_sidebar_structure() -> None: def test_html_session_sidebar_structure() -> None:
"""#session-sidebar must contain .sidebar-header (with .sidebar-title and #sidebar-collapse-btn) and #sidebar-list.""" """#session-sidebar must contain .sidebar-header (with #sidebar-view-dropdown and #sidebar-collapse-btn) and #sidebar-list."""
soup = _SOUP soup = _SOUP
sidebar = soup.find(id="session-sidebar") sidebar = soup.find(id="session-sidebar")
assert sidebar is not None, "Missing #session-sidebar" assert sidebar is not None, "Missing #session-sidebar"
# .sidebar-header # .sidebar-header
sidebar_header = sidebar.find(class_="sidebar-header") sidebar_header = sidebar.find(class_="sidebar-header")
assert sidebar_header is not None, "Missing .sidebar-header inside #session-sidebar" assert sidebar_header is not None, "Missing .sidebar-header inside #session-sidebar"
# .sidebar-title with text 'Sessions' # #sidebar-view-dropdown (replaces old .sidebar-title)
sidebar_title = sidebar_header.find(class_="sidebar-title") sidebar_dropdown = sidebar_header.find(id="sidebar-view-dropdown")
assert sidebar_title is not None, "Missing .sidebar-title inside .sidebar-header" assert sidebar_dropdown is not None, "Missing #sidebar-view-dropdown inside .sidebar-header"
assert "Sessions" in sidebar_title.get_text(), (
f".sidebar-title text must contain 'Sessions', got: {sidebar_title.get_text()!r}"
)
# #sidebar-collapse-btn # #sidebar-collapse-btn
collapse_btn = sidebar_header.find(id="sidebar-collapse-btn") collapse_btn = sidebar_header.find(id="sidebar-collapse-btn")
assert collapse_btn is not None, ( assert collapse_btn is not None, (
@@ -1501,3 +1498,25 @@ def test_settings_has_views_panel() -> None:
# Must contain the empty message element # Must contain the empty message element
empty_msg = panel.find(id="views-settings-empty") empty_msg = panel.find(id="views-settings-empty")
assert empty_msg is not None, "Missing #views-settings-empty inside views panel" assert empty_msg is not None, "Missing #views-settings-empty inside views panel"
# ============================================================
# Sidebar View Switcher (task-10)
# ============================================================
def test_sidebar_view_dropdown_exists() -> None:
"""#sidebar-view-dropdown-trigger must exist in the sidebar header."""
soup = _SOUP
trigger = soup.find(id="sidebar-view-dropdown-trigger")
assert trigger is not None, "Missing #sidebar-view-dropdown-trigger"
assert trigger.name == "button", (
f"#sidebar-view-dropdown-trigger must be a <button>, got: {trigger.name}"
)
def test_sidebar_view_dropdown_menu_exists() -> None:
"""#sidebar-view-dropdown-menu must exist in the sidebar header."""
soup = _SOUP
menu = soup.find(id="sidebar-view-dropdown-menu")
assert menu is not None, "Missing #sidebar-view-dropdown-menu"