PlanTempusApp/PlanTempus.Application/wwwroot/ts/modules/controls.ts
Janus C. H. Knudsen 7643a6ab82 Enhances service details with employees and addon sections
Adds new components for service employees and addons
Introduces detailed views with selectable employees and add-ons
Updates localization translations for new sections
Implements time range slider functionality for availability
2026-01-17 15:36:15 +01:00

339 lines
10 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.

/**
* 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')}`;
}
}