Packages
noora
0.82.2
0.85.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/index.js
import * as datePicker from "@zag-js/date-picker";
import {
getOption,
getBooleanOption,
normalizeProps,
spreadProps,
renderPart,
} from "../util.js";
import { Component } from "../component.js";
import { VanillaMachine } from "../machine.js";
import {
calculateRangeFromDuration,
formatDateParts,
compareDates,
parseISODate,
toISODateString,
calculateWeeksForMonth,
getVisibleRangeForMonth,
} from "./dateUtils.js";
import { CalendarNavigation } from "./CalendarNavigation.js";
import { DateInputHandler } from "./DateInputHandler.js";
import { WEEKDAYS, MOBILE_BREAKPOINT } from "./constants.js";
class DatePicker extends Component {
constructor(el, props) {
super(el, props);
this.presets = props.presets || [];
this.selectedPreset = props.selectedPreset;
this.isMobileView = window.innerWidth < MOBILE_BREAKPOINT;
this.pendingRange = null;
this.isSettingPreset = false;
this.minDate = props.min ? datePicker.parse(props.min) : null;
this.maxDate = props.max ? datePicker.parse(props.max) : null;
// Calendar navigation manager - initialized with current value
const value = this.api.value;
this.calendarNav = new CalendarNavigation(
value && value[0],
value && value[1],
() => this.render(),
);
// AbortController for component-level event listeners
this.abortController = new AbortController();
this.setupNavigationHandlers();
}
setupNavigationHandlers() {
const signal = this.abortController.signal;
// Use event delegation on month elements
const months = this.el.querySelectorAll(
"[data-part='months'] > [data-part='month']",
);
months.forEach((monthEl, monthIndex) => {
const prevTrigger = monthEl.querySelector("[data-part='prev-trigger']");
const nextTrigger = monthEl.querySelector("[data-part='next-trigger']");
if (prevTrigger) {
prevTrigger.addEventListener(
"click",
() => {
if (monthIndex === 0) {
this.calendarNav.navigatePrevStart();
} else {
this.calendarNav.navigatePrevEnd();
}
},
{ signal },
);
}
if (nextTrigger) {
nextTrigger.addEventListener(
"click",
() => {
if (monthIndex === 0) {
this.calendarNav.navigateNextStart();
} else {
this.calendarNav.navigateNextEnd();
}
},
{ signal },
);
}
});
// Event delegation for preset buttons
this.el.addEventListener(
"click",
(e) => {
const presetItem = e.target.closest("[data-part='preset-item']");
if (presetItem) {
const presetId = presetItem.dataset.presetId;
this.handlePresetClickById(presetId);
}
},
{ signal },
);
}
initMachine(context) {
const forceOpen = getBooleanOption(this.el, "open");
const isMobile = window.innerWidth < MOBILE_BREAKPOINT;
const minDate = context.min ? datePicker.parse(context.min) : null;
const maxDate = context.max ? datePicker.parse(context.max) : null;
const defaultValue = this.getDefaultValueFromPreset(
context.presets,
context.selectedPreset,
context.periodStart,
context.periodEnd,
);
const isDateUnavailable = (date) => {
if (minDate) {
if (date.year < minDate.year) return true;
if (date.year === minDate.year && date.month < minDate.month)
return true;
if (
date.year === minDate.year &&
date.month === minDate.month &&
date.day < minDate.day
)
return true;
}
if (maxDate) {
if (date.year > maxDate.year) return true;
if (date.year === maxDate.year && date.month > maxDate.month)
return true;
if (
date.year === maxDate.year &&
date.month === maxDate.month &&
date.day > maxDate.day
)
return true;
}
return false;
};
const machineContext = {
...context,
selectionMode: "range",
numOfMonths: isMobile ? 1 : 2,
fixedWeeks: true,
closeOnSelect: false,
open: forceOpen || undefined,
defaultValue: defaultValue || undefined,
min: minDate || undefined,
max: maxDate || undefined,
isDateUnavailable,
positioning: {
zIndex: 50,
offset: { mainAxis: 0 },
},
onValueChange: () => {
if (!this.isSettingPreset && this.selectedPreset !== "custom") {
this.selectedPreset = "custom";
this.updatePresetSelection("custom");
}
},
};
return new VanillaMachine(datePicker.machine, machineContext);
}
getDefaultValueFromPreset(presets, selectedPreset, periodStart, periodEnd) {
if (periodStart && periodEnd) {
const startParsed = parseISODate(periodStart);
const endParsed = parseISODate(periodEnd);
if (startParsed && endParsed) {
const startDate = datePicker.parse(
`${startParsed.year}-${String(startParsed.month).padStart(2, "0")}-${String(startParsed.day).padStart(2, "0")}`,
);
const endDate = datePicker.parse(
`${endParsed.year}-${String(endParsed.month).padStart(2, "0")}-${String(endParsed.day).padStart(2, "0")}`,
);
return [startDate, endDate];
}
}
const preset = presets.find((p) => p.id === selectedPreset);
if (!preset || !preset.period) return null;
const range = calculateRangeFromDuration(preset.period);
return [
datePicker.parse(toISODateString(range.start)),
datePicker.parse(toISODateString(range.end)),
];
}
initApi() {
return datePicker.connect(this.machine.service, normalizeProps);
}
open() {
this.api.setOpen(true);
}
close() {
this.api.setOpen(false);
}
setupResizeListener() {
const handler = () => {
const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT;
if (newIsMobile !== this.isMobileView) {
this.isMobileView = newIsMobile;
this.render();
}
};
window.addEventListener("resize", handler, {
signal: this.abortController.signal,
});
}
handlePresetClickById(presetId) {
const preset = this.presets.find((p) => p.id === presetId);
if (!preset) return;
this.selectedPreset = presetId;
this.updatePresetSelection(presetId);
if (preset.period) {
const range = calculateRangeFromDuration(preset.period);
const startDate = datePicker.parse(toISODateString(range.start));
const endDate = datePicker.parse(toISODateString(range.end));
if (startDate && endDate && this.api.setValue) {
this.isSettingPreset = true;
this.api.setValue([startDate, endDate]);
this.calendarNav.updateForSelection(startDate, endDate);
this.api = this.initApi();
this.render();
// Defer flag reset to ensure onValueChange callbacks have fired
queueMicrotask(() => {
this.isSettingPreset = false;
});
}
}
}
handleCancel() {
this.pendingRange = null;
this.close();
if (this.el.dataset.onCancel) {
this.pushEvent(this.el.dataset.onCancel, {});
}
}
handleApply() {
const value = this.api.value;
if (value && value.length >= 2) {
const startDate = value[0].toDate();
const endDate = value[1].toDate();
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
this.emitValueChange(startDate, endDate, this.selectedPreset);
this.close();
}
}
emitValueChange(start, end, preset) {
if (this.el.dataset.onPeriodChange) {
this.pushEvent(this.el.dataset.onPeriodChange, {
value: {
start: start.toISOString(),
end: end.toISOString(),
},
preset: preset,
});
}
}
updatePresetSelection(selectedId) {
if (!selectedId) return;
const presetButtons = this.el.querySelectorAll("[data-part='preset-item']");
presetButtons.forEach((btn) => {
const isSelected = btn.dataset.presetId === selectedId;
if (isSelected) {
btn.setAttribute("data-selected", "true");
} else {
btn.removeAttribute("data-selected");
}
});
}
handleDateInputChange(parsed, type) {
const dateStr = toISODateString(parsed);
const newDateValue = datePicker.parse(dateStr);
if (!newDateValue) return;
const currentValue = this.api.value || [];
let newValue;
if (type === "start") {
const endDate = currentValue[1] || newDateValue;
if (compareDates(newDateValue, endDate) > 0) {
newValue = [newDateValue, newDateValue];
} else {
newValue = [newDateValue, endDate];
}
} else {
const startDate = currentValue[0] || newDateValue;
if (compareDates(newDateValue, startDate) < 0) {
newValue = [newDateValue, newDateValue];
} else {
newValue = [startDate, newDateValue];
}
}
if (this.selectedPreset !== "custom") {
this.selectedPreset = "custom";
this.updatePresetSelection("custom");
}
this.isSettingPreset = true;
this.api.setValue(newValue);
this.calendarNav.updateForSelection(newValue[0], newValue[1]);
this.api = this.initApi();
this.render();
// Defer flag reset to ensure onValueChange callbacks have fired
queueMicrotask(() => {
this.isSettingPreset = false;
});
}
render() {
// Track partial selection state for CSS styling
const value = this.api.value;
const isSelectingRange = value && value.length === 1;
if (isSelectingRange) {
this.el.setAttribute("data-selecting-range", "");
} else {
this.el.removeAttribute("data-selecting-range");
}
this.renderTriggerAndPositioner();
this.renderMonths();
this.renderRangeDisplay();
this.hideSecondMonthOnMobile();
}
renderTriggerAndPositioner() {
renderPart(this.el, "control", this.api);
renderPart(this.el, "control:trigger", this.api);
renderPart(this.el, "positioner", this.api);
renderPart(this.el, "positioner:content", this.api);
// Override Zag's z-index (positioning config doesn't always apply)
const positioner = this.el.querySelector("[data-part='positioner']");
if (positioner) {
positioner.style.zIndex = "1000";
}
}
renderMonths() {
const months = this.el.querySelectorAll(
"[data-part='months'] > [data-part='month']",
);
const minDate = parseISODate(this.el.dataset.min);
const maxDate = parseISODate(this.el.dataset.max);
months.forEach((monthEl, monthIndex) => {
if (this.isMobileView && monthIndex > 0) return;
const calendarMonth =
monthIndex === 0
? this.calendarNav.startCalendarMonth
: this.calendarNav.endCalendarMonth;
if (!calendarMonth) return;
this.renderMonthHeader(monthEl, calendarMonth);
this.renderNavigationButtons(monthEl, monthIndex, minDate, maxDate);
this.renderWeekdayHeaders(monthEl);
this.renderDayCells(monthEl, calendarMonth, minDate, maxDate);
});
}
renderMonthHeader(monthEl, calendarMonth) {
const viewTrigger = monthEl.querySelector("[data-part='view-trigger']");
if (viewTrigger) {
const date = new Date(calendarMonth.year, calendarMonth.month - 1, 1);
const monthName = date.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
viewTrigger.textContent = monthName;
}
}
renderNavigationButtons(monthEl, monthIndex, minDate, maxDate) {
const prevTrigger = monthEl.querySelector("[data-part='prev-trigger']");
const nextTrigger = monthEl.querySelector("[data-part='next-trigger']");
const canGoPrev =
monthIndex === 0
? this.calendarNav.canNavigatePrevStart(minDate)
: this.calendarNav.canNavigatePrevEnd();
const canGoNext =
monthIndex === 0
? this.calendarNav.canNavigateNextStart()
: this.calendarNav.canNavigateNextEnd(maxDate);
if (prevTrigger) {
prevTrigger.disabled = !canGoPrev;
if (canGoPrev) {
prevTrigger.removeAttribute("data-disabled");
} else {
prevTrigger.setAttribute("data-disabled", "");
}
}
if (nextTrigger) {
nextTrigger.disabled = !canGoNext;
if (canGoNext) {
nextTrigger.removeAttribute("data-disabled");
} else {
nextTrigger.setAttribute("data-disabled", "");
}
}
}
renderWeekdayHeaders(monthEl) {
const headerCells = monthEl.querySelectorAll("[data-part='table-header']");
const startOfWeek = parseInt(this.el.dataset.startOfWeek || "0", 10);
headerCells.forEach((cell, i) => {
const dayIndex = (startOfWeek + i) % 7;
cell.textContent = WEEKDAYS[dayIndex];
});
}
renderDayCells(monthEl, calendarMonth, minDate, maxDate) {
const startOfWeek = parseInt(this.el.dataset.startOfWeek || "0", 10);
const monthWeeks = calculateWeeksForMonth(
calendarMonth.year,
calendarMonth.month,
startOfWeek,
);
const monthVisibleRange = getVisibleRangeForMonth(
calendarMonth.year,
calendarMonth.month,
);
const selectedValue = this.api.value;
const rangeStart = selectedValue && selectedValue[0];
const rangeEnd = selectedValue && selectedValue[1];
const today = new Date();
const todayValue = {
year: today.getFullYear(),
month: today.getMonth() + 1,
day: today.getDate(),
};
const rows = monthEl.querySelectorAll(
"[data-part='table-body'] [data-part='table-row']",
);
rows.forEach((row, weekIndex) => {
const cells = row.querySelectorAll("td");
const week = monthWeeks[weekIndex];
cells.forEach((cell, dayIndex) => {
const trigger = cell.querySelector("[data-part='table-cell-trigger']");
if (!trigger) return;
if (week && week[dayIndex]) {
const day = week[dayIndex];
this.renderDayCell(
cell,
trigger,
day,
monthVisibleRange,
rangeStart,
rangeEnd,
todayValue,
minDate,
maxDate,
);
} else {
trigger.textContent = "";
trigger.setAttribute("data-hidden", "");
cell.setAttribute("data-hidden", "");
}
});
});
}
renderDayCell(
cell,
trigger,
day,
monthVisibleRange,
rangeStart,
rangeEnd,
todayValue,
minDate,
maxDate,
) {
trigger.textContent = day.day;
// Clear stale state
trigger.removeAttribute("data-disabled");
trigger.removeAttribute("data-unavailable");
trigger.removeAttribute("aria-disabled");
trigger.removeAttribute("data-outside-range");
trigger.removeAttribute("data-today");
trigger.disabled = false;
cell.removeAttribute("data-disabled");
cell.removeAttribute("aria-disabled");
// Get base props from Zag API
const { id: _triggerId, ...dayProps } =
this.api.getDayTableCellTriggerProps({
value: day,
visibleRange: monthVisibleRange,
});
const { id: _cellId, ...cellProps } = this.api.getDayTableCellProps({
value: day,
visibleRange: monthVisibleRange,
});
spreadProps(trigger, dayProps);
spreadProps(cell, cellProps);
// Handle range selection display
if (rangeStart && rangeEnd) {
const isRangeStart = compareDates(day, rangeStart) === 0;
const isRangeEnd = compareDates(day, rangeEnd) === 0;
const isInRange =
compareDates(day, rangeStart) >= 0 && compareDates(day, rangeEnd) <= 0;
if (isRangeStart) {
trigger.setAttribute("data-selected", "");
trigger.setAttribute("data-range-start", "");
cell.setAttribute("data-range-start", "");
}
if (isRangeEnd) {
trigger.setAttribute("data-selected", "");
trigger.setAttribute("data-range-end", "");
cell.setAttribute("data-range-end", "");
}
if (isInRange) {
trigger.setAttribute("data-in-range", "");
cell.setAttribute("data-in-range", "");
}
} else if (rangeStart && !rangeEnd) {
const isRangeStart = compareDates(day, rangeStart) === 0;
if (isRangeStart) {
trigger.setAttribute("data-selected", "");
trigger.setAttribute("data-range-start", "");
cell.setAttribute("data-range-start", "");
}
}
// Mark today
if (compareDates(day, todayValue) === 0) {
trigger.setAttribute("data-today", "");
}
// Disable dates outside min/max range
const isBeforeMin = minDate && compareDates(day, minDate) < 0;
const isAfterMax = maxDate && compareDates(day, maxDate) > 0;
if (isBeforeMin || isAfterMax) {
trigger.setAttribute("data-disabled", "true");
trigger.setAttribute("data-unavailable", "true");
trigger.setAttribute("aria-disabled", "true");
trigger.disabled = true;
cell.setAttribute("data-disabled", "true");
cell.setAttribute("aria-disabled", "true");
}
trigger.removeAttribute("data-hidden");
cell.removeAttribute("data-hidden");
}
renderRangeDisplay() {
const value = this.api.value;
const startDisplay = this.el.querySelector(
"[data-part='date-display'][data-type='start']",
);
if (startDisplay) {
const parts =
value && value[0]
? formatDateParts(value[0].toDate())
: formatDateParts(null);
if (this.dateInputHandler) {
this.dateInputHandler.updateInputs(startDisplay, parts);
}
}
const endDisplay = this.el.querySelector(
"[data-part='date-display'][data-type='end']",
);
if (endDisplay) {
const parts =
value && value[1]
? formatDateParts(value[1].toDate())
: formatDateParts(null);
if (this.dateInputHandler) {
this.dateInputHandler.updateInputs(endDisplay, parts);
}
}
}
hideSecondMonthOnMobile() {
if (this.isMobileView) {
this.el.setAttribute("data-mobile", "");
} else {
this.el.removeAttribute("data-mobile");
}
}
destroy() {
super.destroy();
this.abortController.abort();
this.dateInputHandler?.destroy();
}
}
export default {
mounted() {
this.datePicker = new DatePicker(this.el, this.context());
this.datePicker.pushEvent = (event, payload) =>
this.pushEvent(event, payload);
this.datePicker.init();
// Initialize date input handler
this.datePicker.dateInputHandler = new DateInputHandler(this.el, {
onDateChange: (parsed, type) =>
this.datePicker.handleDateInputChange(parsed, type),
getCurrentValue: () => this.datePicker.api.value,
minDate: this.datePicker.minDate,
maxDate: this.datePicker.maxDate,
});
// Cancel event handler
this.handleCancelEvent = (event) => {
if (event.detail.id === this.el.id) {
this.datePicker.handleCancel();
}
};
window.addEventListener("phx:date-picker-cancel", this.handleCancelEvent);
// Apply event handler
this.handleApplyEvent = (event) => {
if (event.detail.id === this.el.id) {
this.datePicker.handleApply();
}
};
window.addEventListener("phx:date-picker-apply", this.handleApplyEvent);
// Outside click handler
this.handleOutsideClick = (event) => {
if (!this.datePicker.api.open) return;
const trigger = this.el.querySelector("[data-part='trigger']");
const content = this.el.querySelector("[data-part='content']");
const clickedOutside =
trigger &&
!trigger.contains(event.target) &&
(!content || !content.contains(event.target));
if (clickedOutside) {
this.datePicker.api.setOpen(false);
}
};
document.addEventListener("mousedown", this.handleOutsideClick);
// Resize handler for responsive layout
this.datePicker.setupResizeListener();
},
updated() {
this.datePicker.render();
},
beforeDestroy() {
this.datePicker.destroy();
},
destroyed() {
window.removeEventListener(
"phx:date-picker-cancel",
this.handleCancelEvent,
);
window.removeEventListener("phx:date-picker-apply", this.handleApplyEvent);
document.removeEventListener("mousedown", this.handleOutsideClick);
},
context() {
const presetsJson = this.el.dataset.presets;
const presets = presetsJson ? JSON.parse(presetsJson) : [];
return {
id: this.el.id,
startOfWeek: parseInt(getOption(this.el, "startOfWeek") || "0", 10),
disabled: getBooleanOption(this.el, "disabled"),
presets,
selectedPreset: getOption(this.el, "selectedPreset"),
periodStart: getOption(this.el, "periodStart"),
periodEnd: getOption(this.el, "periodEnd"),
min: getOption(this.el, "min"),
max: getOption(this.el, "max"),
};
},
};