@ -1,19 +1,32 @@
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 {
public static demo = ( ) = > html ` <consentsoftware-toggle></consentsoftware-toggle> ` ;
@property ( { type : Boolean } )
public required = false ;
@property ( { type : Boolean , reflect : true } )
public selected = false ;
/**
* We always track the knob’ s left offset in `currentX`.
* - 0 => fully left
* - 30 => fully right
*/
private currentX = 0 ;
/**
* Drag state
*/
private isDragging = false ;
private hasDragged = false ;
private startX = 0 ; // pointerdown offset
private readonly knobWidth = 30 ;
private readonly trackWidth = 60 ;
public static styles = css `
:host {
display: block;
@ -24,17 +37,21 @@ export class ConsentsoftwareToggle extends LitElement {
margin-bottom: 16px;
}
.toggle {
user-select: none; /* helps avoid text selection on drag */
}
.toggleKnobArea {
cursor: pointer ;
position: relative ;
margin: auto;
height: 30px;
width: 60px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.1);
position: relative;
overflow: hidden;
transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0);
overflow: hidden;
transition: background 0.2s ease;
cursor: pointer;
}
:host([selected]) .toggleKnobArea {
@ -42,19 +59,27 @@ export class ConsentsoftwareToggle extends LitElement {
}
.toggleKnobMover {
tran sition: all 0.2s ;
po sition: relative ;
height: 100%;
width: 100%;
}
.toggleKnobInner {
height: 30px;
width: 30px;
border-radius: 15px;
background: rgba(255, 255, 255, 0.5);
position: absolute;
top: 0;
left: 0;
transition: all 0.2s ;
width: 30px ;
height: 30px;
border-radius: 15px;
background: rgba(255, 255, 255, 0.5);
transition: left 0.2s ease, background 0.2s ease;
transform: scale(0.7);
/* Prevent scroll gestures on mobile */
touch-action: none;
}
.toggleKnobInner.dragging {
transition: background 0.2s ease;
}
:host([selected]) .toggleKnobInner {
@ -78,50 +103,153 @@ export class ConsentsoftwareToggle extends LitElement {
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">
<div class="toggleKnobInner"></div >
<!-- The knob itself, with pointer events for dragging. -- >
<div
class="toggleKnobInner"
style="left: ${ this . currentX } px;"
@pointerdown= ${ this . onPointerDown }
@pointermove= ${ this . onPointerMove }
@pointerup= ${ this . onPointerUp }
@pointercancel= ${ this . onPointerUp }
></div>
</div>
</div>
</div>
` ;
}
public async firstUpdated ( _changedProperties : Map < string | number | symbol , unknown > ) {
super . firstUpdated ( _changedProperties ) ;
/**
* If required = true on first render, auto-select and set the knob to the right.
*/
public async firstUpdated() {
if ( this . required ) {
// If "required" => always selected
this . selected = true ;
this . syncSelection ( ) ;
this . currentX = this . knobWidth ; // 30
this . requestUpdate ( ) ;
} else {
// If not required, set knob to 0 or 30 depending on `selected`
this . currentX = this . selected ? this . knobWidth : 0 ;
this . requestUpdate ( ) ;
}
}
public async handleClick ( mouseEvent ) {
if ( this . required ) {
const moverElement : HTMLDivElement = this . shadowRoot . querySelector ( '.toggleKnobMover' ) ;
moverElement . style . transform = 'translateX(20px)' ;
await delayFor ( 250 ) ;
moverElement . style . transform = 'translateX(30px)' ;
/**
* CLICK HANDLER
*/
public async handleClick ( event : MouseEvent ) {
// If the user truly dragged the knob, skip the normal click toggle.
if ( this . isDragging || this . hasDragged ) {
event . stopPropagation ( ) ;
event . preventDefault ( ) ;
return ;
}
this . selected = ! this . selected ;
mouseEvent . stopPropagation ( ) ;
mouseEvent . preventDefault ( ) ;
this . syncSelection ( ) ;
this . dispatchEvent ( new CustomEvent ( 'toggle' , { detail : { selected : this.selected } } ) ) ;
}
public async syncSelection() {
console . log ( ` Selected ${ this . selected } ` ) ;
const moverElement : HTMLDivElement = this . shadowRoot . querySelector ( '.toggleKnobMover' ) ;
if ( this . selected ) {
moverElement . style . transform = 'translateX(30px)' ;
} else {
moverElement . style . transform = 'translateX(0px)' ;
if ( this . required ) {
// small bounce from 30 -> 20 -> 30
this . currentX = this . knobWidth ; // ensure at 30
this . requestUpdate ( ) ;
await new Promise ( ( r ) = > setTimeout ( r , 10 ) ) ;
this . currentX = 20 ; // small bounce left
this . requestUpdate ( ) ;
await delayFor ( 200 ) ;
this . currentX = this . knobWidth ; // back to 30
this . requestUpdate ( ) ;
return ;
}
// Normal toggle if no drag & not required
event . stopPropagation ( ) ;
event . preventDefault ( ) ;
this . selected = ! this . selected ;
this . currentX = this . selected ? this . knobWidth : 0 ; // snap knob left(0) or right(30)
this . requestUpdate ( ) ;
this . dispatchEvent ( new CustomEvent ( 'toggle' , { detail : { selected : this.selected } } ) ) ;
delayFor ( 0 ) . then ( ( ) = > {
this . hasDragged = false ;
} ) ;
}
public getText() {
/**
* 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
this . currentX = Math . max ( 0 , Math . min ( newX , this . trackWidth - this . knobWidth ) ) ;
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
const midpoint = ( this . trackWidth - this . knobWidth ) / 2 ; // 15
this . selected = this . currentX > midpoint ;
this . currentX = this . selected ? this . knobWidth : 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 . knobWidth : 0 ;
this . requestUpdate ( ) ;
}
super . updated ( changedProperties ) ;
}
public getText ( ) : string | null {
return this . textContent ;
}
}