import { demoFunc } from './dees-statsgrid.demo.js'; import * as plugins from './00plugins.js'; import { cssGeistFontFamily } from './00fonts.js'; import { customElement, html, DeesElement, property, state, css, unsafeCSS, cssManager, } from '@design.estate/dees-element'; import type { TemplateResult } from '@design.estate/dees-element'; import './dees-icon.js'; import './dees-contextmenu.js'; import './dees-button.js'; declare global { interface HTMLElementTagNameMap { 'dees-statsgrid': DeesStatsGrid; } } export interface IStatsTile { id: string; title: string; value: number | string; unit?: string; type: 'number' | 'gauge' | 'percentage' | 'trend' | 'text'; // For gauge type gaugeOptions?: { min: number; max: number; thresholds?: Array<{value: number; color: string}>; }; // For trend type trendData?: number[]; // Visual customization color?: string; icon?: string; description?: string; // Tile-specific actions actions?: plugins.tsclass.website.IMenuItem[]; } @customElement('dees-statsgrid') export class DeesStatsGrid extends DeesElement { public static demo = demoFunc; @property({ type: Array }) public tiles: IStatsTile[] = []; @property({ type: Number }) public minTileWidth: number = 250; @property({ type: Number }) public gap: number = 16; @property({ type: Array }) public gridActions: plugins.tsclass.website.IMenuItem[] = []; @state() private contextMenuVisible = false; @state() private contextMenuPosition = { x: 0, y: 0 }; @state() private contextMenuActions: plugins.tsclass.website.IMenuItem[] = []; public static styles = [ cssManager.defaultStyles, css` :host { display: block; width: 100%; font-family: ${cssGeistFontFamily}; } /* CSS Variables for consistent spacing and sizing */ :host { --grid-gap: 16px; --tile-padding: 24px; --header-spacing: 16px; --content-min-height: 48px; --value-font-size: 30px; --unit-font-size: 16px; --label-font-size: 13px; --title-font-size: 14px; --description-spacing: 12px; --border-radius: 8px; --transition-duration: 0.15s; } /* Grid Layout */ .grid-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: calc(var(--grid-gap) * 1.5); min-height: 40px; } .grid-title { font-size: 16px; font-weight: 500; color: ${cssManager.bdTheme('#09090b', '#fafafa')}; letter-spacing: -0.01em; } .grid-actions { display: flex; gap: 6px; } .grid-actions dees-button { font-size: var(--label-font-size); } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(${unsafeCSS(250)}px, 1fr)); gap: ${unsafeCSS(16)}px; width: 100%; } /* Tile Base Styles */ .stats-tile { background: ${cssManager.bdTheme('#ffffff', '#09090b')}; border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 11.8%)')}; border-radius: var(--border-radius); padding: var(--tile-padding); transition: all var(--transition-duration) ease; cursor: default; position: relative; overflow: hidden; display: flex; flex-direction: column; } .stats-tile:hover { background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 10.2%)')}; border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 16.8%)')}; } .stats-tile.clickable { cursor: pointer; } .stats-tile.clickable:hover { transform: translateY(-1px); box-shadow: 0 2px 8px ${cssManager.bdTheme('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.2)')}; } /* Tile Header */ .tile-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--header-spacing); flex-shrink: 0; } .tile-title { font-size: var(--title-font-size); font-weight: 500; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; margin: 0; letter-spacing: -0.01em; line-height: 1.2; } .tile-icon { opacity: 0.7; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; font-size: 16px; flex-shrink: 0; } /* Tile Content */ .tile-content { min-height: var(--content-min-height); display: flex; flex-direction: column; justify-content: center; flex: 1; } .tile-value { font-size: var(--value-font-size); font-weight: 600; color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')}; line-height: 1.1; display: flex; align-items: baseline; gap: 4px; letter-spacing: -0.025em; } .tile-unit { font-size: var(--unit-font-size); font-weight: 400; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; letter-spacing: -0.01em; } .tile-description { font-size: var(--label-font-size); color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; margin-top: var(--description-spacing); letter-spacing: -0.01em; flex-shrink: 0; } /* Gauge Styles */ .gauge-wrapper { width: 100%; display: flex; justify-content: center; align-items: center; } .gauge-container { width: 140px; height: 80px; position: relative; margin-top: -10px; } .gauge-svg { width: 100%; height: 100%; } .gauge-background { fill: none; stroke: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 21.8%)')}; stroke-width: 8; } .gauge-fill { fill: none; stroke-width: 8; stroke-linecap: round; transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .gauge-text { fill: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')}; font-family: ${cssGeistFontFamily}; font-size: var(--value-font-size); font-weight: 600; text-anchor: middle; letter-spacing: -0.025em; } .gauge-unit { font-size: var(--unit-font-size); fill: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; font-weight: 400; font-family: ${cssGeistFontFamily}; } /* Percentage Styles */ .percentage-wrapper { width: 100%; position: relative; } .percentage-value { font-size: var(--value-font-size); font-weight: 600; color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')}; line-height: 1.1; letter-spacing: -0.025em; margin-bottom: 8px; } .percentage-bar { width: 100%; height: 8px; background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 21.8%)')}; border-radius: 4px; overflow: hidden; } .percentage-fill { height: 100%; background: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')}; transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 4px; } /* Trend Styles */ .trend-container { width: 100%; display: flex; flex-direction: column; gap: 8px; } .trend-header { display: flex; align-items: baseline; gap: 8px; } .trend-value { font-size: var(--value-font-size); font-weight: 600; color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')}; line-height: 1.1; letter-spacing: -0.025em; } .trend-unit { font-size: var(--unit-font-size); font-weight: 400; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; letter-spacing: -0.01em; } .trend-label { font-size: var(--label-font-size); font-weight: 500; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; letter-spacing: -0.01em; margin-left: auto; } .trend-graph { width: 100%; height: 32px; position: relative; } .trend-svg { width: 100%; height: 100%; display: block; } .trend-line { fill: none; stroke: ${cssManager.bdTheme('hsl(215.4 16.3% 66.9%)', 'hsl(215 20.2% 55.1%)')}; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } .trend-area { fill: ${cssManager.bdTheme('hsl(215.4 16.3% 66.9% / 0.1)', 'hsl(215 20.2% 55.1% / 0.08)')}; } /* Text Value Styles */ .text-value { font-size: var(--value-font-size); font-weight: 600; color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')}; line-height: 1.1; letter-spacing: -0.025em; } /* Context Menu */ dees-contextmenu { position: fixed; z-index: 1000; } `, ]; constructor() { super(); } public render(): TemplateResult { return html` ${this.gridActions.length > 0 ? html`
${this.gridActions.map(action => html` this.handleGridAction(action)} type="outline" size="sm" > ${action.iconName ? html`` : ''} ${action.name} `)}
` : ''}
${this.tiles.map(tile => this.renderTile(tile))}
${this.contextMenuVisible ? html` this.contextMenuVisible = false} > ` : ''} `; } private renderTile(tile: IStatsTile): TemplateResult { const hasActions = tile.actions && tile.actions.length > 0; const clickable = hasActions && tile.actions.length === 1; return html`
this.handleTileAction(tile.actions![0], tile) : undefined} @contextmenu=${hasActions ? (e: MouseEvent) => this.showContextMenu(e, tile) : undefined} >

${tile.title}

${tile.icon ? html` ` : ''}
${this.renderTileContent(tile)}
${tile.description && tile.type !== 'trend' ? html`
${tile.description}
` : ''}
`; } private renderTileContent(tile: IStatsTile): TemplateResult { switch (tile.type) { case 'number': return html`
${tile.value} ${tile.unit ? html`${tile.unit}` : ''}
`; case 'gauge': return this.renderGauge(tile); case 'percentage': return this.renderPercentage(tile); case 'trend': return this.renderTrend(tile); case 'text': return html`
${tile.value}
`; default: return html`
${tile.value}
`; } } private renderGauge(tile: IStatsTile): TemplateResult { const value = typeof tile.value === 'number' ? tile.value : parseFloat(tile.value); const options = tile.gaugeOptions || { min: 0, max: 100 }; const percentage = ((value - options.min) / (options.max - options.min)) * 100; // SVG dimensions and calculations const width = 140; const height = 80; const strokeWidth = 8; const padding = strokeWidth / 2 + 2; const radius = 48; const centerX = width / 2; const centerY = height - padding; // Arc path const startX = centerX - radius; const startY = centerY; const endX = centerX + radius; const endY = centerY; const arcPath = `M ${startX} ${startY} A ${radius} ${radius} 0 0 1 ${endX} ${endY}`; // Calculate stroke dasharray and dashoffset const circumference = Math.PI * radius; const strokeDashoffset = circumference - (circumference * percentage) / 100; let strokeColor = tile.color || cssManager.bdTheme('hsl(215.3 25% 28.8%)', 'hsl(210 40% 78%)'); if (options.thresholds) { const sortedThresholds = [...options.thresholds].sort((a, b) => b.value - a.value); for (const threshold of sortedThresholds) { if (value >= threshold.value) { strokeColor = threshold.color; break; } } } return html`
${value}${tile.unit ? html`${tile.unit}` : ''}
`; } private renderPercentage(tile: IStatsTile): TemplateResult { const value = typeof tile.value === 'number' ? tile.value : parseFloat(tile.value); const percentage = Math.min(100, Math.max(0, value)); return html`
${percentage}%
`; } private renderTrend(tile: IStatsTile): TemplateResult { if (!tile.trendData || tile.trendData.length < 2) { return html`
${tile.value}
`; } const data = tile.trendData; const max = Math.max(...data); const min = Math.min(...data); const range = max - min || 1; const width = 300; const height = 32; // Add padding to prevent clipping const padding = 2; const points = data.map((value, index) => { const x = (index / (data.length - 1)) * width; const y = padding + (height - 2 * padding) - ((value - min) / range) * (height - 2 * padding); return `${x},${y}`; }).join(' '); const areaPoints = `0,${height} ${points} ${width},${height}`; return html`
${tile.value} ${tile.unit ? html`${tile.unit}` : ''} ${tile.description ? html`${tile.description}` : ''}
`; } private async handleGridAction(action: plugins.tsclass.website.IMenuItem) { if (action.action) { await action.action(); } } private async handleTileAction(action: plugins.tsclass.website.IMenuItem, _tile: IStatsTile) { if (action.action) { await action.action(); } // Note: tile data is available through closure when defining actions } private showContextMenu(event: MouseEvent, tile: IStatsTile) { if (!tile.actions || tile.actions.length === 0) return; event.preventDefault(); this.contextMenuPosition = { x: event.clientX, y: event.clientY }; this.contextMenuActions = tile.actions; this.contextMenuVisible = true; // Close context menu on click outside const closeHandler = () => { this.contextMenuVisible = false; document.removeEventListener('click', closeHandler); }; setTimeout(() => { document.addEventListener('click', closeHandler); }, 100); } }