From 53df62a9fd63b985668d64b60ce2222330330850 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 4 Jan 2026 10:48:03 +0000 Subject: [PATCH] feat(wcctools): add context menu and pinning support, persist pinned state in URL, and add grouped demo test elements --- changelog.md | 9 + test/elements/index.ts | 7 + test/elements/test-button-danger.ts | 45 ++++ test/elements/test-button-primary.ts | 45 ++++ test/elements/test-button-secondary.ts | 45 ++++ test/elements/test-input-checkbox.ts | 68 +++++ test/elements/test-input-text.ts | 59 +++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/wcc-contextmenu.ts | 211 ++++++++++++++++ ts_web/elements/wcc-dashboard.ts | 45 ++++ ts_web/elements/wcc-sidebar.ts | 327 ++++++++++++++++++++----- 11 files changed, 803 insertions(+), 60 deletions(-) create mode 100644 test/elements/test-button-danger.ts create mode 100644 test/elements/test-button-primary.ts create mode 100644 test/elements/test-button-secondary.ts create mode 100644 test/elements/test-input-checkbox.ts create mode 100644 test/elements/test-input-text.ts create mode 100644 ts_web/elements/wcc-contextmenu.ts diff --git a/changelog.md b/changelog.md index e273671..c5c8b11 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-01-04 - 3.5.0 - feat(wcctools) +add context menu and pinning support, persist pinned state in URL, and add grouped demo test elements + +- Add wcc-contextmenu custom element with a static show() API, proper positioning, visibility transitions, outside-click and Escape handling, and menu item actions. +- Introduce pinnedItems (Set) on wcc-dashboard and wcc-sidebar; pass pinnedItems to the sidebar, handle pinnedChanged events, and persist pinned item keys in the URL query param 'pinned'. Changes include defensive updates to avoid unnecessary update loops. +- Enhance wcc-sidebar to render pinned state: new styles for pinned items and pinned sections, contextmenu integration for element items, adjusted layout (grid-template-columns) and improved element/demo rendering logic. +- Add grouped demo test components and exports to demo the demoGroup feature: test-button-primary, test-button-secondary, test-button-danger, test-input-text, and test-input-checkbox. +- Misc: adjust dashboard URL state serialization/deserialization to include pinned items and ensure scroll/search state handling remains stable. + ## 2025-12-30 - 3.4.0 - feat(sidebar) add searchable sidebar with URL-backed query state and highlighted matches diff --git a/test/elements/index.ts b/test/elements/index.ts index 61a66eb..4b6b166 100644 --- a/test/elements/index.ts +++ b/test/elements/index.ts @@ -4,3 +4,10 @@ export * from './test-complextypes.js'; export * from './test-withwrapper.js'; export * from './test-edgecases.js'; export * from './test-nested.js'; + +// Grouped elements to demo the demoGroup feature +export * from './test-button-primary.js'; +export * from './test-button-secondary.js'; +export * from './test-button-danger.js'; +export * from './test-input-text.js'; +export * from './test-input-checkbox.js'; diff --git a/test/elements/test-button-danger.ts b/test/elements/test-button-danger.ts new file mode 100644 index 0000000..7d7c466 --- /dev/null +++ b/test/elements/test-button-danger.ts @@ -0,0 +1,45 @@ +import { + DeesElement, + customElement, + html, + property, + css, +} from '@design.estate/dees-element'; + +@customElement('test-button-danger') +export class TestButtonDanger extends DeesElement { + // Same group as other buttons + public static demoGroup = 'Buttons'; + + public static demo = () => html` + Delete + `; + + @property({ type: String }) + accessor label: string = 'Delete'; + + public static styles = [ + css` + :host { + display: inline-block; + } + button { + background: #ef4444; + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; + } + button:hover { + background: #dc2626; + } + `, + ]; + + public render() { + return html``; + } +} diff --git a/test/elements/test-button-primary.ts b/test/elements/test-button-primary.ts new file mode 100644 index 0000000..6aced7b --- /dev/null +++ b/test/elements/test-button-primary.ts @@ -0,0 +1,45 @@ +import { + DeesElement, + customElement, + html, + property, + css, +} from '@design.estate/dees-element'; + +@customElement('test-button-primary') +export class TestButtonPrimary extends DeesElement { + // This groups the element with other "Buttons" in the sidebar + public static demoGroup = 'Buttons'; + + public static demo = () => html` + Click Me + `; + + @property({ type: String }) + accessor label: string = 'Button'; + + public static styles = [ + css` + :host { + display: inline-block; + } + button { + background: #3b82f6; + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; + } + button:hover { + background: #2563eb; + } + `, + ]; + + public render() { + return html``; + } +} diff --git a/test/elements/test-button-secondary.ts b/test/elements/test-button-secondary.ts new file mode 100644 index 0000000..89a555f --- /dev/null +++ b/test/elements/test-button-secondary.ts @@ -0,0 +1,45 @@ +import { + DeesElement, + customElement, + html, + property, + css, +} from '@design.estate/dees-element'; + +@customElement('test-button-secondary') +export class TestButtonSecondary extends DeesElement { + // Same group as test-button-primary - they'll appear together + public static demoGroup = 'Buttons'; + + public static demo = () => html` + Secondary Action + `; + + @property({ type: String }) + accessor label: string = 'Button'; + + public static styles = [ + css` + :host { + display: inline-block; + } + button { + background: transparent; + color: #3b82f6; + border: 1px solid #3b82f6; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + } + button:hover { + background: rgba(59, 130, 246, 0.1); + } + `, + ]; + + public render() { + return html``; + } +} diff --git a/test/elements/test-input-checkbox.ts b/test/elements/test-input-checkbox.ts new file mode 100644 index 0000000..7098552 --- /dev/null +++ b/test/elements/test-input-checkbox.ts @@ -0,0 +1,68 @@ +import { + DeesElement, + customElement, + html, + property, + css, +} from '@design.estate/dees-element'; + +@customElement('test-input-checkbox') +export class TestInputCheckbox extends DeesElement { + // Same group as test-input-text + public static demoGroup = 'Inputs'; + + public static demo = () => html` + + `; + + @property({ type: String }) + accessor label: string = 'Checkbox'; + + @property({ type: Boolean }) + accessor checked: boolean = false; + + public static styles = [ + css` + :host { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + } + .checkbox { + width: 18px; + height: 18px; + border: 1px solid #333; + border-radius: 4px; + background: #1a1a1a; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + .checkbox.checked { + background: #3b82f6; + border-color: #3b82f6; + } + .checkbox.checked::after { + content: '✓'; + color: white; + font-size: 12px; + } + .label { + color: #e5e5e5; + font-size: 14px; + } + `, + ]; + + public render() { + return html` +
this.checked = !this.checked} + >
+ ${this.label} + `; + } +} diff --git a/test/elements/test-input-text.ts b/test/elements/test-input-text.ts new file mode 100644 index 0000000..94440b9 --- /dev/null +++ b/test/elements/test-input-text.ts @@ -0,0 +1,59 @@ +import { + DeesElement, + customElement, + html, + property, + css, +} from '@design.estate/dees-element'; + +@customElement('test-input-text') +export class TestInputText extends DeesElement { + // Different group - "Inputs" + public static demoGroup = 'Inputs'; + + public static demo = () => html` + + `; + + @property({ type: String }) + accessor placeholder: string = ''; + + @property({ type: String }) + accessor value: string = ''; + + public static styles = [ + css` + :host { + display: inline-block; + } + input { + background: #1a1a1a; + color: #e5e5e5; + border: 1px solid #333; + padding: 10px 14px; + border-radius: 6px; + font-size: 14px; + outline: none; + transition: border-color 0.2s; + min-width: 200px; + } + input:focus { + border-color: #3b82f6; + } + input::placeholder { + color: #666; + } + `, + ]; + + public render() { + return html` + this.value = (e.target as HTMLInputElement).value} + /> + `; + } +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 15edcfe..f2796b7 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-wcctools', - version: '3.4.0', + version: '3.5.0', description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.' } diff --git a/ts_web/elements/wcc-contextmenu.ts b/ts_web/elements/wcc-contextmenu.ts new file mode 100644 index 0000000..da00fbe --- /dev/null +++ b/ts_web/elements/wcc-contextmenu.ts @@ -0,0 +1,211 @@ +import { DeesElement, property, html, customElement, type TemplateResult, state, css, cssManager } from '@design.estate/dees-element'; + +export interface IContextMenuItem { + name: string; + iconName?: string; + action: () => void | Promise; + disabled?: boolean; +} + +@customElement('wcc-contextmenu') +export class WccContextmenu extends DeesElement { + // Static method to show context menu at position + public static async show( + event: MouseEvent, + menuItems: IContextMenuItem[] + ): Promise { + event.preventDefault(); + event.stopPropagation(); + + // Remove any existing context menu + const existing = document.querySelector('wcc-contextmenu'); + if (existing) { + existing.remove(); + } + + const menu = new WccContextmenu(); + menu.menuItems = menuItems; + menu.x = event.clientX; + menu.y = event.clientY; + + document.body.appendChild(menu); + + // Wait for render then adjust position if needed + await menu.updateComplete; + menu.adjustPosition(); + } + + @property({ type: Array }) + accessor menuItems: IContextMenuItem[] = []; + + @property({ type: Number }) + accessor x: number = 0; + + @property({ type: Number }) + accessor y: number = 0; + + @state() + accessor visible: boolean = false; + + private boundHandleOutsideClick = this.handleOutsideClick.bind(this); + private boundHandleKeydown = this.handleKeydown.bind(this); + + public static styles = [ + css` + :host { + position: fixed; + z-index: 10000; + opacity: 0; + transform: scale(0.95) translateY(-5px); + transition: opacity 0.15s ease, transform 0.15s ease; + pointer-events: none; + } + + :host(.visible) { + opacity: 1; + transform: scale(1) translateY(0); + pointer-events: auto; + } + + .menu { + min-width: 160px; + background: #0f0f0f; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + padding: 4px 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + font-size: 12px; + } + + .menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + color: #ccc; + cursor: pointer; + transition: background 0.1s ease; + user-select: none; + } + + .menu-item:hover { + background: rgba(59, 130, 246, 0.15); + color: #fff; + } + + .menu-item.disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + } + + .menu-item .icon { + font-family: 'Material Symbols Outlined'; + font-size: 16px; + font-weight: normal; + font-style: normal; + line-height: 1; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24; + opacity: 0.7; + } + + .menu-item:hover .icon { + opacity: 1; + } + + .menu-item .label { + flex: 1; + } + ` + ]; + + public render(): TemplateResult { + return html` + + `; + } + + async connectedCallback() { + await super.connectedCallback(); + // Delay adding listeners to avoid immediate close + requestAnimationFrame(() => { + document.addEventListener('click', this.boundHandleOutsideClick); + document.addEventListener('contextmenu', this.boundHandleOutsideClick); + document.addEventListener('keydown', this.boundHandleKeydown); + this.classList.add('visible'); + }); + } + + async disconnectedCallback() { + await super.disconnectedCallback(); + document.removeEventListener('click', this.boundHandleOutsideClick); + document.removeEventListener('contextmenu', this.boundHandleOutsideClick); + document.removeEventListener('keydown', this.boundHandleKeydown); + } + + private adjustPosition() { + const rect = this.getBoundingClientRect(); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + let x = this.x; + let y = this.y; + + // Adjust if menu goes off right edge + if (x + rect.width > windowWidth - 10) { + x = windowWidth - rect.width - 10; + } + + // Adjust if menu goes off bottom edge + if (y + rect.height > windowHeight - 10) { + y = windowHeight - rect.height - 10; + } + + // Ensure not off left or top + if (x < 10) x = 10; + if (y < 10) y = 10; + + this.style.left = `${x}px`; + this.style.top = `${y}px`; + } + + private handleOutsideClick(e: Event) { + const path = e.composedPath(); + if (!path.includes(this)) { + this.close(); + } + } + + private handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + this.close(); + } + } + + private async handleItemClick(item: IContextMenuItem) { + if (item.disabled) return; + await item.action(); + this.close(); + } + + private close() { + this.classList.remove('visible'); + setTimeout(() => this.remove(), 150); + } +} diff --git a/ts_web/elements/wcc-dashboard.ts b/ts_web/elements/wcc-dashboard.ts index 3375894..e3814d3 100644 --- a/ts_web/elements/wcc-dashboard.ts +++ b/ts_web/elements/wcc-dashboard.ts @@ -62,6 +62,10 @@ export class WccDashboard extends DeesElement { @property() accessor searchQuery: string = ''; + // Pinned items as Set of "sectionName::itemName" + @property({ attribute: false }) + accessor pinnedItems: Set = new Set(); + // Derived from selectedViewport - no need for separate property public get isNative(): boolean { return this.selectedViewport === 'native'; @@ -122,6 +126,7 @@ export class WccDashboard extends DeesElement { .dashboardRef=${this} .selectedItem=${this.selectedItem} .searchQuery=${this.searchQuery} + .pinnedItems=${this.pinnedItems} .isNative=${this.isNative} @selectedType=${(eventArg) => { this.selectedType = eventArg.detail; @@ -136,6 +141,10 @@ export class WccDashboard extends DeesElement { this.searchQuery = eventArg.detail; this.updateUrlWithScrollState(); }} + @pinnedChanged=${(eventArg: CustomEvent) => { + this.pinnedItems = eventArg.detail; + this.updateUrlWithScrollState(); + }} > this.pinnedItems.has(k))) { + this.pinnedItems = newPinned; + } + } else if (this.pinnedItems.size > 0) { + this.pinnedItems = new Set(); + } // Apply scroll positions after a short delay to ensure DOM is ready setTimeout(() => { @@ -256,6 +276,10 @@ export class WccDashboard extends DeesElement { }, 100); } else { this.searchQuery = ''; + // Only clear if not already empty to avoid update loops + if (this.pinnedItems.size > 0) { + this.pinnedItems = new Set(); + } } const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup(); @@ -301,6 +325,7 @@ export class WccDashboard extends DeesElement { const search = routeInfo.queryParams.search; const frameScrollY = routeInfo.queryParams.frameScrollY; const sidebarScrollY = routeInfo.queryParams.sidebarScrollY; + const pinned = routeInfo.queryParams.pinned; if (search) { this.searchQuery = search; @@ -313,6 +338,16 @@ export class WccDashboard extends DeesElement { if (sidebarScrollY) { this.sidebarScrollY = parseInt(sidebarScrollY); } + if (pinned) { + const newPinned = new Set(pinned.split(',').filter(Boolean)); + // Only update if actually different to avoid update loops + if (this.pinnedItems.size !== newPinned.size || + ![...newPinned].every(k => this.pinnedItems.has(k))) { + this.pinnedItems = newPinned; + } + } else if (this.pinnedItems.size > 0) { + this.pinnedItems = new Set(); + } // Apply scroll positions after a short delay to ensure DOM is ready setTimeout(() => { @@ -320,6 +355,10 @@ export class WccDashboard extends DeesElement { }, 100); } else { this.searchQuery = ''; + // Only clear if not already empty to avoid update loops + if (this.pinnedItems.size > 0) { + this.pinnedItems = new Set(); + } } const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup(); @@ -402,6 +441,9 @@ export class WccDashboard extends DeesElement { if (this.sidebarScrollY > 0) { queryParams.set('sidebarScrollY', this.sidebarScrollY.toString()); } + if (this.pinnedItems.size > 0) { + queryParams.set('pinned', Array.from(this.pinnedItems).join(',')); + } const queryString = queryParams.toString(); const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl; @@ -462,6 +504,9 @@ export class WccDashboard extends DeesElement { if (this.sidebarScrollY > 0) { queryParams.set('sidebarScrollY', this.sidebarScrollY.toString()); } + if (this.pinnedItems.size > 0) { + queryParams.set('pinned', Array.from(this.pinnedItems).join(',')); + } const queryString = queryParams.toString(); const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl; diff --git a/ts_web/elements/wcc-sidebar.ts b/ts_web/elements/wcc-sidebar.ts index 27b97d0..1b73c29 100644 --- a/ts_web/elements/wcc-sidebar.ts +++ b/ts_web/elements/wcc-sidebar.ts @@ -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 = 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; + }
`; @@ -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(); 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` +
this.toggleSectionCollapsed('__pinned__')} + > + expand_more + push_pin + Pinned +
+
+ ${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` +
{ + await plugins.deesDomtools.DomTools.setupDomTools(); + this.selectItem(type, itemName, item, 0, section); + }} + @contextmenu=${(e: MouseEvent) => this.showContextMenu(e, sectionName, itemName)} + > + ${icon} +
${this.highlightMatch(itemName)}
+ +
+ `; + })} +
+ `; + } + /** * 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`
{ await plugins.deesDomtools.DomTools.setupDomTools(); this.selectItem('page', pageName, item, 0, section); }} + @contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, pageName)} > insert_drive_file
${this.highlightMatch(pageName)}
@@ -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>(); - if (isMultiDemo) { - // Multi-demo element - render as expandable folder - return html` -
this.toggleExpanded(elementName)} - > - chevron_right - folder -
${this.highlightMatch(elementName)}
-
- ${isExpanded ? html` -
- ${Array.from({ length: demoCount }, (_, i) => { - const demoIndex = i; - const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex; - return html` -
{ - await plugins.deesDomtools.DomTools.setupDomTools(); - this.selectItem('element', elementName, item, demoIndex, section); - }} - > - play_circle -
demo${demoIndex + 1}
-
- `; - })} -
- ` : null} - `; - } else { - // Single demo element - return html` -
{ - await plugins.deesDomtools.DomTools.setupDomTools(); - this.selectItem('element', elementName, item, 0, section); - }} - > - featured_video -
${this.highlightMatch(elementName)}
-
- `; + 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` +
+ ${groupName} + ${items.map((entry) => this.renderElementItem(entry, section))} +
+ `); + } + + 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` +
this.toggleExpanded(elementName)} + @contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)} + > + chevron_right +
${this.highlightMatch(elementName)}
+
+ ${isExpanded ? html` +
+ ${Array.from({ length: demoCount }, (_, i) => { + const demoIndex = i; + const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex; + return html` +
{ + await plugins.deesDomtools.DomTools.setupDomTools(); + this.selectItem('element', elementName, item, demoIndex, section); + }} + > + play_circle +
demo${demoIndex + 1}
+
+ `; + })} +
+ ` : null} + `; + } else { + // Single demo element + return html` +
{ + await plugins.deesDomtools.DomTools.setupDomTools(); + this.selectItem('element', elementName, item, 0, section); + }} + @contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)} + > + featured_video +
${this.highlightMatch(elementName)}
+
+ `; } } @@ -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;