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
+60 -242
View File
@@ -142,7 +142,6 @@ let _pollingTimer;
let _heartbeatTimer; let _heartbeatTimer;
let _notificationPermission = 'default'; let _notificationPermission = 'default';
let _pollFailCount = 0; let _pollFailCount = 0;
let _previewPopover = null;
let _previewTimer = null; let _previewTimer = null;
var _previewSessionName = null; // track by NAME, not DOM element 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); }); return live.filter(function (s) { return inView(s) && !isSessionHidden(s); });
} }
// Expose for <view-dropdown> component
window.filterVisible = filterVisible;
function visibleCount(sessions, settings, view, options) { function visibleCount(sessions, settings, view, options) {
return filterVisible(sessions, settings, view, options).length; 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. * Called on open and after a view switch.
*/ */
function renderViewDropdown() { function renderViewDropdown() {
var menu = $('view-dropdown-menu'); var dropdown = $('view-dropdown');
if (!menu) return; if (!dropdown) return;
dropdown.views = (_serverSettings && _serverSettings.views) || [];
var views = (_serverSettings && _serverSettings.views) || []; dropdown.activeView = _activeView;
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden"); dropdown.sessions = _currentSessions || [];
dropdown.settings = _serverSettings;
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;
}
}
} }
/** /**
* Toggle the view dropdown open/closed. * Toggle the header view dropdown open/closed.
* Calls renderViewDropdown() when opening to ensure fresh content.
*/ */
function toggleViewDropdown() { function toggleViewDropdown() {
var menu = $('view-dropdown-menu'); var dropdown = $('view-dropdown');
var trigger = $('view-dropdown-trigger'); if (!dropdown) return;
if (!menu) return; dropdown.open = !dropdown.open;
if (dropdown.open) renderViewDropdown();
var isOpen = !menu.classList.contains('hidden');
if (isOpen) {
closeViewDropdown();
} else {
menu.classList.remove('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'true');
renderViewDropdown();
}
} }
/** /**
* Close the view dropdown. Removes inline new-view input if present. * Close the header view dropdown.
*/ */
function closeViewDropdown() { function closeViewDropdown() {
var menu = $('view-dropdown-menu'); var dropdown = $('view-dropdown');
var trigger = $('view-dropdown-trigger'); if (dropdown) dropdown.open = false;
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');
} }
/** /**
* Render the sidebar view dropdown menu (same data as the header dropdown, * Update <view-dropdown> component properties for the sidebar dropdown.
* but no action buttons — navigation only).
*/ */
function renderSidebarViewDropdown() { function renderSidebarViewDropdown() {
var menu = $('sidebar-view-dropdown-menu'); var dropdown = $('sidebar-view-dropdown');
if (!menu) return; if (!dropdown) return;
dropdown.views = (_serverSettings && _serverSettings.views) || [];
var views = (_serverSettings && _serverSettings.views) || []; dropdown.activeView = _activeView;
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden"); dropdown.sessions = _currentSessions || [];
dropdown.settings = _serverSettings;
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;
} }
/** /**
* Toggle the sidebar view dropdown open/closed. * Toggle the sidebar view dropdown open/closed.
* Calls renderSidebarViewDropdown() when opening to ensure fresh content.
*/ */
function toggleSidebarViewDropdown() { function toggleSidebarViewDropdown() {
var menu = $('sidebar-view-dropdown-menu'); var dropdown = $('sidebar-view-dropdown');
var trigger = $('sidebar-view-dropdown-trigger'); if (!dropdown) return;
if (!menu) return; dropdown.open = !dropdown.open;
if (dropdown.open) renderSidebarViewDropdown();
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();
}
} }
/** /**
@@ -1210,7 +1101,8 @@ function toggleSidebarViewDropdown() {
* - On blur: closes the dropdown after 150ms if input is no longer focused. * - On blur: closes the dropdown after 150ms if input is no longer focused.
*/ */
function showNewViewInput() { function showNewViewInput() {
var menu = $('view-dropdown-menu'); var dropdown = $('view-dropdown');
var menu = dropdown && dropdown.querySelector('.view-dropdown__menu');
if (!menu) return; if (!menu) return;
// Re-focus existing input instead of creating a duplicate // Re-focus existing input instead of creating a duplicate
@@ -1288,7 +1180,8 @@ function showNewViewInput() {
* - On Escape / blur: closes the sidebar dropdown. * - On Escape / blur: closes the sidebar dropdown.
*/ */
function showSidebarNewViewInput() { function showSidebarNewViewInput() {
var menu = $('sidebar-view-dropdown-menu'); var dropdown = $('sidebar-view-dropdown');
var menu = dropdown && dropdown.querySelector('.view-dropdown__menu');
if (!menu) return; if (!menu) return;
// Re-focus existing input instead of creating a duplicate // Re-focus existing input instead of creating a duplicate
@@ -1619,17 +1512,7 @@ function switchView(viewName) {
renderGrid(_currentSessions || []); renderGrid(_currentSessions || []);
renderSidebar(_currentSessions || [], _viewingSession, _viewingRemoteId); renderSidebar(_currentSessions || [], _viewingSession, _viewingRemoteId);
renderViewDropdown(); renderViewDropdown();
// Update sidebar view label to match the active view renderSidebarViewDropdown();
var sidebarLabel = $('sidebar-view-label');
if (sidebarLabel) {
if (viewName === 'all') {
sidebarLabel.textContent = 'All Sessions';
} else if (viewName === 'hidden') {
sidebarLabel.textContent = 'Hidden';
} else {
sidebarLabel.textContent = viewName;
}
}
// Persist active view — fire and forget // Persist active view — fire and forget
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); api('PATCH', '/api/state', { active_view: viewName }).catch(function() {});
} }
@@ -3995,97 +3878,38 @@ function bindStaticEventListeners() {
on($('back-btn'), 'click', closeSession); on($('back-btn'), 'click', closeSession);
// View dropdown — trigger opens/closes, delegated item clicks switch view // View dropdown — <view-dropdown> component event listeners
var viewDropdownTrigger = $('view-dropdown-trigger'); var headerDropdown = $('view-dropdown');
if (viewDropdownTrigger) on(viewDropdownTrigger, 'click', toggleViewDropdown); if (headerDropdown) {
headerDropdown.addEventListener('dropdown-toggle', function() { toggleViewDropdown(); });
var viewDropdownMenu = $('view-dropdown-menu'); headerDropdown.addEventListener('dropdown-close', function() { closeViewDropdown(); });
if (viewDropdownMenu) { headerDropdown.addEventListener('view-switch', function(e) { switchView(e.detail.view); });
viewDropdownMenu.addEventListener('click', function(e) { headerDropdown.addEventListener('view-new-input', function() { showNewViewInput(); });
var item = e.target.closest('[data-view]'); headerDropdown.addEventListener('view-manage', function() { closeViewDropdown(); openManageViewPanel(); });
if (item) { headerDropdown.addEventListener('view-manage-all', function() { closeViewDropdown(); openSettings(); switchSettingsTab('views'); });
switchView(item.dataset.view);
return;
} }
var action = e.target.closest('[data-action]');
if (action) { // Sidebar view dropdown — <view-dropdown> component event listeners
if (action.dataset.action === 'new-view') { var sidebarDropdown = $('sidebar-view-dropdown');
showNewViewInput(); if (sidebarDropdown) {
} else if (action.dataset.action === 'manage-view') { sidebarDropdown.addEventListener('dropdown-toggle', function() { toggleSidebarViewDropdown(); });
closeViewDropdown(); 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(); openManageViewPanel();
} else if (action.dataset.action === 'manage-views') { });
closeViewDropdown(); sidebarDropdown.addEventListener('view-manage-all', function() {
if (sidebarDropdown) sidebarDropdown.open = false;
openSettings(); openSettings();
switchSettingsTab('views'); 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'); var newSessionBtn = $('new-session-btn');
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
var sidebarNewSessionBtn = $('sidebar-new-session-btn'); var sidebarNewSessionBtn = $('sidebar-new-session-btn');
@@ -4425,13 +4249,7 @@ document.addEventListener('DOMContentLoaded', async function() {
startHeartbeat(); startHeartbeat();
bindStaticEventListeners(); bindStaticEventListeners();
renderViewDropdown(); renderViewDropdown();
// Update sidebar label after restoreState sets _activeView (Issue 7) renderSidebarViewDropdown();
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;
}
}) })
.catch(function(err) { .catch(function(err) {
console.error('[init] restoreState failed, retrying in 5s:', 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"> <div id="view-overview" class="view view--active">
<header class="app-header"> <header class="app-header">
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1> <h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
<div class="view-dropdown" id="view-dropdown"> <view-dropdown id="view-dropdown" variant="header" active-view="all"></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>
<div class="header-actions"> <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="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> <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 class="view-body">
<div id="session-sidebar" class="session-sidebar"> <div id="session-sidebar" class="session-sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-view-dropdown" id="sidebar-view-dropdown"> <view-dropdown id="sidebar-view-dropdown" variant="sidebar" active-view="all"></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>
<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> <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>
<div id="sidebar-list" class="sidebar-list"></div> <div id="sidebar-list" class="sidebar-list"></div>
@@ -297,6 +285,8 @@
import '/components/hover-preview.js'; import '/components/hover-preview.js';
import '/components/bottom-sheet-switcher.js'; import '/components/bottom-sheet-switcher.js';
import '/components/session-grid.js'; import '/components/session-grid.js';
import '/components/store.js';
import '/components/view-dropdown.js';
</script> </script>
</body> </body>
</html> </html>
+32 -29
View File
@@ -1402,42 +1402,42 @@ def test_html_has_search_bar() -> None:
def test_view_dropdown_trigger_exists() -> 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 soup = _SOUP
trigger = soup.find(id="view-dropdown-trigger") dropdown = soup.find(id="view-dropdown")
assert trigger is not None, "Missing #view-dropdown-trigger" assert dropdown is not None, "Missing #view-dropdown"
assert trigger.name == "button", ( assert dropdown.name == "view-dropdown", (
f"#view-dropdown-trigger must be a <button>, got: {trigger.name}" f"#view-dropdown must be a <view-dropdown>, got: {dropdown.name}"
) )
def test_view_dropdown_container_exists() -> None: 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 soup = _SOUP
menu = soup.find(id="view-dropdown-menu") dropdown = soup.find(id="view-dropdown")
assert menu is not None, "Missing #view-dropdown-menu" 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: 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 soup = _SOUP
trigger = soup.find(id="view-dropdown-trigger") dropdown = soup.find(id="view-dropdown")
assert trigger is not None, "Missing #view-dropdown-trigger" assert dropdown is not None, "Missing #view-dropdown"
assert trigger.get("aria-haspopup") == "true", ( assert dropdown.get("active-view") == "all", (
f"#view-dropdown-trigger must have aria-haspopup='true', got: {trigger.get('aria-haspopup')!r}" f"#view-dropdown must have active-view='all', got: {dropdown.get('active-view')!r}"
)
assert trigger.get("aria-expanded") == "false", (
f"#view-dropdown-trigger must have aria-expanded='false', got: {trigger.get('aria-expanded')!r}"
) )
def test_view_dropdown_menu_has_role_menu() -> None: 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 soup = _SOUP
menu = soup.find(id="view-dropdown-menu") dropdown = soup.find(id="view-dropdown")
assert menu is not None, "Missing #view-dropdown-menu" assert dropdown is not None, "Missing #view-dropdown"
assert menu.get("role") == "menu", ( assert dropdown.get("variant") == "header", (
f"#view-dropdown-menu must have role='menu', got: {menu.get('role')!r}" 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: 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 soup = _SOUP
trigger = soup.find(id="sidebar-view-dropdown-trigger") dropdown = soup.find(id="sidebar-view-dropdown")
assert trigger is not None, "Missing #sidebar-view-dropdown-trigger" assert dropdown is not None, "Missing #sidebar-view-dropdown"
assert trigger.name == "button", ( assert dropdown.name == "view-dropdown", (
f"#sidebar-view-dropdown-trigger must be a <button>, got: {trigger.name}" f"#sidebar-view-dropdown must be a <view-dropdown>, got: {dropdown.name}"
) )
def test_sidebar_view_dropdown_menu_exists() -> None: 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 soup = _SOUP
menu = soup.find(id="sidebar-view-dropdown-menu") dropdown = soup.find(id="sidebar-view-dropdown")
assert menu is not None, "Missing #sidebar-view-dropdown-menu" 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: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3292,13 +3292,14 @@ def test_render_view_dropdown_uses_bem_item_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__item" in body, ( # Component-delegation pattern: sets properties on the element
"renderViewDropdown must use BEM class 'view-dropdown__item'" 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: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3306,17 +3307,14 @@ def test_render_view_dropdown_no_single_hyphen_item_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
# Should not have the old single-hyphen class; allow only '--active' modifiers via '__' # Component-delegation: function should NOT contain inline HTML building
import re as _re assert "innerHTML" not in body, (
"renderViewDropdown must delegate to <view-dropdown> component, not build HTML inline"
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')"
) )
def test_render_view_dropdown_uses_bem_separator_class() -> None: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3324,13 +3322,13 @@ def test_render_view_dropdown_uses_bem_separator_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__separator" in body, ( assert "dropdown.sessions" in body, (
"renderViewDropdown must use BEM class 'view-dropdown__separator'" "renderViewDropdown must set sessions property on <view-dropdown> component"
) )
def test_render_view_dropdown_uses_bem_action_class() -> None: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3338,13 +3336,13 @@ def test_render_view_dropdown_uses_bem_action_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__action" in body, ( assert "dropdown.settings" in body, (
"renderViewDropdown must use BEM class 'view-dropdown__action'" "renderViewDropdown must set settings property on <view-dropdown> component"
) )
def test_render_view_dropdown_uses_bem_count_class() -> None: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3352,16 +3350,13 @@ def test_render_view_dropdown_uses_bem_count_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__count" in body, ( assert "view-dropdown" in body, (
"renderViewDropdown must use BEM class 'view-dropdown__count' (was 'view-dropdown-badge')" "renderViewDropdown must reference the view-dropdown element"
)
assert "view-dropdown-badge" not in body, (
"renderViewDropdown must not use old 'view-dropdown-badge' class"
) )
def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None: 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( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3369,13 +3364,13 @@ def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__item" in body, ( assert "dropdown.views" in body or "dropdown.activeView" in body, (
"renderSidebarViewDropdown must use BEM class 'view-dropdown__item'" "renderSidebarViewDropdown must delegate to <view-dropdown> component"
) )
def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None: 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( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3383,16 +3378,13 @@ def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
import re as _re assert "innerHTML" not in body, (
"renderSidebarViewDropdown must delegate to <view-dropdown> component, not build HTML inline"
bad_matches = _re.findall(r'"view-dropdown-item(?!--active)', body)
assert not bad_matches, (
"renderSidebarViewDropdown must not use single-hyphen 'view-dropdown-item' class"
) )
def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None: 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( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3400,11 +3392,8 @@ def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__count" in body, ( assert "dropdown.sessions" in body, (
"renderSidebarViewDropdown must use BEM class 'view-dropdown__count'" "renderSidebarViewDropdown must set sessions property on <view-dropdown> component"
)
assert "view-dropdown-badge" not in body, (
"renderSidebarViewDropdown must not use old 'view-dropdown-badge' class"
) )
@@ -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: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3420,14 +3409,13 @@ def test_render_view_dropdown_buttons_have_role_menuitem() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert 'role="menuitem"' in body, ( assert "dropdown.activeView" in body, (
'renderViewDropdown must include role="menuitem" on buttons — ' "renderViewDropdown must set activeView property on <view-dropdown> component"
"handleGlobalKeydown arrow navigation queries [role='menuitem']"
) )
def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None: 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( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3435,8 +3423,8 @@ def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert 'role="menuitem"' in body, ( assert "dropdown.activeView" in body, (
'renderSidebarViewDropdown must include role="menuitem" on buttons' "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: 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( match = re.search(
r"function toggleSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function toggleSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3452,8 +3440,9 @@ def test_toggle_sidebar_view_dropdown_positions_with_bounding_rect() -> None:
) )
assert match, "toggleSidebarViewDropdown function not found" assert match, "toggleSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "getBoundingClientRect" in body, ( # Component handles positioning internally; function just sets .open property
"toggleSidebarViewDropdown must use getBoundingClientRect() to position " assert "dropdown.open" in body, (
"toggleSidebarViewDropdown must set dropdown.open property "
"the menu when opening — sidebar has overflow:hidden which clips absolute children" "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) body = match.group(1)
# The manage-views action must do more than just close the dropdown # The manage-views action must do more than just close the dropdown
# Find the section near 'manage-views' # Find the section near 'manage-views'
assert "manage-views" in body, ( assert "view-manage-all" in body, (
"bindStaticEventListeners must handle manage-views action" "bindStaticEventListeners must handle view-manage-all event"
) )
# After manage-views, openSettings must be called # After view-manage-all, openSettings must be called
idx = body.find("manage-views") idx = body.find("view-manage-all")
nearby = body[idx : idx + 200] nearby = body[idx : idx + 200]
assert "openSettings" in nearby, ( 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" assert match, "bindStaticEventListeners function not found"
body = match.group(1) body = match.group(1)
idx = body.find("manage-views") idx = body.find("view-manage-all")
assert idx >= 0, "manage-views action not found in bindStaticEventListeners" assert idx >= 0, "view-manage-all event not found in bindStaticEventListeners"
nearby = body[idx : idx + 300] nearby = body[idx : idx + 300]
assert "switchSettingsTab" in nearby, ( 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, ( assert "'views'" in nearby or '"views"' in nearby, (
"bindStaticEventListeners manage-views handler must switch to 'views' tab" "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 ─────────────────────── # ─── Fix 6: click-outside handler for sidebar dropdown ───────────────────────
def test_bind_static_event_listeners_has_sidebar_dropdown_click_outside() -> None: def test_bind_static_event_listeners_has_sidebar_dropdown_component() -> None:
"""bindStaticEventListeners must have a click-outside handler for sidebar-view-dropdown-menu.""" """bindStaticEventListeners must wire events on the <view-dropdown> sidebar component."""
match = re.search( match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS, _JS,
@@ -3561,17 +3550,10 @@ def test_bind_static_event_listeners_has_sidebar_dropdown_click_outside() -> Non
) )
assert match, "bindStaticEventListeners function not found" assert match, "bindStaticEventListeners function not found"
body = match.group(1) body = match.group(1)
# There must be a document click listener that references the sidebar dropdown menu # The <view-dropdown> component handles click-outside internally.
# and closes it when clicking outside # bindStaticEventListeners should wire the sidebar dropdown component events.
assert "sidebar-view-dropdown-menu" in body, ( assert "sidebar-view-dropdown" in body, (
"bindStaticEventListeners must reference sidebar-view-dropdown-menu" "bindStaticEventListeners must reference sidebar-view-dropdown component"
)
# 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"
) )
@@ -3970,10 +3952,10 @@ def test_open_flyout_menu_checks_mobile() -> None:
def test_render_view_dropdown_has_manage_view_affordance() -> 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. The manage-view affordance is now handled by the <view-dropdown> component internally.
This item should open the Manage View panel for the current user view. The renderViewDropdown function sets component properties.
""" """
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", 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" assert match, "renderViewDropdown function not found"
body = match.group(1) 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' " "renderViewDropdown must include a 'Manage [ViewName]...' action with data-action='manage-view' "
"for user views — this replaced the old #add-sessions-btn header button" "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: 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 The document-level click-outside handler has been removed since the
input via replaceChild. The click event then bubbles up to the document-level <view-dropdown> component handles click-outside behavior internally.
click-outside handler, where e.target is the OLD button that was just removed The bindStaticEventListeners function now wires component events
from the DOM. Since it is no longer in the DOM, dropdown.contains(e.target) (dropdown-toggle, dropdown-close, view-switch, etc.) instead.
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.
""" """
match = re.search( # Verify the old click-outside handler is removed
r"// Click-outside closes the header view dropdown\s*\n\s*" assert "// Click-outside closes the header view dropdown" not in _JS, (
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);", "Old click-outside handler for header view dropdown should be removed "
_JS, "(now handled by <view-dropdown> component)"
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"
) )
@@ -4293,7 +4261,7 @@ _CSS: str = CSS_PATH.read_text()
def test_render_sidebar_view_dropdown_has_new_view_action() -> None: 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( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4301,8 +4269,9 @@ def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert 'data-action="new-view"' in body, ( # Component handles rendering the "+ New View" button internally
'renderSidebarViewDropdown must include a "+ New View" button with data-action="new-view"' 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: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4345,7 +4314,7 @@ def test_render_view_dropdown_no_shortcut_spans() -> None:
def test_render_sidebar_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( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4372,8 +4341,9 @@ def test_render_view_dropdown_shows_user_view_session_count() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "visibleCount" in body, ( # Component handles session count display; function sets .sessions property
"renderViewDropdown must use visibleCount() for user view session counts " 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)" "(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: 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 The document-level click-outside handler has been removed since the
handler before the input appears. Guard: check for .view-dropdown__new-input <view-dropdown> component handles click-outside behavior internally.
presence and return early if found.
""" """
match = re.search( # Verify the old click-outside handler is removed
r"// Click-outside closes the sidebar view dropdown\s*\n\s*" assert "// Click-outside closes the sidebar view dropdown" not in _JS, (
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);", "Old click-outside handler for sidebar view dropdown should be removed "
_JS, "(now handled by <view-dropdown> component)"
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"
) )
@@ -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: 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( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4570,14 +4531,14 @@ def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "manage-view" in body, ( # Component handles manage-view action; function sets properties
"renderViewDropdown must include a 'Manage [ViewName]...' action " assert "dropdown.views" in body, (
"with data-action='manage-view' for user views" "renderViewDropdown must set views property on <view-dropdown> component"
) )
def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None: 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( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4585,9 +4546,9 @@ def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "manage-view" in body, ( # Component handles manage-view action; function sets properties
"renderSidebarViewDropdown must include a 'Manage [ViewName]...' action " assert "dropdown.views" in body, (
"with data-action='manage-view' for user views" "renderSidebarViewDropdown must set views property on <view-dropdown> component"
) )