This commit is contained in:
2026-01-06 09:47:03 +00:00
parent 2dbc9e35c6
commit dcc3e18474
22 changed files with 993 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -30,6 +30,11 @@ export class EcoApplauncherBatterymenu extends DeesElement {
:host { :host {
display: block; display: block;
position: relative; position: relative;
pointer-events: none;
}
:host([open]) {
pointer-events: auto;
} }
.menu-container { .menu-container {
@@ -241,12 +246,18 @@ export class EcoApplauncherBatterymenu extends DeesElement {
accessor timeRemaining: string | null = null; accessor timeRemaining: string | null = null;
private boundHandleClickOutside = this.handleClickOutside.bind(this); private boundHandleClickOutside = this.handleClickOutside.bind(this);
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
public render(): TemplateResult { public render(): TemplateResult {
const fillClass = this.getFillClass(); const fillClass = this.getFillClass();
return html` return html`
<div class="menu-container"> <div class="menu-container"
@click=${(e: MouseEvent) => e.stopPropagation()}
@mousemove=${this.resetInactivityTimer}
@mousedown=${this.resetInactivityTimer}
>
<div class="battery-display"> <div class="battery-display">
<div class="battery-visual"> <div class="battery-visual">
<div class="battery-icon"> <div class="battery-icon">
@@ -324,12 +335,42 @@ export class EcoApplauncherBatterymenu extends DeesElement {
private handleClickOutside(e: MouseEvent): void { private handleClickOutside(e: MouseEvent): void {
if (this.open && !this.contains(e.target as Node)) { if (this.open && !this.contains(e.target as Node)) {
this.closeMenu();
}
}
private resetInactivityTimer(): void {
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.open = false;
this.dispatchEvent(new CustomEvent('menu-close', { this.dispatchEvent(new CustomEvent('menu-close', {
bubbles: true, bubbles: true,
composed: 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> { async connectedCallback(): Promise<void> {
@@ -342,5 +383,6 @@ export class EcoApplauncherBatterymenu extends DeesElement {
async disconnectedCallback(): Promise<void> { async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback(); await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleClickOutside); document.removeEventListener('click', this.boundHandleClickOutside);
this.clearInactivityTimer();
} }
} }

View File

@@ -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>
`;

View File

@@ -0,0 +1,651 @@
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: 4, type: 'space' },
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
{ 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: 4, type: 'space' },
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
{ 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: 4, type: 'space' },
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
{ 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-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;
}
}
}

View File

@@ -0,0 +1 @@
export * from './eco-applauncher-keyboard.js';

View File

@@ -37,6 +37,11 @@ export class EcoApplauncherSoundmenu extends DeesElement {
:host { :host {
display: block; display: block;
position: relative; position: relative;
pointer-events: none;
}
:host([open]) {
pointer-events: auto;
} }
.menu-container { .menu-container {
@@ -242,12 +247,18 @@ export class EcoApplauncherSoundmenu extends DeesElement {
private boundHandleClickOutside = this.handleClickOutside.bind(this); private boundHandleClickOutside = this.handleClickOutside.bind(this);
private isDragging = false; private isDragging = false;
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
public render(): TemplateResult { public render(): TemplateResult {
const volumeIcon = this.getVolumeIcon(); const volumeIcon = this.getVolumeIcon();
return html` return html`
<div class="menu-container"> <div class="menu-container"
@click=${(e: MouseEvent) => e.stopPropagation()}
@mousemove=${this.resetInactivityTimer}
@mousedown=${this.resetInactivityTimer}
>
<div class="menu-header"> <div class="menu-header">
<span class="menu-title"> <span class="menu-title">
<dees-icon .icon=${volumeIcon} .iconSize=${18}></dees-icon> <dees-icon .icon=${volumeIcon} .iconSize=${18}></dees-icon>
@@ -405,12 +416,42 @@ export class EcoApplauncherSoundmenu extends DeesElement {
private handleClickOutside(e: MouseEvent): void { private handleClickOutside(e: MouseEvent): void {
if (this.open && !this.contains(e.target as Node)) { if (this.open && !this.contains(e.target as Node)) {
this.closeMenu();
}
}
private resetInactivityTimer(): void {
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.open = false;
this.dispatchEvent(new CustomEvent('menu-close', { this.dispatchEvent(new CustomEvent('menu-close', {
bubbles: true, bubbles: true,
composed: 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> { async connectedCallback(): Promise<void> {
@@ -423,5 +464,6 @@ export class EcoApplauncherSoundmenu extends DeesElement {
async disconnectedCallback(): Promise<void> { async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback(); await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleClickOutside); document.removeEventListener('click', this.boundHandleClickOutside);
this.clearInactivityTimer();
} }
} }

View File

@@ -37,6 +37,11 @@ export class EcoApplauncherWifimenu extends DeesElement {
:host { :host {
display: block; display: block;
position: relative; position: relative;
pointer-events: none;
}
:host([open]) {
pointer-events: auto;
} }
.menu-container { .menu-container {
@@ -224,10 +229,16 @@ export class EcoApplauncherWifimenu extends DeesElement {
accessor wifiEnabled = true; accessor wifiEnabled = true;
private boundHandleClickOutside = this.handleClickOutside.bind(this); private boundHandleClickOutside = this.handleClickOutside.bind(this);
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
public render(): TemplateResult { public render(): TemplateResult {
return html` return html`
<div class="menu-container"> <div class="menu-container"
@click=${(e: MouseEvent) => e.stopPropagation()}
@mousemove=${this.resetInactivityTimer}
@mousedown=${this.resetInactivityTimer}
>
<div class="menu-header"> <div class="menu-header">
<span class="menu-title"> <span class="menu-title">
<dees-icon .icon=${'lucide:wifi'} .iconSize=${18}></dees-icon> <dees-icon .icon=${'lucide:wifi'} .iconSize=${18}></dees-icon>
@@ -332,12 +343,42 @@ export class EcoApplauncherWifimenu extends DeesElement {
private handleClickOutside(e: MouseEvent): void { private handleClickOutside(e: MouseEvent): void {
if (this.open && !this.contains(e.target as Node)) { if (this.open && !this.contains(e.target as Node)) {
this.closeMenu();
}
}
private resetInactivityTimer(): void {
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.open = false;
this.dispatchEvent(new CustomEvent('menu-close', { this.dispatchEvent(new CustomEvent('menu-close', {
bubbles: true, bubbles: true,
composed: 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> { async connectedCallback(): Promise<void> {
@@ -351,5 +392,6 @@ export class EcoApplauncherWifimenu extends DeesElement {
async disconnectedCallback(): Promise<void> { async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback(); await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleClickOutside); document.removeEventListener('click', this.boundHandleClickOutside);
this.clearInactivityTimer();
} }
} }

View File

@@ -13,12 +13,14 @@ import { demo } from './eco-applauncher.demo.js';
import { EcoApplauncherWifimenu, type IWifiNetwork } from '../eco-applauncher-wifimenu/index.js'; import { EcoApplauncherWifimenu, type IWifiNetwork } from '../eco-applauncher-wifimenu/index.js';
import { EcoApplauncherBatterymenu } from '../eco-applauncher-batterymenu/index.js'; import { EcoApplauncherBatterymenu } from '../eco-applauncher-batterymenu/index.js';
import { EcoApplauncherSoundmenu, type IAudioDevice } from '../eco-applauncher-soundmenu/index.js'; import { EcoApplauncherSoundmenu, type IAudioDevice } from '../eco-applauncher-soundmenu/index.js';
import { EcoApplauncherKeyboard } from '../eco-applauncher-keyboard/index.js';
// Ensure components are registered // Ensure components are registered
DeesIcon; DeesIcon;
EcoApplauncherWifimenu; EcoApplauncherWifimenu;
EcoApplauncherBatterymenu; EcoApplauncherBatterymenu;
EcoApplauncherSoundmenu; EcoApplauncherSoundmenu;
EcoApplauncherKeyboard;
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -37,6 +39,7 @@ export interface IStatusBarConfig {
showNetwork?: boolean; showNetwork?: boolean;
showBattery?: boolean; showBattery?: boolean;
showSound?: boolean; showSound?: boolean;
showKeyboard?: boolean;
} }
export interface ITopBarConfig { export interface ITopBarConfig {
@@ -409,6 +412,22 @@ export class EcoApplauncher extends DeesElement {
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
left: 0; left: 0;
z-index: 100; z-index: 100;
pointer-events: none;
}
.keyboard-area {
flex-shrink: 0;
height: 0;
overflow: hidden;
transition: height 0.25s ease;
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 6% 12%)')};
z-index: 1000;
position: relative;
}
.keyboard-area.visible {
height: 220px;
overflow: visible;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -452,6 +471,7 @@ export class EcoApplauncher extends DeesElement {
showNetwork: true, showNetwork: true,
showBattery: true, showBattery: true,
showSound: true, showSound: true,
showKeyboard: true,
}; };
@property({ type: Object }) @property({ type: Object })
@@ -492,6 +512,9 @@ export class EcoApplauncher extends DeesElement {
@state() @state()
accessor soundMenuOpen = false; accessor soundMenuOpen = false;
@state()
accessor keyboardVisible = false;
@property({ type: Array }) @property({ type: Array })
accessor networks: IWifiNetwork[] = []; accessor networks: IWifiNetwork[] = [];
@@ -535,8 +558,19 @@ export class EcoApplauncher extends DeesElement {
${this.apps.map((app) => this.renderAppIcon(app))} ${this.apps.map((app) => this.renderAppIcon(app))}
</div> </div>
</div> </div>
<div class="keyboard-area ${this.keyboardVisible ? 'visible' : ''}">
<eco-applauncher-keyboard
?visible=${this.keyboardVisible}
@key-press=${this.handleKeyboardKeyPress}
@backspace=${this.handleKeyboardBackspace}
@enter=${this.handleKeyboardEnter}
@space=${this.handleKeyboardSpace}
@arrow=${this.handleKeyboardArrow}
></eco-applauncher-keyboard>
</div>
<div class="status-bar"> <div class="status-bar">
<div class="status-left"> <div class="status-left">
${this.statusConfig.showKeyboard ? this.renderKeyboardToggle() : ''}
${this.statusConfig.showNetwork ? this.renderNetworkStatusWithMenu() : ''} ${this.statusConfig.showNetwork ? this.renderNetworkStatusWithMenu() : ''}
${this.statusConfig.showBattery ? this.renderBatteryStatusWithMenu() : ''} ${this.statusConfig.showBattery ? this.renderBatteryStatusWithMenu() : ''}
${this.statusConfig.showSound ? this.renderSoundStatusWithMenu() : ''} ${this.statusConfig.showSound ? this.renderSoundStatusWithMenu() : ''}
@@ -765,6 +799,17 @@ export class EcoApplauncher extends DeesElement {
`; `;
} }
private renderKeyboardToggle(): TemplateResult {
return html`
<div
class="status-item clickable ${this.keyboardVisible ? 'active' : ''}"
@click=${this.handleKeyboardToggle}
>
<dees-icon .icon=${'lucide:keyboard'} .iconSize=${18}></dees-icon>
</div>
`;
}
private getNetworkBars(): number { private getNetworkBars(): number {
switch (this.networkStatus) { switch (this.networkStatus) {
case 'online': case 'online':
@@ -901,6 +946,58 @@ export class EcoApplauncher extends DeesElement {
})); }));
} }
private handleKeyboardToggle(): void {
this.keyboardVisible = !this.keyboardVisible;
// Close all menus when opening keyboard
if (this.keyboardVisible) {
this.wifiMenuOpen = false;
this.batteryMenuOpen = false;
this.soundMenuOpen = false;
}
this.dispatchEvent(new CustomEvent('keyboard-toggle', {
detail: { visible: this.keyboardVisible },
bubbles: true,
composed: true,
}));
}
private handleKeyboardKeyPress(e: CustomEvent): void {
this.dispatchEvent(new CustomEvent('keyboard-key-press', {
detail: e.detail,
bubbles: true,
composed: true,
}));
}
private handleKeyboardBackspace(): void {
this.dispatchEvent(new CustomEvent('keyboard-backspace', {
bubbles: true,
composed: true,
}));
}
private handleKeyboardEnter(): void {
this.dispatchEvent(new CustomEvent('keyboard-enter', {
bubbles: true,
composed: true,
}));
}
private handleKeyboardSpace(): void {
this.dispatchEvent(new CustomEvent('keyboard-space', {
bubbles: true,
composed: true,
}));
}
private handleKeyboardArrow(e: CustomEvent): void {
this.dispatchEvent(new CustomEvent('keyboard-arrow', {
detail: e.detail,
bubbles: true,
composed: true,
}));
}
private handleSearchClick(): void { private handleSearchClick(): void {
this.dispatchEvent(new CustomEvent('search-click', { this.dispatchEvent(new CustomEvent('search-click', {
bubbles: true, bubbles: true,

View File

@@ -3,3 +3,4 @@ export * from './eco-applauncher/index.js';
export * from './eco-applauncher-wifimenu/index.js'; export * from './eco-applauncher-wifimenu/index.js';
export * from './eco-applauncher-batterymenu/index.js'; export * from './eco-applauncher-batterymenu/index.js';
export * from './eco-applauncher-soundmenu/index.js'; export * from './eco-applauncher-soundmenu/index.js';
export * from './eco-applauncher-keyboard/index.js';