fix(elements/applauncher): add eco app launcher components, wifi/sound/battery menus, demos and new eco-screensaver; replace dees-screensaver (breaking API change)
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesIcon } from '@design.estate/dees-catalog';
|
||||
import { demo } from './eco-applauncher-soundmenu.demo.js';
|
||||
|
||||
// Ensure dees-icon is registered
|
||||
DeesIcon;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'eco-applauncher-soundmenu': EcoApplauncherSoundmenu;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAudioDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'speaker' | 'headphones' | 'bluetooth' | 'hdmi';
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
@customElement('eco-applauncher-soundmenu')
|
||||
export class EcoApplauncherSoundmenu extends DeesElement {
|
||||
public static demo = demo;
|
||||
public static demoGroup = 'App Launcher';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 10%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
|
||||
border-radius: 12px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 8px 32px rgba(0, 0, 0, 0.15)',
|
||||
'0 8px 32px rgba(0, 0, 0, 0.4)'
|
||||
)};
|
||||
min-width: 280px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-8px);
|
||||
transition: all 0.2s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:host([open]) .menu-container {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.volume-section {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.volume-slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.volume-icon {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.volume-icon:hover {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.volume-icon.muted {
|
||||
color: hsl(0 72% 51%);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(240 5% 20%)')};
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-fill {
|
||||
height: 100%;
|
||||
background: hsl(217 91% 60%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.volume-fill.muted {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 40%)')};
|
||||
}
|
||||
|
||||
.volume-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.volume-thumb:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.volume-percentage {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 12px 16px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.device-item:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
|
||||
}
|
||||
|
||||
.device-item.active {
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(217 91% 60% / 0.15)')};
|
||||
}
|
||||
|
||||
.device-item.active:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 92%)', 'hsl(217 91% 60% / 0.25)')};
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||
}
|
||||
|
||||
.device-item.active .device-icon {
|
||||
color: hsl(217 91% 60%);
|
||||
}
|
||||
|
||||
.device-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.device-check {
|
||||
color: hsl(217 91% 60%);
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
|
||||
}
|
||||
|
||||
.settings-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: hsl(217 91% 60%);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-link:hover {
|
||||
color: hsl(217 91% 50%);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
accessor open = false;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor volume = 50;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor muted = false;
|
||||
|
||||
@property({ type: Array })
|
||||
accessor outputDevices: IAudioDevice[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
accessor activeDeviceId: string | null = null;
|
||||
|
||||
private boundHandleClickOutside = this.handleClickOutside.bind(this);
|
||||
private isDragging = false;
|
||||
|
||||
public render(): TemplateResult {
|
||||
const volumeIcon = this.getVolumeIcon();
|
||||
|
||||
return html`
|
||||
<div class="menu-container">
|
||||
<div class="menu-header">
|
||||
<span class="menu-title">
|
||||
<dees-icon .icon=${volumeIcon} .iconSize=${18}></dees-icon>
|
||||
Sound
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="volume-section">
|
||||
<div class="volume-slider-container">
|
||||
<dees-icon
|
||||
class="volume-icon ${this.muted ? 'muted' : ''}"
|
||||
.icon=${this.muted ? 'lucide:volumeX' : 'lucide:volume2'}
|
||||
.iconSize=${20}
|
||||
@click=${this.handleMuteToggle}
|
||||
></dees-icon>
|
||||
<div
|
||||
class="volume-slider"
|
||||
@click=${this.handleSliderClick}
|
||||
@mousedown=${this.handleSliderMouseDown}
|
||||
>
|
||||
<div
|
||||
class="volume-fill ${this.muted ? 'muted' : ''}"
|
||||
style="width: ${this.muted ? 0 : this.volume}%"
|
||||
></div>
|
||||
<div
|
||||
class="volume-thumb"
|
||||
style="left: ${this.muted ? 0 : this.volume}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="volume-percentage">${this.muted ? 0 : this.volume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.outputDevices.length > 0 ? html`
|
||||
<div class="menu-divider"></div>
|
||||
<div class="section-title">Output</div>
|
||||
<div class="device-list">
|
||||
${this.outputDevices.map((device) => this.renderDeviceItem(device))}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="menu-footer">
|
||||
<div class="settings-link" @click=${this.handleSettingsClick}>
|
||||
<dees-icon .icon=${'lucide:settings'} .iconSize=${14}></dees-icon>
|
||||
Sound Settings...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDeviceItem(device: IAudioDevice): TemplateResult {
|
||||
const isActive = device.id === this.activeDeviceId;
|
||||
const icon = this.getDeviceIcon(device.type);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="device-item ${isActive ? 'active' : ''}"
|
||||
@click=${() => this.handleDeviceSelect(device)}
|
||||
>
|
||||
<dees-icon class="device-icon" .icon=${icon} .iconSize=${18}></dees-icon>
|
||||
<span class="device-name">${device.name}</span>
|
||||
${isActive ? html`
|
||||
<dees-icon class="device-check" .icon=${'lucide:check'} .iconSize=${16}></dees-icon>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getVolumeIcon(): string {
|
||||
if (this.muted || this.volume === 0) return 'lucide:volumeX';
|
||||
if (this.volume < 33) return 'lucide:volume';
|
||||
if (this.volume < 66) return 'lucide:volume1';
|
||||
return 'lucide:volume2';
|
||||
}
|
||||
|
||||
private getDeviceIcon(type: IAudioDevice['type']): string {
|
||||
switch (type) {
|
||||
case 'headphones':
|
||||
return 'lucide:headphones';
|
||||
case 'bluetooth':
|
||||
return 'lucide:bluetooth';
|
||||
case 'hdmi':
|
||||
return 'lucide:monitor';
|
||||
case 'speaker':
|
||||
default:
|
||||
return 'lucide:speaker';
|
||||
}
|
||||
}
|
||||
|
||||
private handleMuteToggle(): void {
|
||||
this.muted = !this.muted;
|
||||
this.dispatchEvent(new CustomEvent('mute-toggle', {
|
||||
detail: { muted: this.muted },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleSliderClick(e: MouseEvent): void {
|
||||
const slider = e.currentTarget as HTMLElement;
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const percentage = Math.round(((e.clientX - rect.left) / rect.width) * 100);
|
||||
this.setVolume(Math.max(0, Math.min(100, percentage)));
|
||||
}
|
||||
|
||||
private handleSliderMouseDown(e: MouseEvent): void {
|
||||
this.isDragging = true;
|
||||
const slider = e.currentTarget as HTMLElement;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!this.isDragging) return;
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const percentage = Math.round(((moveEvent.clientX - rect.left) / rect.width) * 100);
|
||||
this.setVolume(Math.max(0, Math.min(100, percentage)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
private setVolume(value: number): void {
|
||||
this.volume = value;
|
||||
if (this.muted && value > 0) {
|
||||
this.muted = false;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('volume-change', {
|
||||
detail: { volume: this.volume },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleDeviceSelect(device: IAudioDevice): void {
|
||||
this.activeDeviceId = device.id;
|
||||
this.dispatchEvent(new CustomEvent('device-select', {
|
||||
detail: { device },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleSettingsClick(): void {
|
||||
this.dispatchEvent(new CustomEvent('settings-click', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleClickOutside(e: MouseEvent): void {
|
||||
if (this.open && !this.contains(e.target as Node)) {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new CustomEvent('menu-close', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.boundHandleClickOutside);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.boundHandleClickOutside);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user