import { customElement, type TemplateResult, property, html, css, cssManager, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { DeesInputBase } from '../dees-input-base/dees-input-base.js'; import { demoFunc } from './dees-input-toggle.demo.js'; import { cssGeistFontFamily } from '../../00fonts.js'; import { themeDefaultStyles } from '../../00theme.js'; declare global { interface HTMLElementTagNameMap { 'dees-input-toggle': DeesInputToggle; } } @customElement('dees-input-toggle') export class DeesInputToggle extends DeesInputBase { // STATIC public static demo = demoFunc; public static demoGroup = 'Input'; // INSTANCE @property({ type: Boolean, reflect: true }) accessor value: boolean = false; /** * Knob position tracking (0 = off, maxTravel = on) */ private currentX = 0; /** * Drag state */ private isDragging = false; private hasDragged = false; private startX = 0; // Toggle dimensions private readonly trackWidth = 36; private readonly trackHeight = 20; private readonly knobSize = 14; private readonly padding = 2; private readonly maxTravel = 16; // trackWidth - knobSize - (padding * 2) - border constructor() { super(); this.labelPosition = 'right'; // Toggle defaults to label on the right } public static styles = [ themeDefaultStyles, ...DeesInputBase.baseStyles, cssManager.defaultStyles, css` * { box-sizing: border-box; } :host { position: relative; cursor: default; font-family: ${cssGeistFontFamily}; } .maincontainer { display: inline-flex; align-items: flex-start; gap: 8px; cursor: pointer; user-select: none; transition: all 0.15s ease; } .toggle-track { position: relative; flex-shrink: 0; height: 20px; width: 36px; border-radius: 10px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; overflow: hidden; transition: all 0.15s ease; margin-top: 1px; } .maincontainer:hover .toggle-track { border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; } :host([value]) .toggle-track { background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; } .toggle-track:focus-visible { outline: none; box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')}; } .toggle-knob { position: absolute; top: 2px; width: 14px; height: 14px; border-radius: 7px; background: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; transition: left 0.15s ease, background 0.15s ease; touch-action: none; } .toggle-knob.dragging { transition: background 0.15s ease; } :host([value]) .toggle-knob { background: white; } /* Disabled state */ .maincontainer.disabled { cursor: not-allowed; opacity: 0.5; } .toggle-track.disabled { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } /* Required state (locked on) */ :host([required][value]) .toggle-track { background: ${cssManager.bdTheme('hsl(222.2 47.4% 61.2%)', 'hsl(217.2 91.2% 49.8%)')}; border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 61.2%)', 'hsl(217.2 91.2% 49.8%)')}; cursor: not-allowed; } :host([required][value]) .toggle-knob { background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 70%)')}; } /* Label */ .label-container { display: flex; flex-direction: column; gap: 2px; flex: 1; } .toggle-label { font-size: 14px; font-weight: 500; line-height: 20px; color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')}; transition: color 0.15s ease; letter-spacing: -0.01em; } .maincontainer:hover .toggle-label { color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; } .maincontainer.disabled:hover .toggle-label { color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')}; } /* Description */ .description-text { font-size: 12px; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; line-height: 1.5; } `, ]; public render(): TemplateResult { return html`
${this.label ? html`
${this.label}
` : ''} ${this.description ? html`
${this.description}
` : ''}
`; } public async firstUpdated(_changedProperties: Map) { await super.firstUpdated(_changedProperties); // Initialize knob position based on initial value if (this.required && !this.value) { this.value = true; } this.currentX = this.value ? this.maxTravel : 0; this.requestUpdate(); } /** * Click handler - toggles the value */ private async handleClick(event: MouseEvent) { if (this.isDragging || this.hasDragged) { event.stopPropagation(); event.preventDefault(); return; } if (this.disabled) { return; } if (this.required) { // Bounce animation for required toggles this.currentX = this.maxTravel; this.requestUpdate(); await new Promise((r) => setTimeout(r, 10)); this.currentX = this.maxTravel - 3; this.requestUpdate(); await domtools.plugins.smartdelay.delayFor(150); this.currentX = this.maxTravel; this.requestUpdate(); return; } event.stopPropagation(); event.preventDefault(); this.value = !this.value; this.currentX = this.value ? this.maxTravel : 0; this.requestUpdate(); this.dispatchEvent( new CustomEvent('newValue', { detail: this.value, bubbles: true, }) ); this.changeSubject.next(this); domtools.plugins.smartdelay.delayFor(0).then(() => { this.hasDragged = false; }); } /** * Pointer down - start dragging */ private onPointerDown(event: PointerEvent) { if (this.required || this.disabled) { return; } this.isDragging = true; this.startX = event.clientX - this.currentX; (event.target as HTMLElement).setPointerCapture(event.pointerId); } /** * Pointer move - track drag position */ private onPointerMove(event: PointerEvent) { if (!this.isDragging) return; const newX = event.clientX - this.startX; this.hasDragged = true; const toggleKnob = this.shadowRoot?.querySelector('.toggle-knob') as HTMLDivElement; if (toggleKnob) { toggleKnob.classList.add('dragging'); } this.currentX = Math.max(0, Math.min(newX, this.maxTravel)); this.requestUpdate(); } /** * Pointer up - complete drag and snap to nearest side */ private onPointerUp(event: PointerEvent) { if (!this.isDragging) return; (event.target as HTMLElement).releasePointerCapture(event.pointerId); this.isDragging = false; if (!this.hasDragged) { return; } const toggleKnob = this.shadowRoot?.querySelector('.toggle-knob') as HTMLDivElement; if (toggleKnob) { toggleKnob.classList.remove('dragging'); } // Snap to nearest side based on midpoint const midpoint = this.maxTravel / 2; this.value = this.currentX > midpoint; this.currentX = this.value ? this.maxTravel : 0; this.requestUpdate(); this.dispatchEvent( new CustomEvent('newValue', { detail: this.value, bubbles: true, }) ); this.changeSubject.next(this); domtools.plugins.smartdelay.delayFor(0).then(() => { this.hasDragged = false; }); } /** * Sync knob position when value is changed externally */ updated(changedProperties: Map): void { super.updated(changedProperties); if ( changedProperties.has('value') && !this.isDragging && !this.hasDragged ) { this.currentX = this.value ? this.maxTravel : 0; this.requestUpdate(); } } /** * Keyboard support */ private handleKeydown(event: KeyboardEvent) { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); this.handleClick(event as unknown as MouseEvent); } } // DeesInputBase interface implementation public getValue(): boolean { return this.value; } public setValue(valueArg: boolean): void { this.value = valueArg; } public focus(): void { const track = this.shadowRoot?.querySelector('.toggle-track'); if (track) { (track as HTMLElement).focus(); } } }