Packages
noora
0.57.0
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
js/DatePicker/DateInputHandler.js
import { formatDateParts, parseDateFromParts } from "./dateUtils.js";
/**
* Handles date input fields (DD/MM/YYYY) for a date picker.
* Manages event listeners, validation, and keyboard navigation.
*/
export class DateInputHandler {
/**
* @param {HTMLElement} rootEl - Root element containing date displays
* @param {object} options
* @param {function} options.onDateChange - Callback when a valid date is entered
* @param {function} options.getCurrentValue - Get current date value for restoration
* @param {{year: number, month: number, day: number}|null} options.minDate
* @param {{year: number, month: number, day: number}|null} options.maxDate
*/
constructor(rootEl, options) {
this.rootEl = rootEl;
this.onDateChange = options.onDateChange;
this.getCurrentValue = options.getCurrentValue;
this.minDate = options.minDate;
this.maxDate = options.maxDate;
this.abortController = new AbortController();
const startDisplay = this.rootEl.querySelector(
"[data-part='date-display'][data-type='start']",
);
const endDisplay = this.rootEl.querySelector(
"[data-part='date-display'][data-type='end']",
);
if (startDisplay) {
this.attachToContainer(startDisplay, "start");
}
if (endDisplay) {
this.attachToContainer(endDisplay, "end");
}
}
/**
* Attach handlers to inputs within a container.
* @param {HTMLElement} container
* @param {string} type - "start" or "end"
*/
attachToContainer(container, type) {
const inputs = container.querySelectorAll("[data-part='date-input']");
const signal = this.abortController.signal;
inputs.forEach((input) => {
// Handle blur to parse and apply the date
input.addEventListener(
"blur",
() => this.handleFieldChange(container, type),
{ signal },
);
// Handle keyboard navigation
input.addEventListener(
"keydown",
(e) => this.handleKeydown(e, input, container, type),
{ signal },
);
// Auto-advance to next field when maxlength reached
input.addEventListener(
"input",
() => this.handleInput(input, container, type),
{ signal },
);
});
}
/**
* Handle keydown for navigation between fields.
*/
handleKeydown(e, input, container, type) {
if (e.key === "Enter") {
e.preventDefault();
this.handleFieldChange(container, type);
input.blur();
return;
}
const cursorPos = input.selectionStart;
const valueLen = input.value.length;
const inputs = Array.from(
container.querySelectorAll("[data-part='date-input']"),
);
const currentIndex = inputs.indexOf(input);
// Left arrow at start of field -> go to previous field
if (e.key === "ArrowLeft" && cursorPos === 0) {
e.preventDefault();
if (currentIndex > 0) {
const prevInput = inputs[currentIndex - 1];
prevInput.focus();
prevInput.setSelectionRange(
prevInput.value.length,
prevInput.value.length,
);
} else if (type === "end") {
// Jump to start date's last field
const startDisplay = this.rootEl.querySelector(
"[data-part='date-display'][data-type='start']",
);
if (startDisplay) {
const startInputs = startDisplay.querySelectorAll(
"[data-part='date-input']",
);
const lastInput = startInputs[startInputs.length - 1];
if (lastInput) {
lastInput.focus();
lastInput.setSelectionRange(
lastInput.value.length,
lastInput.value.length,
);
}
}
}
return;
}
// Right arrow at end of field -> go to next field
if (e.key === "ArrowRight" && cursorPos === valueLen) {
e.preventDefault();
if (currentIndex < inputs.length - 1) {
const nextInput = inputs[currentIndex + 1];
nextInput.focus();
nextInput.setSelectionRange(0, 0);
} else if (type === "start") {
// Jump to end date's first field
const endDisplay = this.rootEl.querySelector(
"[data-part='date-display'][data-type='end']",
);
if (endDisplay) {
const firstInput = endDisplay.querySelector(
"[data-part='date-input']",
);
if (firstInput) {
firstInput.focus();
firstInput.setSelectionRange(0, 0);
}
}
}
}
}
/**
* Handle input event for auto-advance.
*/
handleInput(input, container, type) {
if (input.value.length >= parseInt(input.maxLength, 10)) {
const inputs = Array.from(
container.querySelectorAll("[data-part='date-input']"),
);
const currentIndex = inputs.indexOf(input);
if (currentIndex < inputs.length - 1) {
inputs[currentIndex + 1].focus();
} else {
// Last field (year) complete - update the calendar immediately
this.handleFieldChange(container, type);
}
}
}
/**
* Handle field change - validate and apply the date.
*/
handleFieldChange(container, type) {
const dayInput = container.querySelector("[data-field='day']");
const monthInput = container.querySelector("[data-field='month']");
const yearInput = container.querySelector("[data-field='year']");
const day = dayInput?.value || "";
const month = monthInput?.value || "";
const year = yearInput?.value || "";
// Don't validate if fields are incomplete
if (!day || !month || !year || year.length < 4) return;
const parsed = parseDateFromParts(day, month, year);
if (!parsed) {
this.restoreInputs(container, type);
return;
}
// Check against min/max constraints
if (this.minDate) {
const minJs = new Date(
this.minDate.year,
this.minDate.month - 1,
this.minDate.day,
);
if (parsed < minJs) {
this.restoreInputs(container, type);
return;
}
}
if (this.maxDate) {
const maxJs = new Date(
this.maxDate.year,
this.maxDate.month - 1,
this.maxDate.day,
);
if (parsed > maxJs) {
this.restoreInputs(container, type);
return;
}
}
this.onDateChange?.(parsed, type);
}
/**
* Restore input values from current selection.
*/
restoreInputs(container, type) {
const value = this.getCurrentValue?.();
let parts;
if (type === "start" && value && value[0]) {
parts = formatDateParts(value[0].toDate());
} else if (type === "end" && value && value[1]) {
parts = formatDateParts(value[1].toDate());
} else {
parts = { day: "", month: "", year: "" };
}
const dayInput = container.querySelector("[data-field='day']");
const monthInput = container.querySelector("[data-field='month']");
const yearInput = container.querySelector("[data-field='year']");
if (dayInput) dayInput.value = parts.day;
if (monthInput) monthInput.value = parts.month;
if (yearInput) yearInput.value = parts.year;
}
/**
* Update input values from date parts (only if not focused).
*/
updateInputs(container, parts) {
const dayInput = container.querySelector("[data-field='day']");
const monthInput = container.querySelector("[data-field='month']");
const yearInput = container.querySelector("[data-field='year']");
if (dayInput && document.activeElement !== dayInput) {
dayInput.value = parts.day;
}
if (monthInput && document.activeElement !== monthInput) {
monthInput.value = parts.month;
}
if (yearInput && document.activeElement !== yearInput) {
yearInput.value = parts.year;
}
}
/**
* Clean up all event listeners.
*/
destroy() {
this.abortController.abort();
}
}