Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 942896a3ef | |||
| 79f41a6001 | |||
| dcc3e18474 | |||
| 2dbc9e35c6 | |||
| 9d07d4ca88 |
BIN
.playwright-mcp/after-body-click.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
.playwright-mcp/after-pointer-fix.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
.playwright-mcp/applauncher-battery-menu-fixed.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
.playwright-mcp/applauncher-battery-menu-open.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
.playwright-mcp/applauncher-initial.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
.playwright-mcp/applauncher-keyboard-bright.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.playwright-mcp/applauncher-keyboard-open.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.playwright-mcp/applauncher-keyboard-toggle.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
.playwright-mcp/applauncher-keyboard-zindex.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.playwright-mcp/applauncher-menus.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/applauncher-topbar-bright.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.playwright-mcp/applauncher-topbar-dark.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.playwright-mcp/applauncher-wifi-menu-open.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
.playwright-mcp/applauncher-with-keyboard-toggle.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.playwright-mcp/battery-menu-open.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/battery-saver-toggled.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/eco-applauncher-bright.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
.playwright-mcp/eco-applauncher-dark.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
.playwright-mcp/eco-batterymenu-bright.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
.playwright-mcp/eco-batterymenu-dark.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
.playwright-mcp/eco-soundmenu-dark.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
.playwright-mcp/eco-wifimenu-bright.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
.playwright-mcp/eco-wifimenu-dark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
.playwright-mcp/menu-test.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/menus-after-fix.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
17
changelog.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-06 - 3.34.2 - fix(applauncher)
|
||||||
|
throttle inactivity timer resets in menus, optimize sound slider updates, and adjust keyboard layout/keys
|
||||||
|
|
||||||
|
- Add lastActivityTime and throttle resetInactivityTimer to only reset if 5+ seconds have passed in battery, sound, and wifi menus to reduce frequent resets from continuous input.
|
||||||
|
- Remove @mousemove listener in menu containers and rely on mousedown + throttled resets to lower event noise.
|
||||||
|
- Debounce slider mousemove handling in sound menu using requestAnimationFrame and pendingPercentage to batch setVolume calls and cancel RAF on mouseup, preventing excessive volume updates.
|
||||||
|
- Add up/down arrow keys to virtual keyboard, reduce space key width from 4 to 3, and add .key.wide-3 CSS class to support the new sizing.
|
||||||
|
|
||||||
|
## 2026-01-06 - 3.34.1 - fix(elements/applauncher)
|
||||||
|
add eco app launcher components, wifi/sound/battery menus, demos and new eco-screensaver; replace dees-screensaver (breaking API change)
|
||||||
|
|
||||||
|
- Add eco-applauncher group and subcomponents: eco-applauncher, eco-applauncher-wifimenu, eco-applauncher-soundmenu, eco-applauncher-batterymenu
|
||||||
|
- Add demos for applauncher, wifi menu, sound menu and battery menu; include mock data for networks/devices/apps
|
||||||
|
- Introduce eco-screensaver component and demo; remove legacy dees-screensaver implementation and export
|
||||||
|
- Update elements index and z-index key (replace 'dees-screensaver' with 'eco-screensaver')
|
||||||
|
- Menus dispatch events and include interactive behavior: wifi-toggle, network-select, volume-change, device-select, battery-saver-toggle, settings-click, menu-close
|
||||||
|
|
||||||
## 2026-01-06 - 3.34.0 - feat(dees-screensaver)
|
## 2026-01-06 - 3.34.0 - feat(dees-screensaver)
|
||||||
improve screensaver activation, visuals, and dismissal animations
|
improve screensaver activation, visuals, and dismissal animations
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ecobridge.xyz/catalog",
|
"name": "@ecobridge.xyz/catalog",
|
||||||
"version": "3.34.0",
|
"version": "3.34.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||||
"main": "dist_ts_web/index.js",
|
"main": "dist_ts_web/index.js",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@ecobridge.xyz/catalog',
|
name: '@ecobridge.xyz/catalog',
|
||||||
version: '3.34.0',
|
version: '3.34.2',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
padding: 48px;
|
||||||
|
background: hsl(240 10% 4%);
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<eco-applauncher-batterymenu
|
||||||
|
open
|
||||||
|
.batteryLevel=${85}
|
||||||
|
.isCharging=${false}
|
||||||
|
.batterySaverEnabled=${false}
|
||||||
|
.timeRemaining=${'2h 30m remaining'}
|
||||||
|
@battery-saver-toggle=${(e: CustomEvent) => console.log('Battery saver:', e.detail)}
|
||||||
|
@settings-click=${() => console.log('Settings clicked')}
|
||||||
|
></eco-applauncher-batterymenu>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
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-batterymenu.demo.js';
|
||||||
|
|
||||||
|
// Ensure dees-icon is registered
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-applauncher-batterymenu': EcoApplauncherBatterymenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('eco-applauncher-batterymenu')
|
||||||
|
export class EcoApplauncherBatterymenu extends DeesElement {
|
||||||
|
public static demo = demo;
|
||||||
|
public static demoGroup = 'App Launcher';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-display {
|
||||||
|
padding: 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-visual {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 70%)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 70%)')};
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-fill {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-fill.good {
|
||||||
|
background: hsl(142 71% 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-fill.medium {
|
||||||
|
background: hsl(47 100% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-fill.low {
|
||||||
|
background: hsl(0 72% 51%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-fill.charging {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
hsl(142 71% 45%) 0%,
|
||||||
|
hsl(142 71% 55%) 50%,
|
||||||
|
hsl(142 71% 45%) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: charging-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes charging-pulse {
|
||||||
|
0% { background-position: 100% 0; }
|
||||||
|
100% { background-position: -100% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.charging-icon {
|
||||||
|
color: hsl(47 100% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-percentage {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-status {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-option:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.active {
|
||||||
|
background: hsl(217 91% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
box-shadow: ${cssManager.bdTheme('0 1px 3px rgba(0,0,0,0.2)', 'none')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.active::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 batteryLevel = 100;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
accessor isCharging = false;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
accessor batterySaverEnabled = false;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor timeRemaining: string | null = null;
|
||||||
|
|
||||||
|
private boundHandleClickOutside = this.handleClickOutside.bind(this);
|
||||||
|
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
|
||||||
|
private lastActivityTime = 0;
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const fillClass = this.getFillClass();
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="menu-container"
|
||||||
|
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||||
|
@mousedown=${this.resetInactivityTimer}
|
||||||
|
>
|
||||||
|
<div class="battery-display">
|
||||||
|
<div class="battery-visual">
|
||||||
|
<div class="battery-icon">
|
||||||
|
<div
|
||||||
|
class="battery-fill ${fillClass} ${this.isCharging ? 'charging' : ''}"
|
||||||
|
style="width: ${this.batteryLevel}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
${this.isCharging ? html`
|
||||||
|
<dees-icon class="charging-icon" .icon=${'lucide:zap'} .iconSize=${24}></dees-icon>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="battery-percentage">${this.batteryLevel}%</div>
|
||||||
|
<div class="battery-status">
|
||||||
|
${this.isCharging ? html`
|
||||||
|
<dees-icon .icon=${'lucide:plug'} .iconSize=${14}></dees-icon>
|
||||||
|
Charging
|
||||||
|
` : this.timeRemaining ? html`
|
||||||
|
<dees-icon .icon=${'lucide:clock'} .iconSize=${14}></dees-icon>
|
||||||
|
${this.timeRemaining}
|
||||||
|
` : html`
|
||||||
|
<dees-icon .icon=${'lucide:battery'} .iconSize=${14}></dees-icon>
|
||||||
|
On Battery
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
|
<div class="menu-option" @click=${this.handleBatterySaverToggle}>
|
||||||
|
<div class="option-label">
|
||||||
|
<dees-icon .icon=${'lucide:leaf'} .iconSize=${18}></dees-icon>
|
||||||
|
<div>
|
||||||
|
<div>Battery Saver</div>
|
||||||
|
<div class="option-description">Extends battery life</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="toggle-switch ${this.batterySaverEnabled ? 'active' : ''}"
|
||||||
|
@click=${(e: Event) => e.stopPropagation()}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-footer">
|
||||||
|
<div class="settings-link" @click=${this.handleSettingsClick}>
|
||||||
|
<dees-icon .icon=${'lucide:settings'} .iconSize=${14}></dees-icon>
|
||||||
|
Power Settings...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFillClass(): string {
|
||||||
|
if (this.batteryLevel > 50) return 'good';
|
||||||
|
if (this.batteryLevel > 20) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBatterySaverToggle(): void {
|
||||||
|
this.batterySaverEnabled = !this.batterySaverEnabled;
|
||||||
|
this.dispatchEvent(new CustomEvent('battery-saver-toggle', {
|
||||||
|
detail: { enabled: this.batterySaverEnabled },
|
||||||
|
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.closeMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetInactivityTimer(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
// Throttle: only reset if 5+ seconds since last reset
|
||||||
|
if (now - this.lastActivityTime < 5000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastActivityTime = now;
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
if (this.open) {
|
||||||
|
this.inactivityTimeout = setTimeout(() => {
|
||||||
|
this.closeMenu();
|
||||||
|
}, this.INACTIVITY_TIMEOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInactivityTimer(): void {
|
||||||
|
if (this.inactivityTimeout) {
|
||||||
|
clearTimeout(this.inactivityTimeout);
|
||||||
|
this.inactivityTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeMenu(): void {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('menu-close', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updated(changedProperties: Map<string, unknown>): void {
|
||||||
|
if (changedProperties.has('open')) {
|
||||||
|
if (this.open) {
|
||||||
|
this.resetInactivityTimer();
|
||||||
|
} else {
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-batterymenu.js';
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
padding: 24px;
|
||||||
|
background: hsl(240 10% 4%);
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.output-area {
|
||||||
|
background: hsl(240 5% 12%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 100px;
|
||||||
|
color: hsl(0 0% 95%);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.output-label {
|
||||||
|
color: hsl(0 0% 60%);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.keyboard-wrapper {
|
||||||
|
height: 260px;
|
||||||
|
}
|
||||||
|
.event-log {
|
||||||
|
background: hsl(240 5% 8%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
color: hsl(0 0% 70%);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<div>
|
||||||
|
<div class="output-label">Typed text:</div>
|
||||||
|
<div class="output-area" id="typed-output">|</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="output-label">Event log:</div>
|
||||||
|
<div class="event-log" id="event-log"></div>
|
||||||
|
</div>
|
||||||
|
<div class="keyboard-wrapper">
|
||||||
|
<eco-applauncher-keyboard
|
||||||
|
visible
|
||||||
|
@key-press=${(e: CustomEvent) => {
|
||||||
|
const output = document.getElementById('typed-output');
|
||||||
|
const log = document.getElementById('event-log');
|
||||||
|
if (output && log) {
|
||||||
|
const currentText = output.textContent?.replace('|', '') || '';
|
||||||
|
if (e.detail.type === 'special') {
|
||||||
|
if (e.detail.key === 'Backspace') {
|
||||||
|
output.textContent = currentText.slice(0, -1) + '|';
|
||||||
|
} else if (e.detail.key === 'Enter') {
|
||||||
|
output.textContent = currentText + '\n|';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.textContent = currentText + e.detail.key + '|';
|
||||||
|
}
|
||||||
|
log.textContent = `key-press: ${JSON.stringify(e.detail)}\n` + log.textContent;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@backspace=${() => {
|
||||||
|
const log = document.getElementById('event-log');
|
||||||
|
if (log) {
|
||||||
|
log.textContent = `backspace\n` + log.textContent;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@enter=${() => {
|
||||||
|
const log = document.getElementById('event-log');
|
||||||
|
if (log) {
|
||||||
|
log.textContent = `enter\n` + log.textContent;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@space=${() => {
|
||||||
|
const log = document.getElementById('event-log');
|
||||||
|
if (log) {
|
||||||
|
log.textContent = `space\n` + log.textContent;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@arrow=${(e: CustomEvent) => {
|
||||||
|
const log = document.getElementById('event-log');
|
||||||
|
if (log) {
|
||||||
|
log.textContent = `arrow: ${e.detail.direction}\n` + log.textContent;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></eco-applauncher-keyboard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
type TemplateResult,
|
||||||
|
html,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
state,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesIcon } from '@design.estate/dees-catalog';
|
||||||
|
import { demo } from './eco-applauncher-keyboard.demo.js';
|
||||||
|
|
||||||
|
// Ensure components are registered
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-applauncher-keyboard': EcoApplauncherKeyboard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TKeyboardLayout = 'qwerty' | 'numbers' | 'symbols';
|
||||||
|
|
||||||
|
export interface IKeyConfig {
|
||||||
|
key: string;
|
||||||
|
display?: string;
|
||||||
|
width?: number; // multiplier, default 1
|
||||||
|
type?: 'char' | 'special' | 'modifier' | 'space' | 'layout';
|
||||||
|
action?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-press alternatives map
|
||||||
|
const alternativesMap: Record<string, string[]> = {
|
||||||
|
'a': ['à', 'á', 'â', 'ä', 'æ', 'ã', 'å', 'ā'],
|
||||||
|
'c': ['ç', 'ć', 'č'],
|
||||||
|
'e': ['è', 'é', 'ê', 'ë', 'ē', 'ė', 'ę'],
|
||||||
|
'i': ['î', 'ï', 'í', 'ī', 'į', 'ì'],
|
||||||
|
'n': ['ñ', 'ń'],
|
||||||
|
'o': ['ô', 'ö', 'ò', 'ó', 'œ', 'ø', 'ō', 'õ'],
|
||||||
|
's': ['ß', 'ś', 'š'],
|
||||||
|
'u': ['û', 'ü', 'ù', 'ú', 'ū'],
|
||||||
|
'y': ['ÿ'],
|
||||||
|
'z': ['ž', 'ź', 'ż'],
|
||||||
|
// Numbers
|
||||||
|
'0': ['°', '⁰'],
|
||||||
|
'1': ['¹', '½', '⅓'],
|
||||||
|
'2': ['²', '⅔'],
|
||||||
|
'3': ['³', '¾'],
|
||||||
|
// Punctuation
|
||||||
|
'-': ['–', '—', '•'],
|
||||||
|
'/': ['\\'],
|
||||||
|
'$': ['€', '£', '¥', '¢'],
|
||||||
|
'&': ['§'],
|
||||||
|
'"': ['"', '"', '«', '»'],
|
||||||
|
'.': ['…'],
|
||||||
|
'?': ['¿'],
|
||||||
|
'!': ['¡'],
|
||||||
|
"'": ['\u2018', '\u2019', '`'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard layouts
|
||||||
|
const qwertyLayout: IKeyConfig[][] = [
|
||||||
|
[
|
||||||
|
{ key: 'q' }, { key: 'w' }, { key: 'e' }, { key: 'r' }, { key: 't' },
|
||||||
|
{ key: 'y' }, { key: 'u' }, { key: 'i' }, { key: 'o' }, { key: 'p' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'a' }, { key: 's' }, { key: 'd' }, { key: 'f' }, { key: 'g' },
|
||||||
|
{ key: 'h' }, { key: 'j' }, { key: 'k' }, { key: 'l' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'shift', display: '⇧', width: 1.5, type: 'modifier' },
|
||||||
|
{ key: 'z' }, { key: 'x' }, { key: 'c' }, { key: 'v' },
|
||||||
|
{ key: 'b' }, { key: 'n' }, { key: 'm' },
|
||||||
|
{ key: 'backspace', display: '⌫', width: 1.5, type: 'special' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' },
|
||||||
|
{ key: 'globe', display: '🌐', type: 'special' },
|
||||||
|
{ key: 'space', display: '', width: 3, type: 'space' },
|
||||||
|
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
|
||||||
|
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
|
||||||
|
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
|
||||||
|
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
|
||||||
|
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const numbersLayout: IKeyConfig[][] = [
|
||||||
|
[
|
||||||
|
{ key: '1' }, { key: '2' }, { key: '3' }, { key: '4' }, { key: '5' },
|
||||||
|
{ key: '6' }, { key: '7' }, { key: '8' }, { key: '9' }, { key: '0' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: '-' }, { key: '/' }, { key: ':' }, { key: ';' }, { key: '(' },
|
||||||
|
{ key: ')' }, { key: '$' }, { key: '&' }, { key: '@' }, { key: '"' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: '#+=', display: '#+=' , width: 1.5, type: 'layout', action: 'symbols' },
|
||||||
|
{ key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" },
|
||||||
|
{ key: 'backspace', display: '⌫', width: 2.5, type: 'special' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' },
|
||||||
|
{ key: 'globe', display: '🌐', type: 'special' },
|
||||||
|
{ key: 'space', display: '', width: 3, type: 'space' },
|
||||||
|
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
|
||||||
|
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
|
||||||
|
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
|
||||||
|
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
|
||||||
|
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const symbolsLayout: IKeyConfig[][] = [
|
||||||
|
[
|
||||||
|
{ key: '[' }, { key: ']' }, { key: '{' }, { key: '}' }, { key: '#' },
|
||||||
|
{ key: '%' }, { key: '^' }, { key: '*' }, { key: '+' }, { key: '=' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: '_' }, { key: '\\' }, { key: '|' }, { key: '~' }, { key: '<' },
|
||||||
|
{ key: '>' }, { key: '€' }, { key: '£' }, { key: '¥' }, { key: '•' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' },
|
||||||
|
{ key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" },
|
||||||
|
{ key: 'backspace', display: '⌫', width: 2.5, type: 'special' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' },
|
||||||
|
{ key: 'globe', display: '🌐', type: 'special' },
|
||||||
|
{ key: 'space', display: '', width: 3, type: 'space' },
|
||||||
|
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
|
||||||
|
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
|
||||||
|
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
|
||||||
|
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
|
||||||
|
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const layouts: Record<TKeyboardLayout, IKeyConfig[][]> = {
|
||||||
|
qwerty: qwertyLayout,
|
||||||
|
numbers: numbersLayout,
|
||||||
|
symbols: symbolsLayout,
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement('eco-applauncher-keyboard')
|
||||||
|
export class EcoApplauncherKeyboard extends DeesElement {
|
||||||
|
public static demo = demo;
|
||||||
|
public static demoGroup = 'App Launcher';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(:not([visible])) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-container {
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 6% 12%)')};
|
||||||
|
padding: 8px 4px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-row.offset {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 40px;
|
||||||
|
height: 100%;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.1s ease, transform 0.05s ease;
|
||||||
|
box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(0, 0, 0, 0.4)')};
|
||||||
|
text-transform: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key:active,
|
||||||
|
.key.pressed {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 5% 28%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.special {
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.modifier {
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.modifier.active {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.layout {
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.space {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.wide-1-5 {
|
||||||
|
flex: 1.5;
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.wide-2 {
|
||||||
|
flex: 2;
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.wide-2-5 {
|
||||||
|
flex: 2.5;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.wide-3 {
|
||||||
|
flex: 3;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.wide-4 {
|
||||||
|
flex: 4;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternatives popup */
|
||||||
|
.alternatives-popup {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 6% 18%)')};
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: 0 4px 20px ${cssManager.bdTheme('rgba(0, 0, 0, 0.25)', 'rgba(0, 0, 0, 0.6)')};
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alternative-key {
|
||||||
|
min-width: 36px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alternative-key.selected {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key preview on press */
|
||||||
|
.key-preview {
|
||||||
|
position: absolute;
|
||||||
|
width: 48px;
|
||||||
|
height: 56px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
||||||
|
box-shadow: 0 4px 16px ${cssManager.bdTheme('rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)')};
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
accessor visible = false;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor layout: TKeyboardLayout = 'qwerty';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor shiftActive = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor capsLock = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor alternativesPopup: {
|
||||||
|
key: string;
|
||||||
|
alternatives: string[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
selectedIndex: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor keyPreview: { key: string; x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
private longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private longPressStartX = 0;
|
||||||
|
private currentLongPressKey: string | null = null;
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const currentLayout = layouts[this.layout];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="keyboard-container">
|
||||||
|
${currentLayout.map((row, rowIndex) => this.renderRow(row, rowIndex))}
|
||||||
|
${this.alternativesPopup ? this.renderAlternativesPopup() : ''}
|
||||||
|
${this.keyPreview ? this.renderKeyPreview() : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRow(row: IKeyConfig[], rowIndex: number): TemplateResult {
|
||||||
|
const isSecondRow = rowIndex === 1 && this.layout === 'qwerty';
|
||||||
|
return html`
|
||||||
|
<div class="keyboard-row ${isSecondRow ? 'offset' : ''}">
|
||||||
|
${row.map((keyConfig) => this.renderKey(keyConfig))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderKey(config: IKeyConfig): TemplateResult {
|
||||||
|
const type = config.type || 'char';
|
||||||
|
const widthClass = config.width ? `wide-${String(config.width).replace('.', '-')}` : '';
|
||||||
|
const isShift = config.key === 'shift';
|
||||||
|
const isActive = isShift && (this.shiftActive || this.capsLock);
|
||||||
|
|
||||||
|
let displayValue = config.display ?? config.key;
|
||||||
|
if (type === 'char' && this.layout === 'qwerty') {
|
||||||
|
displayValue = (this.shiftActive || this.capsLock) ? displayValue.toUpperCase() : displayValue.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="key ${type} ${widthClass} ${isActive ? 'active' : ''}"
|
||||||
|
@pointerdown=${(e: PointerEvent) => this.handlePointerDown(e, config)}
|
||||||
|
@pointerup=${(e: PointerEvent) => this.handlePointerUp(e, config)}
|
||||||
|
@pointerleave=${(e: PointerEvent) => this.handlePointerLeave(e, config)}
|
||||||
|
@pointermove=${(e: PointerEvent) => this.handlePointerMove(e, config)}
|
||||||
|
>
|
||||||
|
${displayValue}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderAlternativesPopup(): TemplateResult {
|
||||||
|
if (!this.alternativesPopup) return html``;
|
||||||
|
|
||||||
|
const { alternatives, x, y, selectedIndex } = this.alternativesPopup;
|
||||||
|
const popupWidth = alternatives.length * 40;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="alternatives-popup"
|
||||||
|
style="left: ${x - popupWidth / 2}px; top: ${y - 60}px;"
|
||||||
|
>
|
||||||
|
${alternatives.map((alt, index) => html`
|
||||||
|
<div class="alternative-key ${index === selectedIndex ? 'selected' : ''}">
|
||||||
|
${alt}
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderKeyPreview(): TemplateResult {
|
||||||
|
if (!this.keyPreview) return html``;
|
||||||
|
|
||||||
|
const { key, x, y } = this.keyPreview;
|
||||||
|
return html`
|
||||||
|
<div class="key-preview" style="left: ${x - 24}px; top: ${y - 70}px;">
|
||||||
|
${(this.shiftActive || this.capsLock) ? key.toUpperCase() : key}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePointerDown(e: PointerEvent, config: IKeyConfig): void {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = e.currentTarget as HTMLElement;
|
||||||
|
target.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
const type = config.type || 'char';
|
||||||
|
|
||||||
|
// Show key preview for character keys
|
||||||
|
if (type === 'char') {
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect();
|
||||||
|
this.keyPreview = {
|
||||||
|
key: config.key,
|
||||||
|
x: rect.left + rect.width / 2 - containerRect.left,
|
||||||
|
y: rect.top - containerRect.top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start long press timer for keys with alternatives
|
||||||
|
const keyLower = config.key.toLowerCase();
|
||||||
|
if (alternativesMap[keyLower] && type === 'char') {
|
||||||
|
this.longPressStartX = e.clientX;
|
||||||
|
this.currentLongPressKey = keyLower;
|
||||||
|
|
||||||
|
this.longPressTimer = setTimeout(() => {
|
||||||
|
const alternatives = alternativesMap[keyLower];
|
||||||
|
if (alternatives) {
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect();
|
||||||
|
this.alternativesPopup = {
|
||||||
|
key: keyLower,
|
||||||
|
alternatives,
|
||||||
|
x: rect.left + rect.width / 2 - containerRect.left,
|
||||||
|
y: rect.top - containerRect.top,
|
||||||
|
selectedIndex: -1,
|
||||||
|
};
|
||||||
|
this.keyPreview = null;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePointerMove(e: PointerEvent, config: IKeyConfig): void {
|
||||||
|
if (this.alternativesPopup) {
|
||||||
|
// Calculate which alternative is being hovered based on x position
|
||||||
|
const deltaX = e.clientX - this.longPressStartX;
|
||||||
|
const alternatives = this.alternativesPopup.alternatives;
|
||||||
|
const keyWidth = 40;
|
||||||
|
const totalWidth = alternatives.length * keyWidth;
|
||||||
|
const startX = -totalWidth / 2;
|
||||||
|
|
||||||
|
// Map deltaX to an index
|
||||||
|
const relativeX = deltaX - startX;
|
||||||
|
const index = Math.floor(relativeX / keyWidth);
|
||||||
|
const clampedIndex = Math.max(-1, Math.min(alternatives.length - 1, index));
|
||||||
|
|
||||||
|
if (clampedIndex !== this.alternativesPopup.selectedIndex) {
|
||||||
|
this.alternativesPopup = {
|
||||||
|
...this.alternativesPopup,
|
||||||
|
selectedIndex: clampedIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePointerUp(e: PointerEvent, config: IKeyConfig): void {
|
||||||
|
e.preventDefault();
|
||||||
|
this.clearLongPressTimer();
|
||||||
|
this.keyPreview = null;
|
||||||
|
|
||||||
|
const type = config.type || 'char';
|
||||||
|
|
||||||
|
// Handle alternatives selection
|
||||||
|
if (this.alternativesPopup) {
|
||||||
|
const { selectedIndex, alternatives, key } = this.alternativesPopup;
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < alternatives.length) {
|
||||||
|
this.emitKeyPress(alternatives[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
// No alternative selected, emit original key
|
||||||
|
this.emitKeyPress(key);
|
||||||
|
}
|
||||||
|
this.alternativesPopup = null;
|
||||||
|
this.handleShiftAfterKeyPress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different key types
|
||||||
|
switch (type) {
|
||||||
|
case 'char':
|
||||||
|
const char = (this.shiftActive || this.capsLock)
|
||||||
|
? config.key.toUpperCase()
|
||||||
|
: config.key.toLowerCase();
|
||||||
|
this.emitKeyPress(char);
|
||||||
|
this.handleShiftAfterKeyPress();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'special':
|
||||||
|
this.handleSpecialKey(config);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'modifier':
|
||||||
|
this.handleModifierKey(config);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'space':
|
||||||
|
this.dispatchEvent(new CustomEvent('space', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
this.dispatchEvent(new CustomEvent('key-press', {
|
||||||
|
detail: { key: ' ', type: 'space' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'layout':
|
||||||
|
this.handleLayoutChange(config);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePointerLeave(e: PointerEvent, config: IKeyConfig): void {
|
||||||
|
if (!this.alternativesPopup) {
|
||||||
|
this.clearLongPressTimer();
|
||||||
|
this.keyPreview = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearLongPressTimer(): void {
|
||||||
|
if (this.longPressTimer) {
|
||||||
|
clearTimeout(this.longPressTimer);
|
||||||
|
this.longPressTimer = null;
|
||||||
|
}
|
||||||
|
this.currentLongPressKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitKeyPress(key: string): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('key-press', {
|
||||||
|
detail: { key, type: 'char' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSpecialKey(config: IKeyConfig): void {
|
||||||
|
switch (config.key) {
|
||||||
|
case 'backspace':
|
||||||
|
this.dispatchEvent(new CustomEvent('backspace', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
this.dispatchEvent(new CustomEvent('key-press', {
|
||||||
|
detail: { key: 'Backspace', type: 'special' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'enter':
|
||||||
|
this.dispatchEvent(new CustomEvent('enter', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
this.dispatchEvent(new CustomEvent('key-press', {
|
||||||
|
detail: { key: 'Enter', type: 'special' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'left':
|
||||||
|
this.dispatchEvent(new CustomEvent('arrow', {
|
||||||
|
detail: { direction: 'left' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'right':
|
||||||
|
this.dispatchEvent(new CustomEvent('arrow', {
|
||||||
|
detail: { direction: 'right' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'up':
|
||||||
|
this.dispatchEvent(new CustomEvent('arrow', {
|
||||||
|
detail: { direction: 'up' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'down':
|
||||||
|
this.dispatchEvent(new CustomEvent('arrow', {
|
||||||
|
detail: { direction: 'down' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'globe':
|
||||||
|
// Could be used for language switching
|
||||||
|
this.dispatchEvent(new CustomEvent('globe-press', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleModifierKey(config: IKeyConfig): void {
|
||||||
|
if (config.key === 'shift') {
|
||||||
|
if (this.capsLock) {
|
||||||
|
// If caps lock is on, turn it off
|
||||||
|
this.capsLock = false;
|
||||||
|
this.shiftActive = false;
|
||||||
|
} else if (this.shiftActive) {
|
||||||
|
// Double tap shift = caps lock
|
||||||
|
this.capsLock = true;
|
||||||
|
this.shiftActive = false;
|
||||||
|
} else {
|
||||||
|
// Single tap = shift
|
||||||
|
this.shiftActive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleShiftAfterKeyPress(): void {
|
||||||
|
// Turn off shift after typing (unless caps lock is on)
|
||||||
|
if (this.shiftActive && !this.capsLock) {
|
||||||
|
this.shiftActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLayoutChange(config: IKeyConfig): void {
|
||||||
|
const action = config.action as TKeyboardLayout;
|
||||||
|
if (action && layouts[action]) {
|
||||||
|
this.layout = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-keyboard.js';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
import type { IAudioDevice } from './eco-applauncher-soundmenu.js';
|
||||||
|
|
||||||
|
const mockDevices: IAudioDevice[] = [
|
||||||
|
{ id: 'speakers', name: 'Built-in Speakers', type: 'speaker' },
|
||||||
|
{ id: 'headphones', name: 'AirPods Pro', type: 'bluetooth' },
|
||||||
|
{ id: 'hdmi', name: 'LG Monitor', type: 'hdmi' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
padding: 48px;
|
||||||
|
background: hsl(240 10% 4%);
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<eco-applauncher-soundmenu
|
||||||
|
open
|
||||||
|
.volume=${70}
|
||||||
|
.muted=${false}
|
||||||
|
.outputDevices=${mockDevices}
|
||||||
|
.activeDeviceId=${'speakers'}
|
||||||
|
@volume-change=${(e: CustomEvent) => console.log('Volume:', e.detail)}
|
||||||
|
@mute-toggle=${(e: CustomEvent) => console.log('Mute:', e.detail)}
|
||||||
|
@device-select=${(e: CustomEvent) => console.log('Device:', e.detail)}
|
||||||
|
@settings-click=${() => console.log('Settings clicked')}
|
||||||
|
></eco-applauncher-soundmenu>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
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;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
|
||||||
|
private lastActivityTime = 0;
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const volumeIcon = this.getVolumeIcon();
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="menu-container"
|
||||||
|
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||||
|
@mousedown=${this.resetInactivityTimer}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
let pendingPercentage: number | null = null;
|
||||||
|
|
||||||
|
const updateVolume = () => {
|
||||||
|
if (pendingPercentage !== null) {
|
||||||
|
this.setVolume(pendingPercentage);
|
||||||
|
pendingPercentage = null;
|
||||||
|
}
|
||||||
|
rafId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
const rect = slider.getBoundingClientRect();
|
||||||
|
pendingPercentage = Math.max(0, Math.min(100, Math.round(((moveEvent.clientX - rect.left) / rect.width) * 100)));
|
||||||
|
if (!rafId) {
|
||||||
|
rafId = requestAnimationFrame(updateVolume);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
this.isDragging = false;
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
if (pendingPercentage !== null) {
|
||||||
|
this.setVolume(pendingPercentage);
|
||||||
|
}
|
||||||
|
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.closeMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetInactivityTimer(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
// Throttle: only reset if 5+ seconds since last reset
|
||||||
|
if (now - this.lastActivityTime < 5000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastActivityTime = now;
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
if (this.open) {
|
||||||
|
this.inactivityTimeout = setTimeout(() => {
|
||||||
|
this.closeMenu();
|
||||||
|
}, this.INACTIVITY_TIMEOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInactivityTimer(): void {
|
||||||
|
if (this.inactivityTimeout) {
|
||||||
|
clearTimeout(this.inactivityTimeout);
|
||||||
|
this.inactivityTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeMenu(): void {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('menu-close', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updated(changedProperties: Map<string, unknown>): void {
|
||||||
|
if (changedProperties.has('open')) {
|
||||||
|
if (this.open) {
|
||||||
|
this.resetInactivityTimer();
|
||||||
|
} else {
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-soundmenu.js';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
import type { IWifiNetwork } from './eco-applauncher-wifimenu.js';
|
||||||
|
|
||||||
|
const mockNetworks: IWifiNetwork[] = [
|
||||||
|
{ ssid: 'HomeNetwork', signalStrength: 95, secured: true },
|
||||||
|
{ ssid: 'OfficeWiFi', signalStrength: 75, secured: true },
|
||||||
|
{ ssid: 'CoffeeShop_Guest', signalStrength: 60, secured: false },
|
||||||
|
{ ssid: 'Neighbor_5G', signalStrength: 40, secured: true },
|
||||||
|
{ ssid: 'WeakSignal', signalStrength: 15, secured: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
padding: 48px;
|
||||||
|
background: hsl(240 10% 4%);
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<eco-applauncher-wifimenu
|
||||||
|
open
|
||||||
|
.networks=${mockNetworks}
|
||||||
|
.connectedNetwork=${'HomeNetwork'}
|
||||||
|
.wifiEnabled=${true}
|
||||||
|
@wifi-toggle=${(e: CustomEvent) => console.log('WiFi toggle:', e.detail)}
|
||||||
|
@network-select=${(e: CustomEvent) => console.log('Network selected:', e.detail)}
|
||||||
|
@settings-click=${() => console.log('Settings clicked')}
|
||||||
|
></eco-applauncher-wifimenu>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
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-wifimenu.demo.js';
|
||||||
|
|
||||||
|
// Ensure dees-icon is registered
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-applauncher-wifimenu': EcoApplauncherWifimenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWifiNetwork {
|
||||||
|
ssid: string;
|
||||||
|
signalStrength: number; // 0-100
|
||||||
|
secured: boolean;
|
||||||
|
connected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('eco-applauncher-wifimenu')
|
||||||
|
export class EcoApplauncherWifimenu extends DeesElement {
|
||||||
|
public static demo = demo;
|
||||||
|
public static demoGroup = 'App Launcher';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.active {
|
||||||
|
background: hsl(217 91% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
box-shadow: ${cssManager.bdTheme('0 1px 3px rgba(0,0,0,0.2)', 'none')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.active::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-item:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-item.connected {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(217 91% 60% / 0.15)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-item.connected:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 92%)', 'hsl(217 91% 60% / 0.25)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 16px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-bar {
|
||||||
|
width: 4px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(0 0% 40%)')};
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-bar.active {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 90%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-bar:nth-child(1) { height: 4px; }
|
||||||
|
.signal-bar:nth-child(2) { height: 8px; }
|
||||||
|
.signal-bar:nth-child(3) { height: 12px; }
|
||||||
|
.signal-bar:nth-child(4) { height: 16px; }
|
||||||
|
|
||||||
|
.network-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-secured {
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-message {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
accessor open = false;
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
accessor networks: IWifiNetwork[] = [];
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor connectedNetwork: string | null = null;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
accessor wifiEnabled = true;
|
||||||
|
|
||||||
|
private boundHandleClickOutside = this.handleClickOutside.bind(this);
|
||||||
|
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
|
||||||
|
private lastActivityTime = 0;
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="menu-container"
|
||||||
|
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||||
|
@mousedown=${this.resetInactivityTimer}
|
||||||
|
>
|
||||||
|
<div class="menu-header">
|
||||||
|
<span class="menu-title">
|
||||||
|
<dees-icon .icon=${'lucide:wifi'} .iconSize=${18}></dees-icon>
|
||||||
|
Wi-Fi
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="toggle-switch ${this.wifiEnabled ? 'active' : ''}"
|
||||||
|
@click=${this.handleToggleWifi}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.wifiEnabled ? this.renderNetworkList() : this.renderDisabledMessage()}
|
||||||
|
|
||||||
|
<div class="menu-footer">
|
||||||
|
<div class="settings-link" @click=${this.handleSettingsClick}>
|
||||||
|
<dees-icon .icon=${'lucide:settings'} .iconSize=${14}></dees-icon>
|
||||||
|
Wi-Fi Settings...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderNetworkList(): TemplateResult {
|
||||||
|
const sortedNetworks = [...this.networks].sort((a, b) => {
|
||||||
|
// Connected network first, then by signal strength
|
||||||
|
if (a.ssid === this.connectedNetwork) return -1;
|
||||||
|
if (b.ssid === this.connectedNetwork) return 1;
|
||||||
|
return b.signalStrength - a.signalStrength;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="network-list">
|
||||||
|
${sortedNetworks.map((network) => this.renderNetworkItem(network))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderNetworkItem(network: IWifiNetwork): TemplateResult {
|
||||||
|
const isConnected = network.ssid === this.connectedNetwork;
|
||||||
|
const signalBars = this.getSignalBars(network.signalStrength);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="network-item ${isConnected ? 'connected' : ''}"
|
||||||
|
@click=${() => this.handleNetworkSelect(network)}
|
||||||
|
>
|
||||||
|
<div class="signal-bars">
|
||||||
|
${[1, 2, 3, 4].map((bar) => html`
|
||||||
|
<div class="signal-bar ${bar <= signalBars ? 'active' : ''}"></div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
<div class="network-info">
|
||||||
|
<div class="network-name">${network.ssid}</div>
|
||||||
|
${isConnected ? html`<div class="network-status">Connected</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${network.secured ? html`
|
||||||
|
<dees-icon class="network-secured" .icon=${'lucide:lock'} .iconSize=${14}></dees-icon>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDisabledMessage(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="disabled-message">
|
||||||
|
Wi-Fi is turned off
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSignalBars(strength: number): number {
|
||||||
|
if (strength >= 75) return 4;
|
||||||
|
if (strength >= 50) return 3;
|
||||||
|
if (strength >= 25) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToggleWifi(): void {
|
||||||
|
this.wifiEnabled = !this.wifiEnabled;
|
||||||
|
this.dispatchEvent(new CustomEvent('wifi-toggle', {
|
||||||
|
detail: { enabled: this.wifiEnabled },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleNetworkSelect(network: IWifiNetwork): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('network-select', {
|
||||||
|
detail: { network },
|
||||||
|
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.closeMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetInactivityTimer(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
// Throttle: only reset if 5+ seconds since last reset
|
||||||
|
if (now - this.lastActivityTime < 5000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastActivityTime = now;
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
if (this.open) {
|
||||||
|
this.inactivityTimeout = setTimeout(() => {
|
||||||
|
this.closeMenu();
|
||||||
|
}, this.INACTIVITY_TIMEOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInactivityTimer(): void {
|
||||||
|
if (this.inactivityTimeout) {
|
||||||
|
clearTimeout(this.inactivityTimeout);
|
||||||
|
this.inactivityTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeMenu(): void {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('menu-close', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updated(changedProperties: Map<string, unknown>): void {
|
||||||
|
if (changedProperties.has('open')) {
|
||||||
|
if (this.open) {
|
||||||
|
this.resetInactivityTimer();
|
||||||
|
} else {
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback(): Promise<void> {
|
||||||
|
await super.connectedCallback();
|
||||||
|
// Delay to prevent immediate close when clicking to open
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', this.boundHandleClickOutside);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectedCallback(): Promise<void> {
|
||||||
|
await super.disconnectedCallback();
|
||||||
|
document.removeEventListener('click', this.boundHandleClickOutside);
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-wifimenu.js';
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
import type { IAppIcon } from './eco-applauncher.js';
|
||||||
|
import type { IWifiNetwork } from '../eco-applauncher-wifimenu/index.js';
|
||||||
|
import type { IAudioDevice } from '../eco-applauncher-soundmenu/index.js';
|
||||||
|
|
||||||
|
const mockApps: IAppIcon[] = [
|
||||||
|
{ name: 'Settings', icon: 'lucide:settings', action: () => console.log('Settings clicked') },
|
||||||
|
{ name: 'Browser', icon: 'lucide:globe', action: () => console.log('Browser clicked') },
|
||||||
|
{ name: 'Terminal', icon: 'lucide:terminal', action: () => console.log('Terminal clicked') },
|
||||||
|
{ name: 'Files', icon: 'lucide:folder', action: () => console.log('Files clicked') },
|
||||||
|
{ name: 'Calendar', icon: 'lucide:calendar', action: () => console.log('Calendar clicked') },
|
||||||
|
{ name: 'Mail', icon: 'lucide:mail', action: () => console.log('Mail clicked') },
|
||||||
|
{ name: 'Music', icon: 'lucide:music', action: () => console.log('Music clicked') },
|
||||||
|
{ name: 'Photos', icon: 'lucide:image', action: () => console.log('Photos clicked') },
|
||||||
|
{ name: 'Notes', icon: 'lucide:fileText', action: () => console.log('Notes clicked') },
|
||||||
|
{ name: 'Calculator', icon: 'lucide:calculator', action: () => console.log('Calculator clicked') },
|
||||||
|
{ name: 'Weather', icon: 'lucide:cloudSun', action: () => console.log('Weather clicked') },
|
||||||
|
{ name: 'Maps', icon: 'lucide:map', action: () => console.log('Maps clicked') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockNetworks: IWifiNetwork[] = [
|
||||||
|
{ ssid: 'HomeNetwork', signalStrength: 95, secured: true },
|
||||||
|
{ ssid: 'OfficeWiFi', signalStrength: 75, secured: true },
|
||||||
|
{ ssid: 'CoffeeShop_Guest', signalStrength: 60, secured: false },
|
||||||
|
{ ssid: 'Neighbor_5G', signalStrength: 40, secured: true },
|
||||||
|
{ ssid: 'WeakSignal', signalStrength: 15, secured: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockAudioDevices: IAudioDevice[] = [
|
||||||
|
{ id: 'speakers', name: 'Built-in Speakers', type: 'speaker' },
|
||||||
|
{ id: 'headphones', name: 'AirPods Pro', type: 'bluetooth' },
|
||||||
|
{ id: 'hdmi', name: 'LG Monitor', type: 'hdmi' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<eco-applauncher
|
||||||
|
.apps=${mockApps}
|
||||||
|
.batteryLevel=${85}
|
||||||
|
.networkStatus=${'online'}
|
||||||
|
.soundLevel=${70}
|
||||||
|
.networks=${mockNetworks}
|
||||||
|
.connectedNetwork=${'HomeNetwork'}
|
||||||
|
.wifiEnabled=${true}
|
||||||
|
.timeRemaining=${'2h 30m remaining'}
|
||||||
|
.outputDevices=${mockAudioDevices}
|
||||||
|
.activeDeviceId=${'speakers'}
|
||||||
|
.muted=${false}
|
||||||
|
.userName=${'John Doe'}
|
||||||
|
.notificationCount=${3}
|
||||||
|
@wifi-toggle=${(e: CustomEvent) => console.log('WiFi toggle:', e.detail)}
|
||||||
|
@network-select=${(e: CustomEvent) => console.log('Network selected:', e.detail)}
|
||||||
|
@wifi-settings-click=${() => console.log('WiFi settings clicked')}
|
||||||
|
@battery-saver-toggle=${(e: CustomEvent) => console.log('Battery saver:', e.detail)}
|
||||||
|
@battery-settings-click=${() => console.log('Battery settings clicked')}
|
||||||
|
@volume-change=${(e: CustomEvent) => console.log('Volume:', e.detail)}
|
||||||
|
@mute-toggle=${(e: CustomEvent) => console.log('Mute:', e.detail)}
|
||||||
|
@device-select=${(e: CustomEvent) => console.log('Device:', e.detail)}
|
||||||
|
@sound-settings-click=${() => console.log('Sound settings clicked')}
|
||||||
|
@search-click=${() => console.log('Search clicked')}
|
||||||
|
@notifications-click=${() => console.log('Notifications clicked')}
|
||||||
|
@user-click=${() => console.log('User clicked')}
|
||||||
|
></eco-applauncher>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher.js';
|
||||||
6
ts_web/elements/00group-applauncher/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// App Launcher Components
|
||||||
|
export * from './eco-applauncher/index.js';
|
||||||
|
export * from './eco-applauncher-wifimenu/index.js';
|
||||||
|
export * from './eco-applauncher-batterymenu/index.js';
|
||||||
|
export * from './eco-applauncher-soundmenu/index.js';
|
||||||
|
export * from './eco-applauncher-keyboard/index.js';
|
||||||
@@ -46,5 +46,5 @@ export function getZIndex(category: keyof typeof zIndexLayers, subcategory?: str
|
|||||||
|
|
||||||
// Z-index assignments for components
|
// Z-index assignments for components
|
||||||
export const componentZIndex = {
|
export const componentZIndex = {
|
||||||
'dees-screensaver': zIndexLayers.overlay.screensaver,
|
'eco-screensaver': zIndexLayers.overlay.screensaver,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './dees-screensaver.js';
|
|
||||||
18
ts_web/elements/eco-screensaver/eco-screensaver.demo.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
import { EcoScreensaver } from './eco-screensaver.js';
|
||||||
|
|
||||||
|
export const demo = () => {
|
||||||
|
// Clean up any existing instance first
|
||||||
|
if (EcoScreensaver.instance) {
|
||||||
|
EcoScreensaver.instance.remove();
|
||||||
|
EcoScreensaver.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<eco-screensaver .delay=${2000}></eco-screensaver>
|
||||||
|
<div style="padding: 24px; color: #888;">
|
||||||
|
Screensaver activates after 2 seconds of inactivity.<br>
|
||||||
|
Move mouse or press keys to reset timer.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
state,
|
state,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import { zIndexLayers } from '../00zindex.js';
|
import { zIndexLayers } from '../00zindex.js';
|
||||||
|
import { demo } from './eco-screensaver.demo.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'dees-screensaver': DeesScreensaver;
|
'eco-screensaver': EcoScreensaver;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,56 +27,36 @@ const colors = [
|
|||||||
'hsl(142 71% 45%)', // green-500
|
'hsl(142 71% 45%)', // green-500
|
||||||
];
|
];
|
||||||
|
|
||||||
@customElement('dees-screensaver')
|
@customElement('eco-screensaver')
|
||||||
export class DeesScreensaver extends DeesElement {
|
export class EcoScreensaver extends DeesElement {
|
||||||
public static demo = () => {
|
public static demo = demo;
|
||||||
// Clean up any existing instance first
|
|
||||||
if (DeesScreensaver.instance) {
|
|
||||||
DeesScreensaver.instance.remove();
|
|
||||||
DeesScreensaver.instance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create screensaver element immediately but inactive
|
|
||||||
const screensaver = document.createElement('dees-screensaver') as DeesScreensaver;
|
|
||||||
document.body.appendChild(screensaver);
|
|
||||||
DeesScreensaver.instance = screensaver;
|
|
||||||
|
|
||||||
// Activate after 2 seconds to show the animation
|
|
||||||
setTimeout(() => {
|
|
||||||
if (DeesScreensaver.instance === screensaver) {
|
|
||||||
screensaver.active = true;
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return html`<div style="padding: 24px; color: #888;">Screensaver will activate in 2 seconds...</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Instance management
|
// Instance management
|
||||||
private static instance: DeesScreensaver | null = null;
|
public static instance: EcoScreensaver | null = null;
|
||||||
|
|
||||||
public static async show(): Promise<DeesScreensaver> {
|
public static async show(): Promise<EcoScreensaver> {
|
||||||
if (DeesScreensaver.instance) {
|
if (EcoScreensaver.instance) {
|
||||||
DeesScreensaver.instance.active = true;
|
EcoScreensaver.instance.active = true;
|
||||||
return DeesScreensaver.instance;
|
return EcoScreensaver.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
const screensaver = new DeesScreensaver();
|
const screensaver = new EcoScreensaver();
|
||||||
screensaver.active = true;
|
screensaver.active = true;
|
||||||
document.body.appendChild(screensaver);
|
document.body.appendChild(screensaver);
|
||||||
DeesScreensaver.instance = screensaver;
|
EcoScreensaver.instance = screensaver;
|
||||||
return screensaver;
|
return screensaver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static hide(): void {
|
public static hide(): void {
|
||||||
if (DeesScreensaver.instance) {
|
if (EcoScreensaver.instance) {
|
||||||
DeesScreensaver.instance.active = false;
|
EcoScreensaver.instance.active = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static destroy(): void {
|
public static destroy(): void {
|
||||||
if (DeesScreensaver.instance) {
|
if (EcoScreensaver.instance) {
|
||||||
DeesScreensaver.instance.remove();
|
EcoScreensaver.instance.remove();
|
||||||
DeesScreensaver.instance = null;
|
EcoScreensaver.instance = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +182,39 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: hsl(240 6% 15%);
|
||||||
|
border: 1px solid hsl(240 5% 26%);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: hsl(0 0% 90%);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@property({ type: Boolean, reflect: true })
|
@property({ type: Boolean, reflect: true })
|
||||||
accessor active = false;
|
accessor active = false;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor delay = 0; // milliseconds before activation (0 = no delay)
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor currentTime = '';
|
accessor currentTime = '';
|
||||||
|
|
||||||
@@ -230,10 +238,18 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
private timeContainerEl: HTMLElement | null = null;
|
private timeContainerEl: HTMLElement | null = null;
|
||||||
private vignetteEl: HTMLElement | null = null;
|
private vignetteEl: HTMLElement | null = null;
|
||||||
private contentEl: HTMLElement | null = null;
|
private contentEl: HTMLElement | null = null;
|
||||||
|
private delayTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private boundResetDelayTimer: () => void;
|
||||||
|
private boundShowHint: () => void;
|
||||||
|
private hintEl: HTMLElement | null = null;
|
||||||
|
private hintTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private hintVisible = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.updateTime();
|
this.updateTime();
|
||||||
|
this.boundResetDelayTimer = this.resetDelayTimer.bind(this);
|
||||||
|
this.boundShowHint = this.showHint.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
@@ -246,6 +262,7 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
|
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint">Click to exit screensaver</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,18 +270,28 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
this.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement;
|
this.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement;
|
||||||
this.vignetteEl = this.shadowRoot?.querySelector('.vignette') as HTMLElement;
|
this.vignetteEl = this.shadowRoot?.querySelector('.vignette') as HTMLElement;
|
||||||
this.contentEl = this.shadowRoot?.querySelector('.screensaver-content') as HTMLElement;
|
this.contentEl = this.shadowRoot?.querySelector('.screensaver-content') as HTMLElement;
|
||||||
|
this.hintEl = this.shadowRoot?.querySelector('.hint') as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback(): Promise<void> {
|
async connectedCallback(): Promise<void> {
|
||||||
await super.connectedCallback();
|
await super.connectedCallback();
|
||||||
|
|
||||||
|
// If delay is set, start the delay timer and listen for activity
|
||||||
|
if (this.delay > 0 && !this.active) {
|
||||||
|
this.startDelayTimer();
|
||||||
|
this.addActivityListeners();
|
||||||
|
} else if (this.active) {
|
||||||
this.startAnimation();
|
this.startAnimation();
|
||||||
this.startTimeUpdate();
|
this.startTimeUpdate();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async disconnectedCallback(): Promise<void> {
|
async disconnectedCallback(): Promise<void> {
|
||||||
await super.disconnectedCallback();
|
await super.disconnectedCallback();
|
||||||
this.stopAnimation();
|
this.stopAnimation();
|
||||||
this.stopTimeUpdate();
|
this.stopTimeUpdate();
|
||||||
|
this.stopDelayTimer();
|
||||||
|
this.removeActivityListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changedProperties: Map<string, unknown>): void {
|
updated(changedProperties: Map<string, unknown>): void {
|
||||||
@@ -276,9 +303,15 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
this.contentEl.style.maskImage = '';
|
this.contentEl.style.maskImage = '';
|
||||||
this.contentEl.style.webkitMaskImage = '';
|
this.contentEl.style.webkitMaskImage = '';
|
||||||
}
|
}
|
||||||
|
// Hide hint when freshly activated
|
||||||
|
this.hideHint();
|
||||||
|
// Listen for mouse movement to show hint
|
||||||
|
window.addEventListener('mousemove', this.boundShowHint);
|
||||||
this.startAnimation();
|
this.startAnimation();
|
||||||
this.startTimeUpdate();
|
this.startTimeUpdate();
|
||||||
} else {
|
} else {
|
||||||
|
window.removeEventListener('mousemove', this.boundShowHint);
|
||||||
|
this.hideHint();
|
||||||
this.stopAnimation();
|
this.stopAnimation();
|
||||||
this.stopTimeUpdate();
|
this.stopTimeUpdate();
|
||||||
}
|
}
|
||||||
@@ -393,6 +426,71 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startDelayTimer(): void {
|
||||||
|
this.stopDelayTimer();
|
||||||
|
this.delayTimeoutId = setTimeout(() => {
|
||||||
|
this.removeActivityListeners();
|
||||||
|
this.active = true;
|
||||||
|
}, this.delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopDelayTimer(): void {
|
||||||
|
if (this.delayTimeoutId) {
|
||||||
|
clearTimeout(this.delayTimeoutId);
|
||||||
|
this.delayTimeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetDelayTimer(): void {
|
||||||
|
if (this.delay > 0 && !this.active) {
|
||||||
|
this.startDelayTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addActivityListeners(): void {
|
||||||
|
window.addEventListener('mousemove', this.boundResetDelayTimer);
|
||||||
|
window.addEventListener('keydown', this.boundResetDelayTimer);
|
||||||
|
window.addEventListener('click', this.boundResetDelayTimer);
|
||||||
|
window.addEventListener('touchstart', this.boundResetDelayTimer);
|
||||||
|
window.addEventListener('scroll', this.boundResetDelayTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeActivityListeners(): void {
|
||||||
|
window.removeEventListener('mousemove', this.boundResetDelayTimer);
|
||||||
|
window.removeEventListener('keydown', this.boundResetDelayTimer);
|
||||||
|
window.removeEventListener('click', this.boundResetDelayTimer);
|
||||||
|
window.removeEventListener('touchstart', this.boundResetDelayTimer);
|
||||||
|
window.removeEventListener('scroll', this.boundResetDelayTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showHint(): void {
|
||||||
|
if (!this.active || this.hintVisible) return;
|
||||||
|
|
||||||
|
this.hintVisible = true;
|
||||||
|
if (this.hintEl) {
|
||||||
|
this.hintEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-hide after 3 seconds
|
||||||
|
if (this.hintTimeoutId) {
|
||||||
|
clearTimeout(this.hintTimeoutId);
|
||||||
|
}
|
||||||
|
this.hintTimeoutId = setTimeout(() => {
|
||||||
|
this.hideHint();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideHint(): void {
|
||||||
|
this.hintVisible = false;
|
||||||
|
if (this.hintEl) {
|
||||||
|
this.hintEl.classList.remove('visible');
|
||||||
|
}
|
||||||
|
if (this.hintTimeoutId) {
|
||||||
|
clearTimeout(this.hintTimeoutId);
|
||||||
|
this.hintTimeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleClick(event: MouseEvent | TouchEvent): void {
|
private handleClick(event: MouseEvent | TouchEvent): void {
|
||||||
// Get click/touch position
|
// Get click/touch position
|
||||||
let x: number, y: number;
|
let x: number, y: number;
|
||||||
@@ -434,7 +532,7 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
} else {
|
} else {
|
||||||
// Animation complete - remove screensaver
|
// Animation complete - remove screensaver
|
||||||
this.active = false;
|
this.active = false;
|
||||||
DeesScreensaver.destroy();
|
EcoScreensaver.destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
1
ts_web/elements/eco-screensaver/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-screensaver.js';
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
export * from './00zindex.js';
|
export * from './00zindex.js';
|
||||||
export * from './00theme.js';
|
export * from './00theme.js';
|
||||||
|
|
||||||
|
// Component Groups
|
||||||
|
export * from './00group-applauncher/index.js';
|
||||||
|
|
||||||
// Standalone Components
|
// Standalone Components
|
||||||
export * from './dees-screensaver/index.js';
|
export * from './eco-screensaver/index.js';
|
||||||
|
|||||||