budgit/assets/js/timepicker.js
2026-04-12 16:07:06 +00:00

379 lines
12 KiB
JavaScript

(function () {
'use strict';
/**
* Reactive Binding for hidden inputs
*
* Problem: Setting input.value programmatically (e.g., via Datastar/Alpine)
* does NOT fire 'input' events - this is standard browser behavior since the 90s.
*
* Solution: Override the value setter to dispatch 'input' events on change.
* This is the same pattern used by Vue.js, MobX, and other reactive frameworks.
*/
function enableReactiveBinding(input) {
if (input._tui) return;
input._tui = true;
const desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
if (!desc?.set) return;
Object.defineProperty(input, 'value', {
get: desc.get,
set(v) {
const old = this.value;
desc.set.call(this, v);
if (old !== v) {
this.dispatchEvent(new Event('input', { bubbles: true }));
}
},
configurable: true
});
}
// Utility functions
function parseTime(str) {
const match = str?.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const [_, hour, minute] = match.map(Number);
return (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) ? { hour, minute } : null;
}
function formatTime(hour, minute, use12Hours) {
if (hour === null || minute === null) return null;
const pad = n => n.toString().padStart(2, '0');
if (use12Hours) {
const h = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${pad(h)}:${pad(minute)} ${hour >= 12 ? 'PM' : 'AM'}`;
}
return `${pad(hour)}:${pad(minute)}`;
}
function isValidTime(hour, minute, minTime, maxTime) {
if (!minTime && !maxTime) return true;
const timeInMinutes = hour * 60 + minute;
if (minTime) {
const minInMinutes = minTime.hour * 60 + minTime.minute;
if (timeInMinutes < minInMinutes) return false;
}
if (maxTime) {
const maxInMinutes = maxTime.hour * 60 + maxTime.minute;
if (timeInMinutes > maxInMinutes) return false;
}
return true;
}
// DOM helpers
function findRoot(element) {
return element?.closest('[data-tui-timepicker-root]') || null;
}
function findTrigger(element) {
return findRoot(element)?.querySelector('[data-tui-timepicker="true"]') || null;
}
function getElements(trigger) {
const root = findRoot(trigger);
const popup = root?.querySelector('[data-tui-timepicker-popup]');
if (!popup) return null;
return {
root,
trigger,
popup,
hourList: popup.querySelector('[data-tui-timepicker-hour-list]'),
minuteList: popup.querySelector('[data-tui-timepicker-minute-list]'),
hiddenInput: root?.querySelector('[data-tui-timepicker-hidden-input]')
};
}
function closePopover(trigger) {
const root = findRoot(trigger);
const popoverContent = root?.querySelector('[data-tui-popover-content]');
if (!popoverContent?.matches(':popover-open')) return;
try {
popoverContent.hidePopover();
} catch {
// ignore
}
}
// State management
function getState(trigger) {
return {
hour: trigger.dataset.tuiTimepickerCurrentHour ? parseInt(trigger.dataset.tuiTimepickerCurrentHour) : null,
minute: trigger.dataset.tuiTimepickerCurrentMinute ? parseInt(trigger.dataset.tuiTimepickerCurrentMinute) : null,
use12Hours: trigger.getAttribute('data-tui-timepicker-use12hours') === 'true',
step: parseInt(trigger.getAttribute('data-tui-timepicker-step') || '1'),
minTime: parseTime(trigger.getAttribute('data-tui-timepicker-min-time')),
maxTime: parseTime(trigger.getAttribute('data-tui-timepicker-max-time')),
placeholder: trigger.getAttribute('data-tui-timepicker-placeholder') || 'Select time'
};
}
function setState(trigger, hour, minute) {
if (hour !== null) {
trigger.dataset.tuiTimepickerCurrentHour = hour;
} else {
delete trigger.dataset.tuiTimepickerCurrentHour;
}
if (minute !== null) {
trigger.dataset.tuiTimepickerCurrentMinute = minute;
} else {
delete trigger.dataset.tuiTimepickerCurrentMinute;
}
updateDisplay(trigger);
}
// Display updates
function updateDisplay(trigger) {
const state = getState(trigger);
const elements = getElements(trigger);
// Update trigger display
const display = trigger.querySelector('[data-tui-timepicker-display]');
if (display) {
const formatted = formatTime(state.hour, state.minute, state.use12Hours);
display.textContent = formatted || state.placeholder;
display.classList.toggle('text-muted-foreground', !formatted);
}
// Update hidden input
if (elements?.hiddenInput) {
elements.hiddenInput.value = (state.hour !== null && state.minute !== null) ?
formatTime(state.hour, state.minute, false) : '';
}
// Update selections if popup is visible
if (elements?.hourList && elements?.minuteList) {
updateSelections(elements, state);
}
}
function updateSelections(elements, state) {
// Update hour buttons
elements.hourList.querySelectorAll('[data-tui-timepicker-hour]').forEach(btn => {
const hour = parseInt(btn.getAttribute('data-tui-timepicker-hour'));
let isSelected = false;
if (state.hour !== null) {
if (state.use12Hours) {
isSelected = (hour === state.hour) ||
(hour === 0 && state.hour === 12) ||
(hour === state.hour - 12 && state.hour > 12);
} else {
isSelected = hour === state.hour;
}
}
btn.setAttribute('data-tui-timepicker-selected', isSelected);
// Check validity
let valid = false;
for (let m = 0; m < 60; m++) {
if (isValidTime(hour, m, state.minTime, state.maxTime)) {
valid = true;
break;
}
}
btn.disabled = !valid;
btn.classList.toggle('opacity-50', !valid);
btn.classList.toggle('cursor-not-allowed', !valid);
});
// Update minute buttons
elements.minuteList.querySelectorAll('[data-tui-timepicker-minute]').forEach(btn => {
const minute = parseInt(btn.getAttribute('data-tui-timepicker-minute'));
const isSelected = minute === state.minute;
const valid = state.hour === null || isValidTime(state.hour, minute, state.minTime, state.maxTime);
btn.setAttribute('data-tui-timepicker-selected', isSelected);
btn.disabled = !valid;
btn.classList.toggle('opacity-50', !valid);
btn.classList.toggle('cursor-not-allowed', !valid);
});
// Update AM/PM buttons
const amBtn = elements.popup.querySelector('[data-tui-timepicker-period="AM"]');
const pmBtn = elements.popup.querySelector('[data-tui-timepicker-period="PM"]');
if (amBtn && pmBtn) {
const isAM = state.hour === null || state.hour < 12;
amBtn.setAttribute('data-tui-timepicker-active', isAM);
pmBtn.setAttribute('data-tui-timepicker-active', !isAM);
}
}
// Event handlers
document.addEventListener('click', (e) => {
const target = e.target;
// Hour selection
if (target.matches('[data-tui-timepicker-hour]') && !target.disabled) {
const trigger = findTrigger(target);
if (!trigger) return;
const state = getState(trigger);
let hour = parseInt(target.getAttribute('data-tui-timepicker-hour'));
if (state.use12Hours) {
const isPM = state.hour !== null && state.hour >= 12;
hour = hour === 0 ? (isPM ? 12 : 0) : (isPM ? hour + 12 : hour);
}
if (!isValidTime(hour, state.minute, state.minTime, state.maxTime)) {
// Find first valid minute
for (let m = 0; m < 60; m += state.step) {
if (isValidTime(hour, m, state.minTime, state.maxTime)) {
setState(trigger, hour, m);
return;
}
}
} else {
setState(trigger, hour, state.minute);
}
return;
}
// Minute selection
if (target.matches('[data-tui-timepicker-minute]') && !target.disabled) {
const trigger = findTrigger(target);
if (!trigger) return;
const state = getState(trigger);
const minute = parseInt(target.getAttribute('data-tui-timepicker-minute'));
if (state.hour === null || isValidTime(state.hour, minute, state.minTime, state.maxTime)) {
setState(trigger, state.hour, minute);
}
return;
}
// AM/PM selection
if (target.matches('[data-tui-timepicker-period]')) {
const trigger = findTrigger(target);
if (!trigger) return;
const state = getState(trigger);
if (state.hour === null) return;
const period = target.getAttribute('data-tui-timepicker-period');
let newHour = state.hour;
if (period === 'AM' && state.hour >= 12) {
newHour = state.hour === 12 ? 0 : state.hour - 12;
} else if (period === 'PM' && state.hour < 12) {
newHour = state.hour === 0 ? 12 : state.hour + 12;
}
if (newHour !== state.hour) {
if (!isValidTime(newHour, state.minute, state.minTime, state.maxTime)) {
// Find first valid minute
for (let m = 0; m < 60; m += state.step) {
if (isValidTime(newHour, m, state.minTime, state.maxTime)) {
setState(trigger, newHour, m);
return;
}
}
} else {
setState(trigger, newHour, state.minute);
}
}
return;
}
// Done button
if (target.matches('[data-tui-timepicker-done]')) {
const trigger = findTrigger(target);
if (trigger) {
closePopover(trigger);
}
return;
}
});
// Handle hidden input value changes (for reactive frameworks)
document.addEventListener('input', (e) => {
if (!e.target.matches('[data-tui-timepicker-hidden-input]')) return;
const trigger = findTrigger(e.target);
if (trigger) {
const parsed = parseTime(e.target.value);
if (parsed) {
setState(trigger, parsed.hour, parsed.minute);
} else {
setState(trigger, null, null);
}
}
});
// Form reset
document.addEventListener('reset', (e) => {
if (!e.target.matches('form')) return;
e.target.querySelectorAll('[data-tui-timepicker-root]').forEach(root => {
const trigger = root.querySelector('[data-tui-timepicker="true"]');
if (!trigger) return;
setState(trigger, null, null);
const elements = getElements(trigger);
if (elements?.hiddenInput) {
elements.hiddenInput.value = '';
}
});
});
// Initialize timepickers
function initializeTimePickers() {
document.querySelectorAll('[data-tui-timepicker-root]').forEach(root => {
const trigger = root.querySelector('[data-tui-timepicker="true"]');
const hiddenInput = root.querySelector('[data-tui-timepicker-hidden-input]');
if (!trigger) return;
if (!hiddenInput || hiddenInput._tui) return;
// Read initial value from hidden input
const initialValue = hiddenInput.value;
if (initialValue) {
const parsed = parseTime(initialValue);
if (parsed) {
setState(trigger, parsed.hour, parsed.minute);
}
}
// Enable reactive binding for hidden input
enableReactiveBinding(hiddenInput);
updateDisplay(trigger);
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeTimePickers);
} else {
initializeTimePickers();
}
// MutationObserver for dynamically added elements
new MutationObserver(initializeTimePickers).observe(document.body, { childList: true, subtree: true });
// Scroll to selected values when timepicker popover opens
new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.target.getAttribute('data-tui-popover-open') !== 'true') continue;
const popup = m.target.querySelector('[data-tui-timepicker-popup]');
if (!popup) continue;
requestAnimationFrame(() => {
popup.querySelector('[data-tui-timepicker-hour-list] [data-tui-timepicker-selected="true"]')?.scrollIntoView({ block: 'center' });
popup.querySelector('[data-tui-timepicker-minute-list] [data-tui-timepicker-selected="true"]')?.scrollIntoView({ block: 'center' });
});
}
}).observe(document.body, { attributes: true, attributeFilter: ['data-tui-popover-open'], subtree: true });
})();