Files
catalog/ts_web/elements/consentsoftware-toggle.ts

266 lines
7.2 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 { LitElement, html, css, type TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js';
import { property } from 'lit/decorators/property.js';
import { delayFor } from '@push.rocks/smartdelay';
@customElement('consentsoftware-toggle')
export class ConsentsoftwareToggle extends LitElement {
@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
private readonly trackWidth = 36;
private readonly trackHeight = 20;
private readonly knobSize = 14;
private readonly padding = 3; // padding from track edge to knob
private readonly maxTravel = 16; // trackWidth - knobSize - (2 * padding) = 36 - 14 - 6 = 16
public static styles = css`
:host {
display: block;
position: relative;
}
.label {
margin-bottom: 8px;
font-size: 0.8em;
font-weight: 500;
color: var(--muted-foreground, hsl(0 0% 64%));
}
.toggle {
user-select: none;
}
.toggleKnobArea {
position: relative;
margin: auto;
height: 20px;
width: 36px;
border-radius: 10px;
background: var(--input, hsl(0 0% 15%));
border: 1px solid var(--border, hsl(0 0% 18%));
overflow: hidden;
transition: all 0.15s ease;
cursor: pointer;
}
.toggleKnobArea:hover {
border-color: var(--ring, hsl(0 0% 30%));
}
:host([selected]) .toggleKnobArea {
background: var(--primary, hsl(0 0% 98%));
border-color: var(--primary, hsl(0 0% 98%));
}
.toggleKnobMover {
position: relative;
height: 100%;
width: 100%;
}
.toggleKnobInner {
position: absolute;
top: 3px;
width: 14px;
height: 14px;
border-radius: 7px;
background: var(--muted-foreground, hsl(0 0% 64%));
transition: left 0.15s ease, background 0.15s ease;
touch-action: none;
}
.toggleKnobInner.dragging {
transition: background 0.15s ease;
}
:host([selected]) .toggleKnobInner {
background: var(--primary-foreground, hsl(0 0% 9%));
}
:host([required]) .toggleKnobArea {
background: var(--muted, hsl(0 0% 15%));
border-color: var(--border, hsl(0 0% 18%));
opacity: 0.6;
cursor: not-allowed;
}
:host([required]) .toggleKnobInner {
background: var(--muted-foreground, hsl(0 0% 45%));
}
:host([required][selected]) .toggleKnobArea {
background: var(--muted-foreground, hsl(0 0% 45%));
}
`;
constructor() {
super();
}
public render(): TemplateResult {
return html`
<div class="label">${this.getText()}</div>
<!-- A user can click anywhere in this toggle area. -->
<div class="toggle" @click=${this.handleClick}>
<div class="toggleKnobArea">
<div class="toggleKnobMover">
<!-- The knob itself, with pointer events for dragging. -->
<div
class="toggleKnobInner"
style="left: ${this.padding + this.currentX}px;"
@pointerdown=${this.onPointerDown}
@pointermove=${this.onPointerMove}
@pointerup=${this.onPointerUp}
@pointercancel=${this.onPointerUp}
></div>
</div>
</div>
</div>
`;
}
/**
* If required = true on first render, auto-select and set the knob to the right.
*/
public async firstUpdated() {
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 pointers X and the knobs 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<PropertyKey, unknown>): 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;
}
}