feat(wcctools): add context menu and pinning support, persist pinned state in URL, and add grouped demo test elements

This commit is contained in:
2026-01-04 10:48:03 +00:00
parent 4fe17f5afd
commit 53df62a9fd
11 changed files with 803 additions and 60 deletions

View File

@@ -4,6 +4,7 @@ import { WccDashboard, getSectionItems } from './wcc-dashboard.js';
import type { TTemplateFactory } from './wcctools.helpers.js';
import { getDemoCount, hasMultipleDemos } from './wcctools.helpers.js';
import type { IWccSection, TElementType } from '../wcctools.interfaces.js';
import { WccContextmenu } from './wcc-contextmenu.js';
@customElement('wcc-sidebar')
export class WccSidebar extends DeesElement {
@@ -31,6 +32,10 @@ export class WccSidebar extends DeesElement {
@property()
accessor searchQuery: string = '';
// Pinned items as Set of "sectionName::itemName"
@property({ attribute: false })
accessor pinnedItems: Set<string> = new Set();
private sectionsInitialized = false;
public render(): TemplateResult {
@@ -159,7 +164,7 @@ export class WccSidebar extends DeesElement {
}
.selectOption.folder {
grid-template-columns: 16px 20px 1fr;
grid-template-columns: 16px 1fr;
}
.selectOption .expand-icon {
@@ -288,6 +293,65 @@ export class WccSidebar extends DeesElement {
background: rgba(59, 130, 246, 0.3);
border-radius: 2px;
}
/* Pinned item highlight in original section */
.selectOption.pinned {
background: rgba(245, 158, 11, 0.08);
}
.selectOption.pinned:hover {
background: rgba(245, 158, 11, 0.12);
}
.selectOption.pinned.selected {
background: rgba(245, 158, 11, 0.18);
}
/* Pinned section styling */
.section-header.pinned-section {
background: rgba(245, 158, 11, 0.08);
color: #f59e0b;
}
.section-header.pinned-section:hover {
background: rgba(245, 158, 11, 0.12);
}
.section-header.pinned-section .section-icon {
opacity: 0.8;
}
/* Section tag for pinned items */
.section-tag {
font-size: 0.55rem;
color: #555;
margin-left: auto;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Group container */
.item-group {
margin: 0.375rem 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 0.25rem 0;
background: rgba(255, 255, 255, 0.01);
}
.item-group-legend {
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #555;
padding: 0.125rem 0.625rem 0.25rem;
display: block;
}
.item-group .selectOption {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
</style>
<div class="search-container">
<input
@@ -299,6 +363,7 @@ export class WccSidebar extends DeesElement {
/>
</div>
<div class="menu">
${this.renderPinnedSection()}
${this.renderSections()}
</div>
`;
@@ -308,7 +373,7 @@ export class WccSidebar extends DeesElement {
* Initialize collapsed sections from section config
*/
private initCollapsedSections() {
if (this.sectionsInitialized) return;
if (this.sectionsInitialized || !this.dashboardRef?.sections) return;
const collapsed = new Set<string>();
for (const section of this.dashboardRef.sections) {
@@ -320,13 +385,116 @@ export class WccSidebar extends DeesElement {
this.sectionsInitialized = true;
}
// ============ Pinning helpers ============
private getPinKey(sectionName: string, itemName: string): string {
return `${sectionName}::${itemName}`;
}
private isPinned(sectionName: string, itemName: string): boolean {
return this.pinnedItems.has(this.getPinKey(sectionName, itemName));
}
private togglePin(sectionName: string, itemName: string) {
const key = this.getPinKey(sectionName, itemName);
const newPinned = new Set(this.pinnedItems);
if (newPinned.has(key)) {
newPinned.delete(key);
} else {
newPinned.add(key);
}
this.pinnedItems = newPinned;
this.dispatchEvent(new CustomEvent('pinnedChanged', { detail: newPinned }));
}
private showContextMenu(e: MouseEvent, sectionName: string, itemName: string) {
const isPinned = this.isPinned(sectionName, itemName);
WccContextmenu.show(e, [
{
name: isPinned ? 'Unpin' : 'Pin',
iconName: isPinned ? 'push_pin' : 'push_pin',
action: () => this.togglePin(sectionName, itemName),
},
]);
}
/**
* Render the PINNED section (only if there are pinned items)
*/
private renderPinnedSection() {
if (!this.dashboardRef?.sections || this.pinnedItems.size === 0) {
return null;
}
const isCollapsed = this.collapsedSections.has('__pinned__');
// Collect pinned items with their original section info
const pinnedEntries: Array<{ sectionName: string; itemName: string; item: any; section: IWccSection }> = [];
for (const key of this.pinnedItems) {
const [sectionName, itemName] = key.split('::');
const section = this.dashboardRef.sections.find(s => s.name === sectionName);
if (section) {
const entries = getSectionItems(section);
const found = entries.find(([name]) => name === itemName);
if (found) {
pinnedEntries.push({ sectionName, itemName, item: found[1], section });
}
}
}
// Filter by search
const filteredEntries = pinnedEntries.filter(e => this.matchesSearch(e.itemName));
if (filteredEntries.length === 0 && this.searchQuery) {
return null;
}
return html`
<div
class="section-header pinned-section ${isCollapsed ? 'collapsed' : ''}"
@click=${() => this.toggleSectionCollapsed('__pinned__')}
>
<i class="material-symbols-outlined expand-icon">expand_more</i>
<i class="material-symbols-outlined section-icon">push_pin</i>
<span>Pinned</span>
</div>
<div class="section-content ${isCollapsed ? 'collapsed' : ''}">
${filteredEntries.map(({ sectionName, itemName, item, section }) => {
const isSelected = this.selectedItem === item;
const type = section.type === 'elements' ? 'element' : 'page';
const icon = section.type === 'elements' ? 'featured_video' : 'insert_drive_file';
return html`
<div
class="selectOption ${isSelected ? 'selected' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem(type, itemName, item, 0, section);
}}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, sectionName, itemName)}
>
<i class="material-symbols-outlined">${icon}</i>
<div class="text">${this.highlightMatch(itemName)}</div>
<span class="section-tag">${sectionName}</span>
</div>
`;
})}
</div>
`;
}
/**
* Render all sections
*/
private renderSections() {
if (!this.dashboardRef?.sections) {
return null;
}
this.initCollapsedSections();
return this.dashboardRef.sections.map((section, index) => {
return this.dashboardRef.sections.map((section) => {
// Check if section has any matching items
const entries = getSectionItems(section);
const filteredEntries = entries.filter(([name]) => this.matchesSearch(name));
@@ -365,13 +533,15 @@ export class WccSidebar extends DeesElement {
if (section.type === 'pages') {
return filteredEntries.map(([pageName, item]) => {
const isPinned = this.isPinned(section.name, pageName);
return html`
<div
class="selectOption ${this.selectedItem === item ? 'selected' : ''}"
class="selectOption ${this.selectedItem === item ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('page', pageName, item, 0, section);
}}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, pageName)}
>
<i class="material-symbols-outlined">insert_drive_file</i>
<div class="text">${this.highlightMatch(pageName)}</div>
@@ -379,62 +549,101 @@ export class WccSidebar extends DeesElement {
`;
});
} else {
// type === 'elements'
return filteredEntries.map(([elementName, item]) => {
const anonItem = item as any;
const demoCount = anonItem.demo ? getDemoCount(anonItem.demo) : 0;
const isMultiDemo = anonItem.demo && hasMultipleDemos(anonItem.demo);
const isExpanded = this.expandedElements.has(elementName);
const isSelected = this.selectedItem === item;
// type === 'elements' - group by demoGroup
const groupedItems = new Map<string | null, Array<[string, any]>>();
if (isMultiDemo) {
// Multi-demo element - render as expandable folder
return html`
<div
class="selectOption folder ${isExpanded ? 'expanded' : ''} ${isSelected ? 'selected' : ''}"
@click=${() => this.toggleExpanded(elementName)}
>
<i class="material-symbols-outlined expand-icon">chevron_right</i>
<i class="material-symbols-outlined">folder</i>
<div class="text">${this.highlightMatch(elementName)}</div>
</div>
${isExpanded ? html`
<div class="demo-children">
${Array.from({ length: demoCount }, (_, i) => {
const demoIndex = i;
const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex;
return html`
<div
class="demo-child ${isThisDemoSelected ? 'selected' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('element', elementName, item, demoIndex, section);
}}
>
<i class="material-symbols-outlined">play_circle</i>
<div class="text">demo${demoIndex + 1}</div>
</div>
`;
})}
</div>
` : null}
`;
} else {
// Single demo element
return html`
<div
class="selectOption ${isSelected ? 'selected' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('element', elementName, item, 0, section);
}}
>
<i class="material-symbols-outlined">featured_video</i>
<div class="text">${this.highlightMatch(elementName)}</div>
</div>
`;
for (const entry of filteredEntries) {
const [, item] = entry;
const group = (item as any).demoGroup || null;
if (!groupedItems.has(group)) {
groupedItems.set(group, []);
}
});
groupedItems.get(group)!.push(entry);
}
const result: TemplateResult[] = [];
// Render ungrouped items first
const ungrouped = groupedItems.get(null) || [];
for (const entry of ungrouped) {
result.push(this.renderElementItem(entry, section));
}
// Render grouped items
for (const [groupName, items] of groupedItems) {
if (groupName === null) continue;
result.push(html`
<div class="item-group">
<span class="item-group-legend">${groupName}</span>
${items.map((entry) => this.renderElementItem(entry, section))}
</div>
`);
}
return result;
}
}
/**
* Render a single element item (used by renderSectionItems)
*/
private renderElementItem(entry: [string, any], section: IWccSection): TemplateResult {
const [elementName, item] = entry;
const anonItem = item as any;
const demoCount = anonItem.demo ? getDemoCount(anonItem.demo) : 0;
const isMultiDemo = anonItem.demo && hasMultipleDemos(anonItem.demo);
const isExpanded = this.expandedElements.has(elementName);
const isSelected = this.selectedItem === item;
const isPinned = this.isPinned(section.name, elementName);
if (isMultiDemo) {
// Multi-demo element - render as expandable folder
return html`
<div
class="selectOption folder ${isExpanded ? 'expanded' : ''} ${isSelected ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
@click=${() => this.toggleExpanded(elementName)}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)}
>
<i class="material-symbols-outlined expand-icon">chevron_right</i>
<div class="text">${this.highlightMatch(elementName)}</div>
</div>
${isExpanded ? html`
<div class="demo-children">
${Array.from({ length: demoCount }, (_, i) => {
const demoIndex = i;
const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex;
return html`
<div
class="demo-child ${isThisDemoSelected ? 'selected' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('element', elementName, item, demoIndex, section);
}}
>
<i class="material-symbols-outlined">play_circle</i>
<div class="text">demo${demoIndex + 1}</div>
</div>
`;
})}
</div>
` : null}
`;
} else {
// Single demo element
return html`
<div
class="selectOption ${isSelected ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('element', elementName, item, 0, section);
}}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)}
>
<i class="material-symbols-outlined">featured_video</i>
<div class="text">${this.highlightMatch(elementName)}</div>
</div>
`;
}
}
@@ -485,7 +694,7 @@ export class WccSidebar extends DeesElement {
super.updated(changedProperties);
// Auto-expand folder when a multi-demo element is selected
if (changedProperties.has('selectedItem') && this.selectedItem) {
if (changedProperties.has('selectedItem') && this.selectedItem && this.dashboardRef?.sections) {
// Find the element in any section
for (const section of this.dashboardRef.sections) {
if (section.type !== 'elements') continue;