9c86cf096d
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.
184 lines
6.7 KiB
JavaScript
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);
|