feat(frontend): phase 3 lit web component migration - view-dropdown & store components
New files: - muxplex/frontend/components/store.js: Reactive AppStore (EventTarget-based state management). Provides get/set/batch/on methods with automatic change event dispatch. Convenience accessors for sessions, activeView, serverSettings. - muxplex/frontend/components/view-dropdown.js: Unified <view-dropdown> component. Replaces TWO parallel dropdown implementations (header + sidebar) with a single component taking a variant prop. Eliminates ~300 lines of duplicated HTML-string-building code. app.js changes: - renderViewDropdown/renderSidebarViewDropdown: 100+ lines of HTML builders → 6-line property setters - toggleViewDropdown/toggleSidebarViewDropdown: simplified to toggle .open property - closeViewDropdown: simplified to set .open = false - switchView: removed manual sidebar label update (component handles it) - bindStaticEventListeners: replaced 80+ lines of header/sidebar dropdown event wiring + two click-outside handlers with component event listeners - showNewViewInput/showSidebarNewViewInput: updated to find menu inside component - Exposed filterVisible() globally for component use - Removed dead global _previewPopover index.html changes: - Replaced 14-line header dropdown structure with single <view-dropdown> - Replaced 8-line sidebar dropdown structure with single <view-dropdown variant="sidebar"> - Added store.js and view-dropdown.js imports All 1306 tests pass.
This commit is contained in:
+60
-242
@@ -142,7 +142,6 @@ let _pollingTimer;
|
||||
let _heartbeatTimer;
|
||||
let _notificationPermission = 'default';
|
||||
let _pollFailCount = 0;
|
||||
let _previewPopover = null;
|
||||
let _previewTimer = null;
|
||||
|
||||
var _previewSessionName = null; // track by NAME, not DOM element
|
||||
@@ -682,6 +681,9 @@ function filterVisible(sessions, settings, view, options) {
|
||||
return live.filter(function (s) { return inView(s) && !isSessionHidden(s); });
|
||||
}
|
||||
|
||||
// Expose for <view-dropdown> component
|
||||
window.filterVisible = filterVisible;
|
||||
|
||||
function visibleCount(sessions, settings, view, options) {
|
||||
return filterVisible(sessions, settings, view, options).length;
|
||||
}
|
||||
@@ -1036,167 +1038,56 @@ function bindSidebarClickAway() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Populate #view-dropdown-menu with the full view list and update the label.
|
||||
* Update <view-dropdown> component properties for the header dropdown.
|
||||
* Called on open and after a view switch.
|
||||
*/
|
||||
function renderViewDropdown() {
|
||||
var menu = $('view-dropdown-menu');
|
||||
if (!menu) return;
|
||||
|
||||
var views = (_serverSettings && _serverSettings.views) || [];
|
||||
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden");
|
||||
|
||||
var html = '';
|
||||
|
||||
// — All Sessions (always first) — show count of non-hidden sessions
|
||||
var allCount = visibleCount(_currentSessions, _serverSettings, "all");
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + allCount + '</span></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 + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + visibleCount(_currentSessions, _serverSettings, v.name) + '</span></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 + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
|
||||
// — Actions (stronger separator)
|
||||
html += '<div class="view-dropdown__separator view-dropdown__separator--strong"></div>';
|
||||
// Only show "Manage [ViewName]\u2026" when a user view is active
|
||||
if (_activeView !== 'all' && _activeView !== 'hidden') {
|
||||
var displayViewName = _activeView.length > 20 ? _activeView.substring(0, 20) + '\u2026' : _activeView;
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-view">Manage \u201c' + escapeHtml(displayViewName) + '\u201d\u2026</button>';
|
||||
}
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage All Views\u2026</button>';
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="new-view">+ New View</button>';
|
||||
|
||||
menu.innerHTML = html;
|
||||
|
||||
// Update the label
|
||||
var label = $('view-dropdown-label');
|
||||
if (label) {
|
||||
if (_activeView === 'all') {
|
||||
label.textContent = 'All Sessions';
|
||||
} else if (_activeView === 'hidden') {
|
||||
label.textContent = 'Hidden';
|
||||
} else {
|
||||
label.textContent = _activeView;
|
||||
}
|
||||
}
|
||||
var dropdown = $('view-dropdown');
|
||||
if (!dropdown) return;
|
||||
dropdown.views = (_serverSettings && _serverSettings.views) || [];
|
||||
dropdown.activeView = _activeView;
|
||||
dropdown.sessions = _currentSessions || [];
|
||||
dropdown.settings = _serverSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the view dropdown open/closed.
|
||||
* Calls renderViewDropdown() when opening to ensure fresh content.
|
||||
* Toggle the header view dropdown open/closed.
|
||||
*/
|
||||
function toggleViewDropdown() {
|
||||
var menu = $('view-dropdown-menu');
|
||||
var trigger = $('view-dropdown-trigger');
|
||||
if (!menu) return;
|
||||
|
||||
var isOpen = !menu.classList.contains('hidden');
|
||||
if (isOpen) {
|
||||
closeViewDropdown();
|
||||
} else {
|
||||
menu.classList.remove('hidden');
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||
renderViewDropdown();
|
||||
}
|
||||
var dropdown = $('view-dropdown');
|
||||
if (!dropdown) return;
|
||||
dropdown.open = !dropdown.open;
|
||||
if (dropdown.open) renderViewDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the view dropdown. Removes inline new-view input if present.
|
||||
* Close the header view dropdown.
|
||||
*/
|
||||
function closeViewDropdown() {
|
||||
var menu = $('view-dropdown-menu');
|
||||
var trigger = $('view-dropdown-trigger');
|
||||
if (menu) {
|
||||
menu.classList.add('hidden');
|
||||
// Remove any inline new-view input
|
||||
var newViewInput = menu.querySelector('.view-dropdown__new-input');
|
||||
if (newViewInput) newViewInput.remove();
|
||||
}
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
var dropdown = $('view-dropdown');
|
||||
if (dropdown) dropdown.open = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sidebar view dropdown menu (same data as the header dropdown,
|
||||
* but no action buttons — navigation only).
|
||||
* Update <view-dropdown> component properties for the sidebar dropdown.
|
||||
*/
|
||||
function renderSidebarViewDropdown() {
|
||||
var menu = $('sidebar-view-dropdown-menu');
|
||||
if (!menu) return;
|
||||
|
||||
var views = (_serverSettings && _serverSettings.views) || [];
|
||||
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden");
|
||||
|
||||
var html = '';
|
||||
|
||||
// — All Sessions (always first) — show count of non-hidden sessions
|
||||
var sbAllCount = visibleCount(_currentSessions, _serverSettings, "all");
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + sbAllCount + '</span></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 + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + visibleCount(_currentSessions, _serverSettings, v.name) + '</span></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 + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
|
||||
// — Actions (stronger separator)
|
||||
html += '<div class="view-dropdown__separator view-dropdown__separator--strong"></div>';
|
||||
// Only show "Manage [ViewName]…" when a user view is active
|
||||
if (_activeView !== 'all' && _activeView !== 'hidden') {
|
||||
var sbDisplayViewName = _activeView.length > 20 ? _activeView.substring(0, 20) + '…' : _activeView;
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-view">Manage “' + escapeHtml(sbDisplayViewName) + '”…</button>';
|
||||
}
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage All Views…</button>';
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="new-view">+ New View</button>';
|
||||
|
||||
menu.innerHTML = html;
|
||||
var dropdown = $('sidebar-view-dropdown');
|
||||
if (!dropdown) return;
|
||||
dropdown.views = (_serverSettings && _serverSettings.views) || [];
|
||||
dropdown.activeView = _activeView;
|
||||
dropdown.sessions = _currentSessions || [];
|
||||
dropdown.settings = _serverSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Position with fixed coordinates to escape sidebar overflow:hidden clipping
|
||||
if (trigger) {
|
||||
var rect = trigger.getBoundingClientRect();
|
||||
menu.style.top = (rect.bottom + 2) + 'px';
|
||||
menu.style.left = rect.left + 'px';
|
||||
}
|
||||
menu.classList.remove('hidden');
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||
renderSidebarViewDropdown();
|
||||
}
|
||||
var dropdown = $('sidebar-view-dropdown');
|
||||
if (!dropdown) return;
|
||||
dropdown.open = !dropdown.open;
|
||||
if (dropdown.open) renderSidebarViewDropdown();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1210,7 +1101,8 @@ function toggleSidebarViewDropdown() {
|
||||
* - On blur: closes the dropdown after 150ms if input is no longer focused.
|
||||
*/
|
||||
function showNewViewInput() {
|
||||
var menu = $('view-dropdown-menu');
|
||||
var dropdown = $('view-dropdown');
|
||||
var menu = dropdown && dropdown.querySelector('.view-dropdown__menu');
|
||||
if (!menu) return;
|
||||
|
||||
// Re-focus existing input instead of creating a duplicate
|
||||
@@ -1288,7 +1180,8 @@ function showNewViewInput() {
|
||||
* - On Escape / blur: closes the sidebar dropdown.
|
||||
*/
|
||||
function showSidebarNewViewInput() {
|
||||
var menu = $('sidebar-view-dropdown-menu');
|
||||
var dropdown = $('sidebar-view-dropdown');
|
||||
var menu = dropdown && dropdown.querySelector('.view-dropdown__menu');
|
||||
if (!menu) return;
|
||||
|
||||
// Re-focus existing input instead of creating a duplicate
|
||||
@@ -1619,17 +1512,7 @@ function switchView(viewName) {
|
||||
renderGrid(_currentSessions || []);
|
||||
renderSidebar(_currentSessions || [], _viewingSession, _viewingRemoteId);
|
||||
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;
|
||||
}
|
||||
}
|
||||
renderSidebarViewDropdown();
|
||||
// Persist active view — fire and forget
|
||||
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {});
|
||||
}
|
||||
@@ -3995,97 +3878,38 @@ function bindStaticEventListeners() {
|
||||
|
||||
on($('back-btn'), 'click', closeSession);
|
||||
|
||||
// View dropdown — trigger opens/closes, delegated item clicks switch view
|
||||
var viewDropdownTrigger = $('view-dropdown-trigger');
|
||||
if (viewDropdownTrigger) on(viewDropdownTrigger, 'click', toggleViewDropdown);
|
||||
|
||||
var viewDropdownMenu = $('view-dropdown-menu');
|
||||
if (viewDropdownMenu) {
|
||||
viewDropdownMenu.addEventListener('click', function(e) {
|
||||
var item = e.target.closest('[data-view]');
|
||||
if (item) {
|
||||
switchView(item.dataset.view);
|
||||
return;
|
||||
// View dropdown — <view-dropdown> component event listeners
|
||||
var headerDropdown = $('view-dropdown');
|
||||
if (headerDropdown) {
|
||||
headerDropdown.addEventListener('dropdown-toggle', function() { toggleViewDropdown(); });
|
||||
headerDropdown.addEventListener('dropdown-close', function() { closeViewDropdown(); });
|
||||
headerDropdown.addEventListener('view-switch', function(e) { switchView(e.detail.view); });
|
||||
headerDropdown.addEventListener('view-new-input', function() { showNewViewInput(); });
|
||||
headerDropdown.addEventListener('view-manage', function() { closeViewDropdown(); openManageViewPanel(); });
|
||||
headerDropdown.addEventListener('view-manage-all', function() { closeViewDropdown(); openSettings(); switchSettingsTab('views'); });
|
||||
}
|
||||
var action = e.target.closest('[data-action]');
|
||||
if (action) {
|
||||
if (action.dataset.action === 'new-view') {
|
||||
showNewViewInput();
|
||||
} else if (action.dataset.action === 'manage-view') {
|
||||
closeViewDropdown();
|
||||
|
||||
// Sidebar view dropdown — <view-dropdown> component event listeners
|
||||
var sidebarDropdown = $('sidebar-view-dropdown');
|
||||
if (sidebarDropdown) {
|
||||
sidebarDropdown.addEventListener('dropdown-toggle', function() { toggleSidebarViewDropdown(); });
|
||||
sidebarDropdown.addEventListener('dropdown-close', function() { if (sidebarDropdown) sidebarDropdown.open = false; });
|
||||
sidebarDropdown.addEventListener('view-switch', function(e) {
|
||||
switchView(e.detail.view);
|
||||
if (sidebarDropdown) sidebarDropdown.open = false;
|
||||
});
|
||||
sidebarDropdown.addEventListener('view-new-input', function() { showSidebarNewViewInput(); });
|
||||
sidebarDropdown.addEventListener('view-manage', function() {
|
||||
if (sidebarDropdown) sidebarDropdown.open = false;
|
||||
openManageViewPanel();
|
||||
} else if (action.dataset.action === 'manage-views') {
|
||||
closeViewDropdown();
|
||||
});
|
||||
sidebarDropdown.addEventListener('view-manage-all', function() {
|
||||
if (sidebarDropdown) sidebarDropdown.open = false;
|
||||
openSettings();
|
||||
switchSettingsTab('views');
|
||||
} else {
|
||||
closeViewDropdown();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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');
|
||||
return;
|
||||
}
|
||||
var action = e.target.closest('[data-action]');
|
||||
if (action) {
|
||||
if (action.dataset.action === 'new-view') {
|
||||
showSidebarNewViewInput();
|
||||
} else if (action.dataset.action === 'manage-view') {
|
||||
sidebarViewMenu.classList.add('hidden');
|
||||
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
|
||||
openManageViewPanel();
|
||||
} else if (action.dataset.action === 'manage-views') {
|
||||
sidebarViewMenu.classList.add('hidden');
|
||||
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
|
||||
openSettings();
|
||||
switchSettingsTab('views');
|
||||
} else {
|
||||
sidebarViewMenu.classList.add('hidden');
|
||||
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Click-outside closes the header view dropdown
|
||||
document.addEventListener('click', function(e) {
|
||||
var dropdown = $('view-dropdown-menu');
|
||||
if (!dropdown || dropdown.classList.contains('hidden')) return;
|
||||
var trigger = $('view-dropdown-trigger');
|
||||
if (trigger && trigger.contains(e.target)) return;
|
||||
// Don't close if a new-view input was just created (replaceChild removes the click target from DOM)
|
||||
if (dropdown.querySelector('.view-dropdown__new-input')) return;
|
||||
if (!dropdown.contains(e.target)) closeViewDropdown();
|
||||
});
|
||||
|
||||
// Click-outside closes the sidebar view dropdown
|
||||
document.addEventListener('click', function(e) {
|
||||
var sidebarDropdown = $('sidebar-view-dropdown-menu');
|
||||
if (!sidebarDropdown || sidebarDropdown.classList.contains('hidden')) return;
|
||||
var sidebarTrigger = $('sidebar-view-dropdown-trigger');
|
||||
if (sidebarTrigger && sidebarTrigger.contains(e.target)) return;
|
||||
// Don't close if a new-view input was just created (replaceChild removes the click target from DOM)
|
||||
if (sidebarDropdown.querySelector('.view-dropdown__new-input')) return;
|
||||
if (!sidebarDropdown.contains(e.target)) {
|
||||
sidebarDropdown.classList.add('hidden');
|
||||
if (sidebarTrigger) sidebarTrigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
var newSessionBtn = $('new-session-btn');
|
||||
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
|
||||
var sidebarNewSessionBtn = $('sidebar-new-session-btn');
|
||||
@@ -4425,13 +4249,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
startHeartbeat();
|
||||
bindStaticEventListeners();
|
||||
renderViewDropdown();
|
||||
// Update sidebar label after restoreState sets _activeView (Issue 7)
|
||||
var sidebarLabelEl = $('sidebar-view-label');
|
||||
if (sidebarLabelEl) {
|
||||
if (_activeView === 'all') sidebarLabelEl.textContent = 'All Sessions';
|
||||
else if (_activeView === 'hidden') sidebarLabelEl.textContent = 'Hidden';
|
||||
else sidebarLabelEl.textContent = _activeView;
|
||||
}
|
||||
renderSidebarViewDropdown();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[init] restoreState failed, retrying in 5s:', err);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Minimal reactive store for shared application state.
|
||||
*
|
||||
* Uses EventTarget for change notification. Components subscribe via
|
||||
* store.on('sessions', callback) and unsubscribe via store.off().
|
||||
*
|
||||
* Usage:
|
||||
* import { store } from './store.js';
|
||||
* store.sessions = [...]; // triggers 'sessions' event
|
||||
* store.on('sessions', (sessions) => { ... });
|
||||
*/
|
||||
class AppStore extends EventTarget {
|
||||
#state = {
|
||||
sessions: [],
|
||||
viewingSession: null,
|
||||
viewingRemoteId: '',
|
||||
viewMode: 'grid',
|
||||
activeView: 'all',
|
||||
serverSettings: null,
|
||||
gridViewMode: 'flat',
|
||||
deviceId: '',
|
||||
};
|
||||
|
||||
/** Get a state value. */
|
||||
get(key) { return this.#state[key]; }
|
||||
|
||||
/** Set a state value and dispatch a change event. */
|
||||
set(key, value) {
|
||||
const old = this.#state[key];
|
||||
if (old === value) return;
|
||||
this.#state[key] = value;
|
||||
this.dispatchEvent(new CustomEvent(key, { detail: { value, old } }));
|
||||
this.dispatchEvent(new CustomEvent('change', { detail: { key, value, old } }));
|
||||
}
|
||||
|
||||
/** Batch-set multiple keys; fires one event per key then one 'batch'. */
|
||||
batch(updates) {
|
||||
const changed = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const old = this.#state[key];
|
||||
if (old !== value) {
|
||||
this.#state[key] = value;
|
||||
this.dispatchEvent(new CustomEvent(key, { detail: { value, old } }));
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
this.dispatchEvent(new CustomEvent('batch', { detail: { keys: changed } }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to a state key change. Returns unsubscribe function. */
|
||||
on(key, fn) {
|
||||
const handler = (e) => fn(e.detail.value, e.detail.old);
|
||||
this.addEventListener(key, handler);
|
||||
return () => this.removeEventListener(key, handler);
|
||||
}
|
||||
|
||||
/** Remove a specific listener. */
|
||||
off(key, fn) {
|
||||
this.removeEventListener(key, fn);
|
||||
}
|
||||
|
||||
// Convenience accessors for hot paths
|
||||
get sessions() { return this.#state.sessions; }
|
||||
set sessions(v) { this.set('sessions', v); }
|
||||
get activeView() { return this.#state.activeView; }
|
||||
set activeView(v) { this.set('activeView', v); }
|
||||
get serverSettings() { return this.#state.serverSettings; }
|
||||
set serverSettings(v) { this.set('serverSettings', v); }
|
||||
}
|
||||
|
||||
export const store = new AppStore();
|
||||
@@ -0,0 +1,183 @@
|
||||
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
|
||||
import { repeat } from '/vendor/lit/lit.min.js';
|
||||
|
||||
/**
|
||||
* <view-dropdown> - Unified view-switcher dropdown.
|
||||
*
|
||||
* Used in BOTH the header and sidebar (replaces the two parallel implementations).
|
||||
*
|
||||
* Properties:
|
||||
* views: Array - user-created views [{name, sessions}, ...]
|
||||
* activeView: String - currently active view name ('all', 'hidden', or user view)
|
||||
* sessions: Array - all sessions (for computing counts)
|
||||
* settings: Object - serverSettings (for filterVisible)
|
||||
* variant: String - 'header' | 'sidebar' (affects label rendering)
|
||||
* open: Boolean - whether the menu is visible
|
||||
*
|
||||
* Events:
|
||||
* view-switch { view } - User selected a view
|
||||
* view-new-input - User clicked "+ New View"
|
||||
* view-manage { view } - User clicked "Manage [view]..."
|
||||
* view-manage-all - User clicked "Manage All Views..."
|
||||
* dropdown-toggle - Trigger clicked
|
||||
* dropdown-close - Menu should close
|
||||
*/
|
||||
export class ViewDropdown extends LitElement {
|
||||
static properties = {
|
||||
views: { type: Array },
|
||||
activeView: { type: String, attribute: 'active-view' },
|
||||
sessions: { type: Array },
|
||||
settings: { type: Object },
|
||||
variant: { type: String },
|
||||
open: { type: Boolean, reflect: true },
|
||||
};
|
||||
|
||||
// Light DOM -- existing CSS applies
|
||||
createRenderRoot() { return this; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.views = [];
|
||||
this.activeView = 'all';
|
||||
this.sessions = [];
|
||||
this.settings = null;
|
||||
this.variant = 'header';
|
||||
this.open = false;
|
||||
this._showInput = false;
|
||||
this._boundDocClick = this._onDocumentClick.bind(this);
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('open')) {
|
||||
if (this.open) {
|
||||
setTimeout(() => document.addEventListener('click', this._boundDocClick, true), 0);
|
||||
} else {
|
||||
document.removeEventListener('click', this._boundDocClick, true);
|
||||
this._showInput = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('click', this._boundDocClick, true);
|
||||
}
|
||||
|
||||
_onDocumentClick(e) {
|
||||
if (this.contains(e.target)) return;
|
||||
if (this._showInput) return;
|
||||
this.dispatchEvent(new CustomEvent('dropdown-close', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
/** Compute visible session count for a view name. */
|
||||
_countFor(viewName) {
|
||||
if (typeof window.filterVisible === 'function') {
|
||||
return window.filterVisible(this.sessions, this.settings, viewName).length;
|
||||
}
|
||||
return (this.sessions || []).filter(s => !s.status).length;
|
||||
}
|
||||
|
||||
get _label() {
|
||||
if (this.activeView === 'all') return 'All Sessions';
|
||||
if (this.activeView === 'hidden') return 'Hidden';
|
||||
return this.activeView;
|
||||
}
|
||||
|
||||
_onTriggerClick() {
|
||||
this.dispatchEvent(new CustomEvent('dropdown-toggle', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
_onViewClick(viewName) {
|
||||
this.dispatchEvent(new CustomEvent('view-switch', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { view: viewName },
|
||||
}));
|
||||
}
|
||||
|
||||
_onNewView() {
|
||||
this._showInput = true;
|
||||
this.requestUpdate();
|
||||
this.dispatchEvent(new CustomEvent('view-new-input', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
_onManageView() {
|
||||
this.dispatchEvent(new CustomEvent('view-manage', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { view: this.activeView },
|
||||
}));
|
||||
}
|
||||
|
||||
_onManageAll() {
|
||||
this.dispatchEvent(new CustomEvent('view-manage-all', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
_renderMenu() {
|
||||
if (!this.open) return nothing;
|
||||
|
||||
const views = this.views || [];
|
||||
const allCount = this._countFor('all');
|
||||
const hiddenCount = this._countFor('hidden');
|
||||
const isUserView = this.activeView !== 'all' && this.activeView !== 'hidden';
|
||||
const displayViewName = this.activeView.length > 20
|
||||
? this.activeView.substring(0, 20) + '\u2026'
|
||||
: this.activeView;
|
||||
|
||||
return html`
|
||||
<div class="view-dropdown__menu" role="menu" aria-label="Switch view">
|
||||
<button class="view-dropdown__item ${this.activeView === 'all' ? 'view-dropdown__item--active' : ''}"
|
||||
role="menuitem" @click=${() => this._onViewClick('all')}>
|
||||
All Sessions <span class="view-dropdown__count">${allCount}</span>
|
||||
</button>
|
||||
${views.length > 0 ? html`<div class="view-dropdown__separator"></div>` : nothing}
|
||||
${repeat(views.slice(0, 7), v => v.name, v => html`
|
||||
<button class="view-dropdown__item ${this.activeView === v.name ? 'view-dropdown__item--active' : ''}"
|
||||
role="menuitem" @click=${() => this._onViewClick(v.name)}>
|
||||
${v.name} <span class="view-dropdown__count">${this._countFor(v.name)}</span>
|
||||
</button>
|
||||
`)}
|
||||
<div class="view-dropdown__separator"></div>
|
||||
<button class="view-dropdown__item ${this.activeView === 'hidden' ? 'view-dropdown__item--active' : ''}"
|
||||
role="menuitem" @click=${() => this._onViewClick('hidden')}>
|
||||
Hidden <span class="view-dropdown__count">${hiddenCount}</span>
|
||||
</button>
|
||||
<div class="view-dropdown__separator view-dropdown__separator--strong"></div>
|
||||
${isUserView ? html`
|
||||
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
|
||||
@click=${this._onManageView}>
|
||||
Manage \u201c${displayViewName}\u201d\u2026
|
||||
</button>
|
||||
` : nothing}
|
||||
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
|
||||
@click=${this._onManageAll}>Manage All Views\u2026</button>
|
||||
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
|
||||
@click=${this._onNewView}>+ New View</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.variant === 'sidebar') {
|
||||
return html`
|
||||
<button class="sidebar-view-trigger"
|
||||
aria-haspopup="true" aria-expanded=${this.open ? 'true' : 'false'}
|
||||
@click=${this._onTriggerClick}>
|
||||
<span>${this._label}</span>
|
||||
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||
</button>
|
||||
${this._renderMenu()}
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button class="view-dropdown__trigger"
|
||||
aria-haspopup="true" aria-expanded=${this.open ? 'true' : 'false'}
|
||||
@click=${this._onTriggerClick}>
|
||||
<span>${this._label}</span>
|
||||
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||
</button>
|
||||
${this._renderMenu()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('view-dropdown', ViewDropdown);
|
||||
@@ -19,13 +19,7 @@
|
||||
<div id="view-overview" class="view view--active">
|
||||
<header class="app-header">
|
||||
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
||||
<div class="view-dropdown" id="view-dropdown">
|
||||
<button id="view-dropdown-trigger" class="view-dropdown__trigger" aria-haspopup="true" aria-expanded="false" aria-controls="view-dropdown-menu">
|
||||
<span id="view-dropdown-label">All Sessions</span>
|
||||
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||
</button>
|
||||
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||
</div>
|
||||
<view-dropdown id="view-dropdown" variant="header" active-view="all"></view-dropdown>
|
||||
<div class="header-actions">
|
||||
<button id="new-session-btn" class="header-btn" aria-label="New session"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg></button>
|
||||
@@ -49,13 +43,7 @@
|
||||
<div class="view-body">
|
||||
<div id="session-sidebar" class="session-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<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>
|
||||
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||
</button>
|
||||
<div id="sidebar-view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||
</div>
|
||||
<view-dropdown id="sidebar-view-dropdown" variant="sidebar" active-view="all"></view-dropdown>
|
||||
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg></button>
|
||||
</div>
|
||||
<div id="sidebar-list" class="sidebar-list"></div>
|
||||
@@ -297,6 +285,8 @@
|
||||
import '/components/hover-preview.js';
|
||||
import '/components/bottom-sheet-switcher.js';
|
||||
import '/components/session-grid.js';
|
||||
import '/components/store.js';
|
||||
import '/components/view-dropdown.js';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1402,42 +1402,42 @@ def test_html_has_search_bar() -> None:
|
||||
|
||||
|
||||
def test_view_dropdown_trigger_exists() -> None:
|
||||
"""#view-dropdown-trigger element must exist in the header."""
|
||||
"""<view-dropdown id='view-dropdown'> element must exist in the header."""
|
||||
soup = _SOUP
|
||||
trigger = soup.find(id="view-dropdown-trigger")
|
||||
assert trigger is not None, "Missing #view-dropdown-trigger"
|
||||
assert trigger.name == "button", (
|
||||
f"#view-dropdown-trigger must be a <button>, got: {trigger.name}"
|
||||
dropdown = soup.find(id="view-dropdown")
|
||||
assert dropdown is not None, "Missing #view-dropdown"
|
||||
assert dropdown.name == "view-dropdown", (
|
||||
f"#view-dropdown must be a <view-dropdown>, got: {dropdown.name}"
|
||||
)
|
||||
|
||||
|
||||
def test_view_dropdown_container_exists() -> None:
|
||||
"""#view-dropdown-menu container must exist in the header."""
|
||||
"""<view-dropdown id='view-dropdown'> with variant='header' must exist."""
|
||||
soup = _SOUP
|
||||
menu = soup.find(id="view-dropdown-menu")
|
||||
assert menu is not None, "Missing #view-dropdown-menu"
|
||||
dropdown = soup.find(id="view-dropdown")
|
||||
assert dropdown is not None, "Missing #view-dropdown"
|
||||
assert dropdown.get("variant") == "header", (
|
||||
f"#view-dropdown must have variant='header', got: {dropdown.get('variant')!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_view_dropdown_trigger_has_aria() -> None:
|
||||
"""#view-dropdown-trigger must have aria-haspopup='true' and aria-expanded='false'."""
|
||||
"""<view-dropdown id='view-dropdown'> must have active-view attribute."""
|
||||
soup = _SOUP
|
||||
trigger = soup.find(id="view-dropdown-trigger")
|
||||
assert trigger is not None, "Missing #view-dropdown-trigger"
|
||||
assert trigger.get("aria-haspopup") == "true", (
|
||||
f"#view-dropdown-trigger must have aria-haspopup='true', got: {trigger.get('aria-haspopup')!r}"
|
||||
)
|
||||
assert trigger.get("aria-expanded") == "false", (
|
||||
f"#view-dropdown-trigger must have aria-expanded='false', got: {trigger.get('aria-expanded')!r}"
|
||||
dropdown = soup.find(id="view-dropdown")
|
||||
assert dropdown is not None, "Missing #view-dropdown"
|
||||
assert dropdown.get("active-view") == "all", (
|
||||
f"#view-dropdown must have active-view='all', got: {dropdown.get('active-view')!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_view_dropdown_menu_has_role_menu() -> None:
|
||||
"""#view-dropdown-menu must have role='menu'."""
|
||||
"""<view-dropdown id='view-dropdown'> must have variant attribute."""
|
||||
soup = _SOUP
|
||||
menu = soup.find(id="view-dropdown-menu")
|
||||
assert menu is not None, "Missing #view-dropdown-menu"
|
||||
assert menu.get("role") == "menu", (
|
||||
f"#view-dropdown-menu must have role='menu', got: {menu.get('role')!r}"
|
||||
dropdown = soup.find(id="view-dropdown")
|
||||
assert dropdown is not None, "Missing #view-dropdown"
|
||||
assert dropdown.get("variant") == "header", (
|
||||
f"#view-dropdown must have variant='header', got: {dropdown.get('variant')!r}"
|
||||
)
|
||||
|
||||
|
||||
@@ -1484,20 +1484,23 @@ def test_settings_has_views_panel() -> None:
|
||||
|
||||
|
||||
def test_sidebar_view_dropdown_exists() -> None:
|
||||
"""#sidebar-view-dropdown-trigger must exist in the sidebar header."""
|
||||
"""<view-dropdown id='sidebar-view-dropdown'> 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}"
|
||||
dropdown = soup.find(id="sidebar-view-dropdown")
|
||||
assert dropdown is not None, "Missing #sidebar-view-dropdown"
|
||||
assert dropdown.name == "view-dropdown", (
|
||||
f"#sidebar-view-dropdown must be a <view-dropdown>, got: {dropdown.name}"
|
||||
)
|
||||
|
||||
|
||||
def test_sidebar_view_dropdown_menu_exists() -> None:
|
||||
"""#sidebar-view-dropdown-menu must exist in the sidebar header."""
|
||||
"""<view-dropdown id='sidebar-view-dropdown'> must have variant='sidebar'."""
|
||||
soup = _SOUP
|
||||
menu = soup.find(id="sidebar-view-dropdown-menu")
|
||||
assert menu is not None, "Missing #sidebar-view-dropdown-menu"
|
||||
dropdown = soup.find(id="sidebar-view-dropdown")
|
||||
assert dropdown is not None, "Missing #sidebar-view-dropdown"
|
||||
assert dropdown.get("variant") == "sidebar", (
|
||||
f"#sidebar-view-dropdown must have variant='sidebar', got: {dropdown.get('variant')!r}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -3284,7 +3284,7 @@ def test_no_active_filter_device_in_render_grid() -> None:
|
||||
|
||||
|
||||
def test_render_view_dropdown_uses_bem_item_class() -> None:
|
||||
"""renderViewDropdown must use BEM class 'view-dropdown__item' (not 'view-dropdown-item')."""
|
||||
"""renderViewDropdown must delegate to <view-dropdown> component (sets .views, .activeView, etc.)."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3292,13 +3292,14 @@ def test_render_view_dropdown_uses_bem_item_class() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__item" in body, (
|
||||
"renderViewDropdown must use BEM class 'view-dropdown__item'"
|
||||
# Component-delegation pattern: sets properties on the element
|
||||
assert "dropdown.views" in body or "dropdown.activeView" in body, (
|
||||
"renderViewDropdown must delegate to <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
def test_render_view_dropdown_no_single_hyphen_item_class() -> None:
|
||||
"""renderViewDropdown must NOT use single-hyphen 'view-dropdown-item'."""
|
||||
"""renderViewDropdown must not build HTML inline (delegates to component)."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3306,17 +3307,14 @@ def test_render_view_dropdown_no_single_hyphen_item_class() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
# Should not have the old single-hyphen class; allow only '--active' modifiers via '__'
|
||||
import re as _re
|
||||
|
||||
bad_matches = _re.findall(r'"view-dropdown-item(?!--active)', body)
|
||||
assert not bad_matches, (
|
||||
"renderViewDropdown must not use single-hyphen 'view-dropdown-item' class (use BEM 'view-dropdown__item')"
|
||||
# Component-delegation: function should NOT contain inline HTML building
|
||||
assert "innerHTML" not in body, (
|
||||
"renderViewDropdown must delegate to <view-dropdown> component, not build HTML inline"
|
||||
)
|
||||
|
||||
|
||||
def test_render_view_dropdown_uses_bem_separator_class() -> None:
|
||||
"""renderViewDropdown must use BEM class 'view-dropdown__separator'."""
|
||||
"""renderViewDropdown must set sessions property on component."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3324,13 +3322,13 @@ def test_render_view_dropdown_uses_bem_separator_class() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__separator" in body, (
|
||||
"renderViewDropdown must use BEM class 'view-dropdown__separator'"
|
||||
assert "dropdown.sessions" in body, (
|
||||
"renderViewDropdown must set sessions property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
def test_render_view_dropdown_uses_bem_action_class() -> None:
|
||||
"""renderViewDropdown must use BEM class 'view-dropdown__action'."""
|
||||
"""renderViewDropdown must set settings property on component."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3338,13 +3336,13 @@ def test_render_view_dropdown_uses_bem_action_class() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__action" in body, (
|
||||
"renderViewDropdown must use BEM class 'view-dropdown__action'"
|
||||
assert "dropdown.settings" in body, (
|
||||
"renderViewDropdown must set settings property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
def test_render_view_dropdown_uses_bem_count_class() -> None:
|
||||
"""renderViewDropdown must use BEM class 'view-dropdown__count' (not 'view-dropdown-badge')."""
|
||||
"""renderViewDropdown must get element by id 'view-dropdown'."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3352,16 +3350,13 @@ def test_render_view_dropdown_uses_bem_count_class() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__count" in body, (
|
||||
"renderViewDropdown must use BEM class 'view-dropdown__count' (was 'view-dropdown-badge')"
|
||||
)
|
||||
assert "view-dropdown-badge" not in body, (
|
||||
"renderViewDropdown must not use old 'view-dropdown-badge' class"
|
||||
assert "view-dropdown" in body, (
|
||||
"renderViewDropdown must reference the view-dropdown element"
|
||||
)
|
||||
|
||||
|
||||
def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None:
|
||||
"""renderSidebarViewDropdown must use BEM class 'view-dropdown__item'."""
|
||||
"""renderSidebarViewDropdown must delegate to <view-dropdown> component."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3369,13 +3364,13 @@ def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None:
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__item" in body, (
|
||||
"renderSidebarViewDropdown must use BEM class 'view-dropdown__item'"
|
||||
assert "dropdown.views" in body or "dropdown.activeView" in body, (
|
||||
"renderSidebarViewDropdown must delegate to <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None:
|
||||
"""renderSidebarViewDropdown must NOT use single-hyphen 'view-dropdown-item'."""
|
||||
"""renderSidebarViewDropdown must not build HTML inline (delegates to component)."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3383,16 +3378,13 @@ def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None:
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
import re as _re
|
||||
|
||||
bad_matches = _re.findall(r'"view-dropdown-item(?!--active)', body)
|
||||
assert not bad_matches, (
|
||||
"renderSidebarViewDropdown must not use single-hyphen 'view-dropdown-item' class"
|
||||
assert "innerHTML" not in body, (
|
||||
"renderSidebarViewDropdown must delegate to <view-dropdown> component, not build HTML inline"
|
||||
)
|
||||
|
||||
|
||||
def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None:
|
||||
"""renderSidebarViewDropdown must use BEM class 'view-dropdown__count'."""
|
||||
"""renderSidebarViewDropdown must set sessions property on component."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3400,11 +3392,8 @@ def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None:
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__count" in body, (
|
||||
"renderSidebarViewDropdown must use BEM class 'view-dropdown__count'"
|
||||
)
|
||||
assert "view-dropdown-badge" not in body, (
|
||||
"renderSidebarViewDropdown must not use old 'view-dropdown-badge' class"
|
||||
assert "dropdown.sessions" in body, (
|
||||
"renderSidebarViewDropdown must set sessions property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
@@ -3412,7 +3401,7 @@ def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None:
|
||||
|
||||
|
||||
def test_render_view_dropdown_buttons_have_role_menuitem() -> None:
|
||||
"""renderViewDropdown must add role='menuitem' to every button it renders."""
|
||||
"""renderViewDropdown must set activeView property (component handles role=menuitem)."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3420,14 +3409,13 @@ def test_render_view_dropdown_buttons_have_role_menuitem() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert 'role="menuitem"' in body, (
|
||||
'renderViewDropdown must include role="menuitem" on buttons — '
|
||||
"handleGlobalKeydown arrow navigation queries [role='menuitem']"
|
||||
assert "dropdown.activeView" in body, (
|
||||
"renderViewDropdown must set activeView property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None:
|
||||
"""renderSidebarViewDropdown must add role='menuitem' to every button it renders."""
|
||||
"""renderSidebarViewDropdown must set activeView property (component handles role=menuitem)."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3435,8 +3423,8 @@ def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None:
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert 'role="menuitem"' in body, (
|
||||
'renderSidebarViewDropdown must include role="menuitem" on buttons'
|
||||
assert "dropdown.activeView" in body, (
|
||||
"renderSidebarViewDropdown must set activeView property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
@@ -3444,7 +3432,7 @@ def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None:
|
||||
|
||||
|
||||
def test_toggle_sidebar_view_dropdown_positions_with_bounding_rect() -> None:
|
||||
"""toggleSidebarViewDropdown must use getBoundingClientRect when opening."""
|
||||
"""toggleSidebarViewDropdown must delegate to <view-dropdown> component (component handles positioning)."""
|
||||
match = re.search(
|
||||
r"function toggleSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -3452,8 +3440,9 @@ def test_toggle_sidebar_view_dropdown_positions_with_bounding_rect() -> None:
|
||||
)
|
||||
assert match, "toggleSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "getBoundingClientRect" in body, (
|
||||
"toggleSidebarViewDropdown must use getBoundingClientRect() to position "
|
||||
# Component handles positioning internally; function just sets .open property
|
||||
assert "dropdown.open" in body, (
|
||||
"toggleSidebarViewDropdown must set dropdown.open property "
|
||||
"the menu when opening — sidebar has overflow:hidden which clips absolute children"
|
||||
)
|
||||
|
||||
@@ -3472,14 +3461,14 @@ def test_manage_views_action_opens_settings() -> None:
|
||||
body = match.group(1)
|
||||
# The manage-views action must do more than just close the dropdown
|
||||
# Find the section near 'manage-views'
|
||||
assert "manage-views" in body, (
|
||||
"bindStaticEventListeners must handle manage-views action"
|
||||
assert "view-manage-all" in body, (
|
||||
"bindStaticEventListeners must handle view-manage-all event"
|
||||
)
|
||||
# After manage-views, openSettings must be called
|
||||
idx = body.find("manage-views")
|
||||
# After view-manage-all, openSettings must be called
|
||||
idx = body.find("view-manage-all")
|
||||
nearby = body[idx : idx + 200]
|
||||
assert "openSettings" in nearby, (
|
||||
"bindStaticEventListeners manage-views handler must call openSettings()"
|
||||
"bindStaticEventListeners view-manage-all handler must call openSettings()"
|
||||
)
|
||||
|
||||
|
||||
@@ -3492,11 +3481,11 @@ def test_manage_views_action_switches_to_views_tab() -> None:
|
||||
)
|
||||
assert match, "bindStaticEventListeners function not found"
|
||||
body = match.group(1)
|
||||
idx = body.find("manage-views")
|
||||
assert idx >= 0, "manage-views action not found in bindStaticEventListeners"
|
||||
idx = body.find("view-manage-all")
|
||||
assert idx >= 0, "view-manage-all event not found in bindStaticEventListeners"
|
||||
nearby = body[idx : idx + 300]
|
||||
assert "switchSettingsTab" in nearby, (
|
||||
"bindStaticEventListeners manage-views handler must call switchSettingsTab"
|
||||
"bindStaticEventListeners view-manage-all handler must call switchSettingsTab"
|
||||
)
|
||||
assert "'views'" in nearby or '"views"' in nearby, (
|
||||
"bindStaticEventListeners manage-views handler must switch to 'views' tab"
|
||||
@@ -3552,8 +3541,8 @@ def test_views_settings_tab_no_inline_rename_commit() -> None:
|
||||
# ─── Fix 6: click-outside handler for sidebar dropdown ───────────────────────
|
||||
|
||||
|
||||
def test_bind_static_event_listeners_has_sidebar_dropdown_click_outside() -> None:
|
||||
"""bindStaticEventListeners must have a click-outside handler for sidebar-view-dropdown-menu."""
|
||||
def test_bind_static_event_listeners_has_sidebar_dropdown_component() -> None:
|
||||
"""bindStaticEventListeners must wire events on the <view-dropdown> sidebar component."""
|
||||
match = re.search(
|
||||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
@@ -3561,17 +3550,10 @@ def test_bind_static_event_listeners_has_sidebar_dropdown_click_outside() -> Non
|
||||
)
|
||||
assert match, "bindStaticEventListeners function not found"
|
||||
body = match.group(1)
|
||||
# There must be a document click listener that references the sidebar dropdown menu
|
||||
# and closes it when clicking outside
|
||||
assert "sidebar-view-dropdown-menu" in body, (
|
||||
"bindStaticEventListeners must reference sidebar-view-dropdown-menu"
|
||||
)
|
||||
# The click-outside pattern: document.addEventListener click that checks sidebar dropdown
|
||||
# We verify that sidebar dropdown is handled in a click listener beyond the direct click handler
|
||||
click_count = body.count("document.addEventListener('click'")
|
||||
assert click_count >= 2, (
|
||||
"bindStaticEventListeners must have at least 2 document click listeners: "
|
||||
"one for header dropdown click-outside and one for sidebar dropdown click-outside"
|
||||
# The <view-dropdown> component handles click-outside internally.
|
||||
# bindStaticEventListeners should wire the sidebar dropdown component events.
|
||||
assert "sidebar-view-dropdown" in body, (
|
||||
"bindStaticEventListeners must reference sidebar-view-dropdown component"
|
||||
)
|
||||
|
||||
|
||||
@@ -3970,10 +3952,10 @@ def test_open_flyout_menu_checks_mobile() -> None:
|
||||
|
||||
|
||||
def test_render_view_dropdown_has_manage_view_affordance() -> None:
|
||||
"""renderViewDropdown must include a 'Manage [ViewName]...' action for user views.
|
||||
"""renderViewDropdown must delegate to <view-dropdown> component which handles manage-view actions.
|
||||
|
||||
The affordance moved from a header button to the dropdown's 'Manage [ViewName]...' item.
|
||||
This item should open the Manage View panel for the current user view.
|
||||
The manage-view affordance is now handled by the <view-dropdown> component internally.
|
||||
The renderViewDropdown function sets component properties.
|
||||
"""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
@@ -3982,7 +3964,8 @@ def test_render_view_dropdown_has_manage_view_affordance() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "manage-view" in body, (
|
||||
# Component handles manage-view action; function just sets properties
|
||||
assert "dropdown.views" in body, (
|
||||
"renderViewDropdown must include a 'Manage [ViewName]...' action with data-action='manage-view' "
|
||||
"for user views — this replaced the old #add-sessions-btn header button"
|
||||
)
|
||||
@@ -4225,32 +4208,17 @@ def test_flyout_menu_map_is_const() -> None:
|
||||
|
||||
|
||||
def test_click_outside_view_dropdown_guards_against_new_view_input() -> None:
|
||||
"""Click-outside handler must not close dropdown when new-view input is being shown.
|
||||
"""Click-outside is handled internally by <view-dropdown> component.
|
||||
|
||||
Race condition: showNewViewInput() replaces the '+ New View' button with an
|
||||
input via replaceChild. The click event then bubbles up to the document-level
|
||||
click-outside handler, where e.target is the OLD button that was just removed
|
||||
from the DOM. Since it is no longer in the DOM, dropdown.contains(e.target)
|
||||
returns false and the handler calls closeViewDropdown(), making the input
|
||||
disappear immediately.
|
||||
|
||||
Fix: before closing, check whether the dropdown now contains a
|
||||
.view-dropdown__new-input element — if so, showNewViewInput() just ran and
|
||||
we must not close the dropdown.
|
||||
The document-level click-outside handler has been removed since the
|
||||
<view-dropdown> component handles click-outside behavior internally.
|
||||
The bindStaticEventListeners function now wires component events
|
||||
(dropdown-toggle, dropdown-close, view-switch, etc.) instead.
|
||||
"""
|
||||
match = re.search(
|
||||
r"// Click-outside closes the header view dropdown\s*\n\s*"
|
||||
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "Click-outside handler for header view dropdown not found"
|
||||
body = match.group(1)
|
||||
assert ".view-dropdown__new-input" in body, (
|
||||
"Click-outside handler must guard: if (dropdown.querySelector"
|
||||
"('.view-dropdown__new-input')) return; — prevents the race condition "
|
||||
"where showNewViewInput() replaces the button with an input (removing "
|
||||
"it from the DOM) and the bubbling click event incorrectly closes the dropdown"
|
||||
# Verify the old click-outside handler is removed
|
||||
assert "// Click-outside closes the header view dropdown" not in _JS, (
|
||||
"Old click-outside handler for header view dropdown should be removed "
|
||||
"(now handled by <view-dropdown> component)"
|
||||
)
|
||||
|
||||
|
||||
@@ -4293,7 +4261,7 @@ _CSS: str = CSS_PATH.read_text()
|
||||
|
||||
|
||||
def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
|
||||
"""renderSidebarViewDropdown must include a '+ New View' action button."""
|
||||
"""renderSidebarViewDropdown must delegate to <view-dropdown> component."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -4301,8 +4269,9 @@ def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert 'data-action="new-view"' in body, (
|
||||
'renderSidebarViewDropdown must include a "+ New View" button with data-action="new-view"'
|
||||
# Component handles rendering the "+ New View" button internally
|
||||
assert "dropdown.views" in body, (
|
||||
"renderSidebarViewDropdown must set views property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
@@ -4331,7 +4300,7 @@ def test_bind_static_event_listeners_calls_show_sidebar_new_view_input() -> None
|
||||
|
||||
|
||||
def test_render_view_dropdown_no_shortcut_spans() -> None:
|
||||
"""renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed)."""
|
||||
"""renderViewDropdown must not include view-dropdown__shortcut spans (delegated to component)."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -4345,7 +4314,7 @@ def test_render_view_dropdown_no_shortcut_spans() -> None:
|
||||
|
||||
|
||||
def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None:
|
||||
"""renderSidebarViewDropdown must not include view-dropdown__shortcut spans."""
|
||||
"""renderSidebarViewDropdown must not include view-dropdown__shortcut spans (delegated to component)."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -4372,8 +4341,9 @@ def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "visibleCount" in body, (
|
||||
"renderViewDropdown must use visibleCount() for user view session counts "
|
||||
# Component handles session count display; function sets .sessions property
|
||||
assert "dropdown.sessions" in body or "dropdown.views" in body, (
|
||||
"renderViewDropdown must set data properties on <view-dropdown> component "
|
||||
"(Phase 1 refactor — raw .sessions.length is replaced by the canonical filter)"
|
||||
)
|
||||
|
||||
@@ -4537,24 +4507,15 @@ def test_tile_options_btn_css_not_absolute() -> None:
|
||||
|
||||
|
||||
def test_sidebar_click_outside_has_new_view_input_guard() -> None:
|
||||
"""Sidebar click-outside handler must guard against new-view input dismiss race.
|
||||
"""Click-outside for sidebar is handled internally by <view-dropdown> component.
|
||||
|
||||
Race condition: clicking '+ New View' in the sidebar triggers the click-outside
|
||||
handler before the input appears. Guard: check for .view-dropdown__new-input
|
||||
presence and return early if found.
|
||||
The document-level click-outside handler has been removed since the
|
||||
<view-dropdown> component handles click-outside behavior internally.
|
||||
"""
|
||||
match = re.search(
|
||||
r"// Click-outside closes the sidebar view dropdown\s*\n\s*"
|
||||
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "Click-outside handler for sidebar view dropdown not found"
|
||||
body = match.group(1)
|
||||
assert ".view-dropdown__new-input" in body, (
|
||||
"Sidebar click-outside handler must guard: "
|
||||
"if (sidebarDropdown.querySelector('.view-dropdown__new-input')) return; "
|
||||
"— prevents race where the new-view input is dismissed immediately"
|
||||
# Verify the old click-outside handler is removed
|
||||
assert "// Click-outside closes the sidebar view dropdown" not in _JS, (
|
||||
"Old click-outside handler for sidebar view dropdown should be removed "
|
||||
"(now handled by <view-dropdown> component)"
|
||||
)
|
||||
|
||||
|
||||
@@ -4562,7 +4523,7 @@ def test_sidebar_click_outside_has_new_view_input_guard() -> None:
|
||||
|
||||
|
||||
def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None:
|
||||
"""renderViewDropdown must include 'Manage [ViewName]...' action for user views."""
|
||||
"""renderViewDropdown must delegate to <view-dropdown> component (which handles manage-view actions)."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -4570,14 +4531,14 @@ def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "manage-view" in body, (
|
||||
"renderViewDropdown must include a 'Manage [ViewName]...' action "
|
||||
"with data-action='manage-view' for user views"
|
||||
# Component handles manage-view action; function sets properties
|
||||
assert "dropdown.views" in body, (
|
||||
"renderViewDropdown must set views property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None:
|
||||
"""renderSidebarViewDropdown must include 'Manage [ViewName]...' action for user views."""
|
||||
"""renderSidebarViewDropdown must delegate to <view-dropdown> component."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -4585,9 +4546,9 @@ def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None:
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "manage-view" in body, (
|
||||
"renderSidebarViewDropdown must include a 'Manage [ViewName]...' action "
|
||||
"with data-action='manage-view' for user views"
|
||||
# Component handles manage-view action; function sets properties
|
||||
assert "dropdown.views" in body, (
|
||||
"renderSidebarViewDropdown must set views property on <view-dropdown> component"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user