feat(frontend): phase 3 lit web component migration - view-dropdown & store components
CI / test (3.13) (push) Failing after 10m48s
CI / test (3.12) (push) Failing after 10m50s
CI / test (3.11) (push) Failing after 15m52s

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:
Ken
2026-05-27 07:01:01 +00:00
parent e0c540b367
commit 9c86cf096d
6 changed files with 442 additions and 414 deletions
+62 -244
View File
@@ -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;
}
var action = e.target.closest('[data-action]');
if (action) {
if (action.dataset.action === 'new-view') {
showNewViewInput();
} else if (action.dataset.action === 'manage-view') {
closeViewDropdown();
openManageViewPanel();
} else if (action.dataset.action === 'manage-views') {
closeViewDropdown();
openSettings();
switchSettingsTab('views');
} else {
closeViewDropdown();
}
}
});
// 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'); });
}
// 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');
}
}
// 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();
});
sidebarDropdown.addEventListener('view-manage-all', function() {
if (sidebarDropdown) sidebarDropdown.open = false;
openSettings();
switchSettingsTab('views');
});
}
// 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);
+73
View File
@@ -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);
+4 -14
View File
@@ -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>
+32 -29
View File
@@ -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}"
)
# ============================================================
+88 -127
View File
@@ -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"
)