Files
catalog/ts_web/elements/00group-applauncher/eco-applauncher-keyboard/eco-applauncher-keyboard.ts

663 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}