import { cssManager, colors } from './shared.js'; import { DeesElement, customElement, html, css, type TemplateResult, property, } from '@design.estate/dees-element'; import { delayFor } from '@push.rocks/smartdelay'; @customElement('consentsoftware-toggle') export class ConsentsoftwareToggle extends DeesElement { @property({ type: Boolean }) public accessor required = false; @property({ type: Boolean, reflect: true }) public accessor selected = false; /** * Knob position tracking (0 = off, maxTravel = on) * This is the travel distance, not absolute left position. * Actual left = padding + currentX */ private currentX = 0; /** * Drag state */ private isDragging = false; private hasDragged = false; private startX = 0; // Toggle dimensions (with border-box, 1px border reduces inner by 2px) private readonly trackWidth = 36; // outer width private readonly trackHeight = 20; // outer height private readonly knobSize = 14; private readonly padding = 2; // padding from inner edge to knob private readonly maxTravel = 16; // (36 - 2) - 14 - (2 * 2) = 34 - 14 - 4 = 16 public static styles = [ cssManager.defaultStyles, css` :host { display: block; position: relative; } .label { margin-bottom: 8px; font-size: 0.8em; font-weight: 500; color: ${cssManager.bdTheme(colors.light.mutedForeground, colors.dark.mutedForeground)}; } .toggle { user-select: none; } .toggleKnobArea { position: relative; margin: auto; height: 20px; width: 36px; border-radius: 10px; background: ${cssManager.bdTheme(colors.light.input, colors.dark.input)}; border: 1px solid ${cssManager.bdTheme(colors.light.border, colors.dark.border)}; overflow: hidden; transition: all 0.15s ease; cursor: pointer; } .toggleKnobArea:hover { border-color: ${cssManager.bdTheme(colors.light.ring, colors.dark.ring)}; } :host([selected]) .toggleKnobArea { background: ${cssManager.bdTheme(colors.light.primary, colors.dark.primary)}; border-color: ${cssManager.bdTheme(colors.light.primary, colors.dark.primary)}; } .toggleKnobMover { position: relative; height: 100%; width: 100%; } .toggleKnobInner { position: absolute; top: 2px; width: 14px; height: 14px; border-radius: 7px; background: ${cssManager.bdTheme(colors.light.mutedForeground, colors.dark.mutedForeground)}; transition: left 0.15s ease, background 0.15s ease; touch-action: none; } .toggleKnobInner.dragging { transition: background 0.15s ease; } :host([selected]) .toggleKnobInner { background: ${cssManager.bdTheme(colors.light.primaryForeground, colors.dark.primaryForeground)}; } :host([required]) .toggleKnobArea { cursor: not-allowed; } :host([required][selected]) .toggleKnobArea { background: ${cssManager.bdTheme(colors.light.ring, colors.dark.ring)}; border-color: ${cssManager.bdTheme(colors.light.ring, colors.dark.ring)}; } :host([required][selected]) .toggleKnobInner { background: ${cssManager.bdTheme(colors.light.muted, colors.dark.muted)}; } `, ]; public render(): TemplateResult { return html`
${this.getText()}
`; } /** * If required = true on first render, auto-select and set the knob to the right. */ public async firstUpdated(_changedProperties: Map) { await super.firstUpdated(_changedProperties); if (this.required) { this.selected = true; this.currentX = this.maxTravel; this.requestUpdate(); } else { this.currentX = this.selected ? this.maxTravel : 0; this.requestUpdate(); } } /** * CLICK HANDLER */ public async handleClick(event: MouseEvent) { if (this.isDragging || this.hasDragged) { event.stopPropagation(); event.preventDefault(); return; } if (this.required) { // Small 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 delayFor(150); this.currentX = this.maxTravel; this.requestUpdate(); return; } event.stopPropagation(); event.preventDefault(); this.selected = !this.selected; this.currentX = this.selected ? this.maxTravel : 0; this.requestUpdate(); this.dispatchEvent(new CustomEvent('toggle', { detail: { selected: this.selected } })); delayFor(0).then(() => { this.hasDragged = false; }); } /** * DRAG HANDLERS (pointer events) */ private onPointerDown(event: PointerEvent) { if (this.required) { // If "required", disallow manual dragging return; } // Start dragging this.isDragging = true; // The difference between the pointer's X and the knob's current position this.startX = event.clientX - this.currentX; // capture pointer so we keep receiving pointermove/pointerup (event.target as HTMLElement).setPointerCapture(event.pointerId); } private onPointerMove(event: PointerEvent) { if (!this.isDragging) return; const newX = event.clientX - this.startX; this.hasDragged = true; const toggleKnobInner: HTMLDivElement = this.shadowRoot.querySelector('.toggleKnobInner'); toggleKnobInner.classList.add('dragging'); // Clamp to valid travel range (0 to maxTravel) this.currentX = Math.max(0, Math.min(newX, this.maxTravel)); this.requestUpdate(); } private onPointerUp(event: PointerEvent) { if (!this.isDragging) return; (event.target as HTMLElement).releasePointerCapture(event.pointerId); this.isDragging = false; // If we didn't truly drag, pointerup does nothing; click handler handles toggling. if (!this.hasDragged) { return; } const toggleKnobInner: HTMLDivElement = this.shadowRoot.querySelector('.toggleKnobInner'); toggleKnobInner.classList.remove('dragging'); // Real drag => decide final side based on midpoint const midpoint = this.maxTravel / 2; this.selected = this.currentX > midpoint; this.currentX = this.selected ? this.maxTravel : 0; // snap to edge this.requestUpdate(); // Dispatch toggle event this.dispatchEvent(new CustomEvent('toggle', { detail: { selected: this.selected } })); delayFor(0).then(() => { this.hasDragged = false; }); } /** * If external code sets `selected = true/false`, we also sync the knob position */ protected updated(changedProperties: Map): void { if ( changedProperties.has('selected') && !this.isDragging && !this.hasDragged && !this.required ) { this.currentX = this.selected ? this.maxTravel : 0; this.requestUpdate(); } super.updated(changedProperties); } public getText(): string | null { return this.textContent; } }