feat(toggle component): Enhanced consent toggle component with drag functionality
This commit is contained in:
		| @@ -1,5 +1,13 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-01-16 - 1.4.0 - feat(toggle component) | ||||
| Enhanced consent toggle component with drag functionality | ||||
|  | ||||
| - Implemented drag functionality for the toggle knob. | ||||
| - Added smooth transitions for the toggle knob movement. | ||||
| - Handled drag state management to differentiate between actual click and drag. | ||||
| - Improved user interaction by allowing click anywhere in the toggle area. | ||||
|  | ||||
| ## 2025-01-16 - 1.3.5 - fix(elements) | ||||
| Improved styling consistency across several components | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@consent.software/catalog', | ||||
|   version: '1.3.5', | ||||
|   version: '1.4.0', | ||||
|   description: 'A library of web components designed to integrate robust consent management capabilities into web applications, ensuring compliance with privacy regulations.' | ||||
| } | ||||
|   | ||||
| @@ -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 { | ||||
|       transition: all 0.2s; | ||||
|       position: 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,151 @@ 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; | ||||
|     } | ||||
|  | ||||
|     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; | ||||
|     mouseEvent.stopPropagation(); | ||||
|     mouseEvent.preventDefault(); | ||||
|     this.syncSelection(); | ||||
|     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; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * DRAG HANDLERS (pointer events) | ||||
|    */ | ||||
|   private onPointerDown(event: PointerEvent) { | ||||
|     if (this.required) { | ||||
|       // If "required", disallow manual dragging | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Start dragging | ||||
|     this.isDragging = true; | ||||
|     this.hasDragged = false; | ||||
|     // 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 } })); | ||||
|   } | ||||
|  | ||||
|   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 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() { | ||||
|   public getText(): string | null { | ||||
|     return this.textContent; | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user