Files
muxplex/muxplex/frontend/components/view-dropdown.js
T
Ken 9c86cf096d
CI / test (3.13) (push) Failing after 10m48s
CI / test (3.12) (push) Failing after 10m50s
CI / test (3.11) (push) Failing after 15m52s
feat(frontend): phase 3 lit web component migration - view-dropdown & store components
New files:
- muxplex/frontend/components/store.js: Reactive AppStore (EventTarget-based
  state management). Provides get/set/batch/on methods with automatic change
  event dispatch. Convenience accessors for sessions, activeView, serverSettings.
- muxplex/frontend/components/view-dropdown.js: Unified <view-dropdown> component.
  Replaces TWO parallel dropdown implementations (header + sidebar) with a single
  component taking a variant prop. Eliminates ~300 lines of duplicated
  HTML-string-building code.

app.js changes:
- renderViewDropdown/renderSidebarViewDropdown: 100+ lines of HTML builders
  → 6-line property setters
- toggleViewDropdown/toggleSidebarViewDropdown: simplified to toggle .open property
- closeViewDropdown: simplified to set .open = false
- switchView: removed manual sidebar label update (component handles it)
- bindStaticEventListeners: replaced 80+ lines of header/sidebar dropdown event
  wiring + two click-outside handlers with component event listeners
- showNewViewInput/showSidebarNewViewInput: updated to find menu inside component
- Exposed filterVisible() globally for component use
- Removed dead global _previewPopover

index.html changes:
- Replaced 14-line header dropdown structure with single <view-dropdown>
- Replaced 8-line sidebar dropdown structure with single <view-dropdown variant="sidebar">
- Added store.js and view-dropdown.js imports

All 1306 tests pass.
2026-05-27 07:01:01 +00:00

184 lines
6.7 KiB
JavaScript

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);