feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies

This commit is contained in:
2026-01-27 10:57:42 +00:00
parent 8158b791c7
commit 162688cdb5
218 changed files with 5223 additions and 458 deletions

View File

@@ -0,0 +1,344 @@
import {
property,
state,
html,
customElement,
css,
cssManager,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-tile-audio': DeesTileAudio;
}
}
@customElement('dees-tile-audio')
export class DeesTileAudio extends DeesTileBase {
public static demo = demo;
public static demoGroups = ['Media'];
public static styles = [
...tileBaseStyles,
css`
.audio-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: ${cssManager.bdTheme(
'linear-gradient(135deg, hsl(250 40% 96%), hsl(280 30% 94%))',
'linear-gradient(135deg, hsl(250 30% 16%), hsl(280 25% 14%))'
)};
}
.music-icon {
font-size: 48px;
color: ${cssManager.bdTheme('hsl(250 60% 65%)', 'hsl(250 60% 70%)')};
opacity: 0.8;
}
.audio-title {
font-size: 12px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(250 20% 35%)', 'hsl(250 20% 80%)')};
text-align: center;
padding: 0 16px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-artist {
font-size: 11px;
font-weight: 400;
color: ${cssManager.bdTheme('hsl(250 15% 50%)', 'hsl(250 15% 65%)')};
text-align: center;
padding: 0 16px;
margin-top: -12px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.waveform-container {
width: calc(100% - 32px);
height: 40px;
position: relative;
overflow: hidden;
}
.waveform-container canvas {
width: 100%;
height: 100%;
display: block;
}
.duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
padding: 3px 8px;
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.6)', 'hsl(0 0% 100% / 0.85)')};
color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')};
border-radius: 4px;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
backdrop-filter: blur(8px);
z-index: 10;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 18;
pointer-events: none;
}
.tile-container.clickable:hover .play-overlay {
opacity: 1;
}
.play-circle {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
}
.play-circle dees-icon {
font-size: 20px;
color: white;
}
`,
] as any;
@property({ type: String })
accessor src: string = '';
@property({ type: String })
accessor title: string = '';
@property({ type: String })
accessor artist: string = '';
@state()
accessor duration: number = 0;
@state()
accessor waveformData: number[] = [];
@state()
accessor waveformReady: boolean = false;
@state()
accessor isPreviewPlaying: boolean = false;
private audioElement: HTMLAudioElement | null = null;
private previewTimeout: ReturnType<typeof setTimeout> | null = null;
private hasLoadedWaveform: boolean = false;
protected renderTileContent(): TemplateResult {
return html`
<div class="audio-content">
<dees-icon class="music-icon" icon="lucide:Music"></dees-icon>
${this.title ? html`<div class="audio-title">${this.title}</div>` : ''}
${this.artist ? html`<div class="audio-artist">${this.artist}</div>` : ''}
${this.waveformReady ? html`
<div class="waveform-container">
<canvas></canvas>
</div>
` : ''}
</div>
${this.duration > 0 ? html`
<div class="duration-badge">${this.formatTime(this.duration)}</div>
` : ''}
<div class="play-overlay">
<div class="play-circle">
<dees-icon icon="lucide:Play"></dees-icon>
</div>
</div>
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:Headphones"></dees-icon>
<span>Play Audio</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
src: this.src,
title: this.title,
artist: this.artist,
duration: this.duration,
};
}
protected onBecameVisible(): void {
if (!this.hasLoadedWaveform && this.src) {
this.hasLoadedWaveform = true;
this.loadAudioMeta();
}
}
private async loadAudioMeta(): Promise<void> {
this.loading = true;
try {
// Load duration via Audio element
const audio = new Audio();
audio.crossOrigin = 'anonymous';
audio.preload = 'metadata';
await new Promise<void>((resolve, reject) => {
audio.addEventListener('loadedmetadata', () => {
this.duration = audio.duration;
resolve();
}, { once: true });
audio.addEventListener('error', () => reject(new Error('Failed to load audio')), { once: true });
audio.src = this.src;
});
// Load waveform data
await this.loadWaveform();
this.loading = false;
} catch {
this.loading = false;
// Don't set error - audio may still be playable, just no waveform
}
}
private async loadWaveform(): Promise<void> {
try {
const response = await fetch(this.src);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const channelData = audioBuffer.getChannelData(0);
const bars = 80;
const blockSize = Math.floor(channelData.length / bars);
const waveform: number[] = [];
for (let i = 0; i < bars; i++) {
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(channelData[i * blockSize + j]);
}
waveform.push(sum / blockSize);
}
const max = Math.max(...waveform);
this.waveformData = waveform.map((v) => (max > 0 ? v / max : 0));
this.waveformReady = true;
await audioContext.close();
await this.updateComplete;
this.drawWaveform();
} catch {
this.waveformReady = false;
}
}
private drawWaveform(): void {
if (!this.waveformReady) return;
const canvas = this.shadowRoot?.querySelector('.waveform-container canvas') as HTMLCanvasElement;
if (!canvas) return;
const container = canvas.parentElement!;
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
const bars = this.waveformData.length;
if (bars === 0) return;
const barWidth = width / bars;
const isDark = document.body.classList.contains('theme-dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
const barColor = isDark ? 'hsl(250 50% 60%)' : 'hsl(250 50% 70%)';
ctx.fillStyle = barColor;
for (let i = 0; i < bars; i++) {
const amplitude = this.waveformData[i];
const barHeight = Math.max(2, amplitude * (height - 4));
const x = i * barWidth;
const y = (height - barHeight) / 2;
ctx.fillRect(x + 0.5, y, barWidth - 1, barHeight);
}
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
if (changedProperties.has('src') && this.src && this.isVisible) {
this.hasLoadedWaveform = true;
this.waveformReady = false;
this.duration = 0;
this.loadAudioMeta();
}
if (changedProperties.has('waveformReady') && this.waveformReady) {
await this.updateComplete;
this.drawWaveform();
}
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
}
if (this.audioElement) {
this.audioElement.pause();
this.audioElement.src = '';
this.audioElement = null;
}
}
private formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,77 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.tile-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: flex-start;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Audio Tiles</h3>
<div class="tile-row">
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="SoundHelix Song 1"
artist="T. Schuerger"
label="soundhelix-1.mp3"
@tile-click=${(e: CustomEvent) => console.log('Audio clicked:', e.detail)}
></dees-tile-audio>
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3"
title="SoundHelix Song 2"
artist="T. Schuerger"
label="soundhelix-2.mp3"
></dees-tile-audio>
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3"
title="SoundHelix Song 3"
label="soundhelix-3.mp3"
></dees-tile-audio>
</div>
</div>
<div class="demo-section">
<h3>Size Variants</h3>
<div class="tile-row">
<dees-tile-audio
size="small"
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="Small"
label="small.mp3"
></dees-tile-audio>
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="Default"
label="default.mp3"
></dees-tile-audio>
<dees-tile-audio
size="large"
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="Large"
label="large.mp3"
></dees-tile-audio>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';