/** * Controls Module * * Handles generic UI controls functionality: * - Toggle sliders (Ja/Nej switches) * - Select dropdowns * - Time range sliders */ /** * Controller for generic UI controls */ export class ControlsController { constructor() { this.initToggleSliders(); this.initSelectDropdowns(); this.initTimeRangeSliders(); } /** * Initialize all toggle sliders on the page * Toggle slider: Ja/Nej button switch with data-value attribute * Clicking anywhere on the slider toggles the value */ private initToggleSliders(): void { document.querySelectorAll('swp-toggle-slider').forEach(slider => { slider.addEventListener('click', () => { const el = slider as HTMLElement; const newValue = el.dataset.value === 'yes' ? 'no' : 'yes'; el.dataset.value = newValue; // Dispatch custom event for listeners slider.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { value: newValue } })); }); }); } /** * Initialize all select dropdowns on the page */ private initSelectDropdowns(): void { document.querySelectorAll('swp-select').forEach(select => { const trigger = select.querySelector('button'); const dropdown = select.querySelector('swp-select-dropdown'); const options = select.querySelectorAll('swp-select-option'); if (!trigger || !dropdown) return; // Toggle dropdown on button click trigger.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = select.classList.toggle('open'); trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); }); // Handle option selection options.forEach(option => { option.addEventListener('click', () => { const value = (option as HTMLElement).dataset.value; const label = option.textContent?.trim() || ''; // Update selected state options.forEach(o => o.classList.remove('selected')); option.classList.add('selected'); // Update trigger display const valueEl = trigger.querySelector('swp-select-value'); if (valueEl) { valueEl.textContent = label; } // Update color dot if present (for color pickers) const triggerDot = trigger.querySelector('swp-color-dot'); if (triggerDot && value) { triggerDot.className = `is-${value}`; } // Update data-value on select element (select as HTMLElement).dataset.value = value; // Close dropdown select.classList.remove('open'); trigger.setAttribute('aria-expanded', 'false'); // Dispatch custom event select.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value, label } })); }); }); }); // Click outside to close any open dropdown document.addEventListener('click', () => { this.closeAllSelects(); }); // Keyboard navigation document.addEventListener('keydown', (e) => { const openSelect = document.querySelector('swp-select.open'); if (!openSelect) return; // Escape to close if (e.key === 'Escape') { this.closeAllSelects(); return; } // Letter key to jump to option if (e.key.length === 1 && /[a-zA-ZæøåÆØÅ]/.test(e.key)) { const options = openSelect.querySelectorAll('swp-select-option'); const letter = e.key.toLowerCase(); for (const option of options) { const text = option.textContent?.trim().toLowerCase() || ''; if (text.startsWith(letter)) { // Scroll into view and highlight option.scrollIntoView({ block: 'nearest' }); options.forEach(o => o.classList.remove('highlighted')); option.classList.add('highlighted'); break; } } } // Enter to select highlighted option if (e.key === 'Enter') { const highlighted = openSelect.querySelector('swp-select-option.highlighted') as HTMLElement; if (highlighted) { highlighted.click(); } } }); } private closeAllSelects(): void { document.querySelectorAll('swp-select.open').forEach(select => { select.classList.remove('open'); select.querySelector('button')?.setAttribute('aria-expanded', 'false'); }); } /** * Initialize all time range sliders on the page * Each slider gets fill bar positioning, input handling, and drag behavior * Dispatches 'timerange:change' event when values change */ private initTimeRangeSliders(): void { document.querySelectorAll('swp-time-range-slider').forEach(slider => { new TimeRangeSlider(slider as HTMLElement); }); } } /** * TimeRangeSlider - Reusable dual-handle time range slider * * Features: * - Dual handle (start/end) range inputs * - Fill bar that shows selected range * - Drag fill bar to move entire range * - Label updates with times and duration * * Dispatches 'timerange:change' event with detail: * { start, end, startTime, endTime } */ class TimeRangeSlider { private static readonly TIME_RANGE_MAX = 60; // 15 hours (06:00-21:00) * 4 intervals private slider: HTMLElement; private startInput: HTMLInputElement | null; private endInput: HTMLInputElement | null; private fill: HTMLElement | null; private track: HTMLElement | null; constructor(slider: HTMLElement) { this.slider = slider; this.startInput = slider.querySelector('.range-start'); this.endInput = slider.querySelector('.range-end'); this.fill = slider.querySelector('swp-time-range-fill'); this.track = slider.querySelector('swp-time-range-track'); this.updateDisplay(); this.setupEventListeners(); this.setupDragBehavior(); } /** * Update the visual display (fill bar position, label text) */ private updateDisplay(): void { if (!this.startInput || !this.endInput || !this.fill) return; let startVal = parseInt(this.startInput.value); let endVal = parseInt(this.endInput.value); // Ensure start doesn't exceed end if (startVal > endVal) { if (this.startInput === document.activeElement) { this.startInput.value = String(endVal); startVal = endVal; } else { this.endInput.value = String(startVal); endVal = startVal; } } // Update fill bar position const startPercent = (startVal / TimeRangeSlider.TIME_RANGE_MAX) * 100; const endPercent = (endVal / TimeRangeSlider.TIME_RANGE_MAX) * 100; this.fill.style.left = startPercent + '%'; this.fill.style.width = (endPercent - startPercent) + '%'; // Update label if exists (inside parent swp-time-range) // Supports two patterns: // 1. Nested: swp-time-range-label > swp-time-range-times + swp-time-range-duration // 2. Simple: swp-time-range-label (direct text content) const parent = this.slider.closest('swp-time-range'); const labelEl = parent?.querySelector('swp-time-range-label'); const timesEl = parent?.querySelector('swp-time-range-times'); const durationEl = parent?.querySelector('swp-time-range-duration'); const timeText = `${this.valueToTime(startVal)} – ${this.valueToTime(endVal)}`; if (timesEl) { // Nested pattern (employee drawer) timesEl.textContent = timeText; } else if (labelEl) { // Simple pattern (services availability) labelEl.textContent = timeText; } if (durationEl) { const durationMinutes = (endVal - startVal) * 15; const durationHours = durationMinutes / 60; durationEl.textContent = durationHours % 1 === 0 ? `${durationHours} timer` : `${durationHours.toFixed(1).replace('.', ',')} timer`; } } /** * Dispatch change event for consumers */ private dispatchChange(): void { if (!this.startInput || !this.endInput) return; const startVal = parseInt(this.startInput.value); const endVal = parseInt(this.endInput.value); this.slider.dispatchEvent(new CustomEvent('timerange:change', { bubbles: true, detail: { start: startVal, end: endVal, startTime: this.valueToTime(startVal), endTime: this.valueToTime(endVal) } })); } /** * Setup input change listeners */ private setupEventListeners(): void { this.startInput?.addEventListener('input', () => { this.updateDisplay(); this.dispatchChange(); }); this.endInput?.addEventListener('input', () => { this.updateDisplay(); this.dispatchChange(); }); } /** * Setup drag behavior on fill bar to move entire range */ private setupDragBehavior(): void { if (!this.fill || !this.track || !this.startInput || !this.endInput) return; let isDragging = false; let dragStartX = 0; let dragStartValues = { start: 0, end: 0 }; this.fill.addEventListener('mousedown', (e: MouseEvent) => { isDragging = true; dragStartX = e.clientX; dragStartValues.start = parseInt(this.startInput!.value); dragStartValues.end = parseInt(this.endInput!.value); e.preventDefault(); }); document.addEventListener('mousemove', (e: MouseEvent) => { if (!isDragging) return; const sliderWidth = this.track!.offsetWidth; const deltaX = e.clientX - dragStartX; const deltaValue = Math.round((deltaX / sliderWidth) * TimeRangeSlider.TIME_RANGE_MAX); const duration = dragStartValues.end - dragStartValues.start; let newStart = dragStartValues.start + deltaValue; let newEnd = dragStartValues.end + deltaValue; if (newStart < 0) { newStart = 0; newEnd = duration; } if (newEnd > TimeRangeSlider.TIME_RANGE_MAX) { newEnd = TimeRangeSlider.TIME_RANGE_MAX; newStart = TimeRangeSlider.TIME_RANGE_MAX - duration; } this.startInput!.value = String(newStart); this.endInput!.value = String(newEnd); this.updateDisplay(); this.dispatchChange(); }); document.addEventListener('mouseup', () => { isDragging = false; }); } /** * Convert slider value to time string (e.g., 12 → "09:00") */ private valueToTime(value: number): string { const totalMinutes = value * 15 + 6 * 60; // Start at 06:00 const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; } }