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,506 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
property,
state,
cssManager,
} from '@design.estate/dees-element';
import '../../00group-utility/dees-icon/dees-icon.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-video-viewer': DeesVideoViewer;
}
}
@customElement('dees-video-viewer')
export class DeesVideoViewer extends DeesElement {
public static demo = demo;
public static demoGroups = ['Media'];
@property()
accessor src: string = '';
@property()
accessor poster: string = '';
@property({ type: Boolean })
accessor showControls: boolean = true;
@property({ type: Boolean })
accessor autoplay: boolean = false;
@property({ type: Boolean })
accessor loop: boolean = false;
@property({ type: Boolean })
accessor muted: boolean = false;
@state()
accessor isPlaying: boolean = false;
@state()
accessor currentTime: number = 0;
@state()
accessor duration: number = 0;
@state()
accessor volume: number = 1;
@state()
accessor loading: boolean = true;
@state()
accessor error: string = '';
@state()
accessor isFullscreen: boolean = false;
@state()
accessor controlsVisible: boolean = true;
private hideControlsTimer: ReturnType<typeof setTimeout> | null = null;
private videoElement: HTMLVideoElement | null = null;
public render(): TemplateResult {
return html`
<style>
:host {
display: block;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000000;
border-radius: 8px;
overflow: hidden;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
cursor: pointer;
}
.center-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease, transform 0.2s ease;
opacity: ${this.isPlaying ? 0 : 1};
pointer-events: ${this.isPlaying ? 'none' : 'auto'};
}
.center-play dees-icon {
font-size: 28px;
}
.center-play:hover {
background: rgba(0, 0, 0, 0.8);
transform: translate(-50%, -50%) scale(1.1);
}
.controls-bar {
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
padding: 24px 12px 12px;
display: flex;
flex-direction: column;
gap: 8px;
opacity: ${this.controlsVisible || !this.isPlaying ? 1 : 0};
transition: opacity 0.3s ease;
}
.seekbar-row {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.seekbar-row:hover {
height: 6px;
}
.seekbar-progress {
height: 100%;
background: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
border-radius: 2px;
pointer-events: none;
}
.controls-row {
display: flex;
align-items: center;
gap: 8px;
}
.ctrl-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #ffffff;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease;
flex-shrink: 0;
}
.ctrl-button dees-icon {
font-size: 16px;
}
.ctrl-button:hover {
background: rgba(255, 255, 255, 0.15);
}
.time-display {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.8);
user-select: none;
flex-shrink: 0;
}
.spacer {
flex: 1;
}
.volume-group {
display: flex;
align-items: center;
gap: 4px;
}
.volume-slider {
width: 60px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
border: none;
cursor: pointer;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.6);
}
.error-overlay dees-icon {
color: #f87171;
font-size: 32px;
}
.error-text {
font-size: 13px;
}
</style>
<div
class="video-container"
@mousemove=${this.handleMouseMove}
@mouseleave=${this.handleMouseLeave}
>
<video
.src=${this.src}
.poster=${this.poster}
.muted=${this.muted}
.loop=${this.loop}
?autoplay=${this.autoplay}
?controls=${!this.showControls}
playsinline
@loadedmetadata=${this.handleLoadedMetadata}
@play=${this.handlePlay}
@pause=${this.handlePause}
@ended=${this.handleEnded}
@timeupdate=${this.handleTimeUpdate}
@error=${this.handleError}
@waiting=${() => { this.loading = true; }}
@canplay=${() => { this.loading = false; }}
></video>
${this.showControls ? html`
<div class="overlay" @click=${this.handleOverlayClick}>
<div class="center-play">
<dees-icon icon="lucide:Play"></dees-icon>
</div>
<div class="controls-bar" @click=${(e: Event) => e.stopPropagation()}>
<div class="seekbar-row" @click=${this.handleSeek}>
<div class="seekbar-progress" style="width: ${this.duration ? (this.currentTime / this.duration) * 100 : 0}%"></div>
</div>
<div class="controls-row">
<button class="ctrl-button" @click=${this.togglePlay}>
<dees-icon icon="lucide:${this.isPlaying ? 'Pause' : 'Play'}"></dees-icon>
</button>
<span class="time-display">
${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}
</span>
<span class="spacer"></span>
<div class="volume-group">
<button class="ctrl-button" @click=${this.toggleMute}>
<dees-icon icon="lucide:${this.muted || this.volume === 0 ? 'VolumeX' : this.volume < 0.5 ? 'Volume1' : 'Volume2'}"></dees-icon>
</button>
<input
class="volume-slider"
type="range"
min="0"
max="1"
step="0.01"
.value=${String(this.muted ? 0 : this.volume)}
@input=${this.handleVolumeChange}
/>
</div>
<button class="ctrl-button" @click=${this.toggleFullscreen} title="Fullscreen">
<dees-icon icon="lucide:${this.isFullscreen ? 'Minimize' : 'Maximize'}"></dees-icon>
</button>
</div>
</div>
</div>
` : ''}
${this.loading && !this.error ? html`
<div class="loading-overlay">
<div class="loading-spinner"></div>
</div>
` : ''}
${this.error ? html`
<div class="error-overlay">
<dees-icon icon="lucide:VideoOff"></dees-icon>
<span class="error-text">${this.error}</span>
</div>
` : ''}
</div>
`;
}
public async firstUpdated(): Promise<void> {
this.videoElement = this.shadowRoot?.querySelector('video') || null;
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
if (this.hideControlsTimer) {
clearTimeout(this.hideControlsTimer);
}
}
public play(): void {
this.videoElement?.play();
}
public pause(): void {
this.videoElement?.pause();
}
public togglePlay(): void {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
}
public seek(time: number): void {
if (this.videoElement) {
this.videoElement.currentTime = time;
}
}
public setVolume(v: number): void {
this.volume = Math.max(0, Math.min(1, v));
if (this.videoElement) {
this.videoElement.volume = this.volume;
}
}
public toggleFullscreen(): void {
const container = this.shadowRoot?.querySelector('.video-container') as HTMLElement;
if (!container) return;
if (this.isFullscreen) {
document.exitFullscreen?.();
} else {
container.requestFullscreen?.();
}
}
private handleLoadedMetadata(): void {
if (this.videoElement) {
this.duration = this.videoElement.duration;
this.loading = false;
}
}
private handlePlay(): void {
this.isPlaying = true;
this.scheduleHideControls();
}
private handlePause(): void {
this.isPlaying = false;
this.controlsVisible = true;
}
private handleEnded(): void {
this.isPlaying = false;
this.controlsVisible = true;
}
private handleTimeUpdate(): void {
if (this.videoElement) {
this.currentTime = this.videoElement.currentTime;
}
}
private handleError(): void {
this.error = 'Failed to load video';
this.loading = false;
}
private handleOverlayClick(): void {
this.togglePlay();
}
private handleSeek(e: MouseEvent): void {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
this.seek(ratio * this.duration);
}
private handleVolumeChange(e: Event): void {
const value = parseFloat((e.target as HTMLInputElement).value);
this.setVolume(value);
this.muted = value === 0;
}
private toggleMute(): void {
this.muted = !this.muted;
if (this.videoElement) {
this.videoElement.muted = this.muted;
}
}
private handleMouseMove(): void {
this.controlsVisible = true;
this.scheduleHideControls();
}
private handleMouseLeave(): void {
if (this.isPlaying) {
this.controlsVisible = false;
}
}
private scheduleHideControls(): void {
if (this.hideControlsTimer) {
clearTimeout(this.hideControlsTimer);
}
if (this.isPlaying) {
this.hideControlsTimer = setTimeout(() => {
this.controlsVisible = false;
}, 3000);
}
}
private handleFullscreenChange = (): void => {
this.isFullscreen = !!document.fullscreenElement;
};
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')}`;
}
}