Updated Date Picker and All New Pickers
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
---
|
||||
// DatePicker.astro - Updated with year dropdown and auto validation
|
||||
export interface Props {
|
||||
label?: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
min?: string;
|
||||
max?: string;
|
||||
format?: "yyyy-mm-dd" | "dd/mm/yyyy" | "mm/dd/yyyy";
|
||||
firstDayOfWeek?: 0 | 1;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label = "Select Date",
|
||||
description,
|
||||
name,
|
||||
value = "",
|
||||
placeholder = "Select date",
|
||||
required = false,
|
||||
disabled = false,
|
||||
min,
|
||||
max,
|
||||
format = "yyyy-mm-dd",
|
||||
firstDayOfWeek = 1,
|
||||
class: className = "",
|
||||
} = Astro.props;
|
||||
|
||||
const fieldId = `field-${name.replace(/[[\]]/g, "-")}`;
|
||||
---
|
||||
|
||||
<div class={`wr:space-y-2 ${className}`} data-field={name}>
|
||||
<label
|
||||
for={fieldId}
|
||||
class="wr:block wr:text-sm wr:font-medium wr:text-foreground"
|
||||
>
|
||||
{label}
|
||||
{required && <span class="wr:text-danger wr:ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{
|
||||
description && (
|
||||
<p class="wr:text-muted-foreground wr:text-sm wr:leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="wr:relative" id={fieldId}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
id={`${fieldId}-hidden`}
|
||||
data-control
|
||||
value={value}
|
||||
required={required}
|
||||
/>
|
||||
|
||||
<div class="wr:relative">
|
||||
<input
|
||||
type="text"
|
||||
id={`${fieldId}-display`}
|
||||
class="wr:w-full wr:px-3 wr:py-2 wr:pr-10 wr:border wr:border-border wr:rounded-lg wr:bg-background wr:text-foreground focus:wr:ring-2 focus:wr:ring-primary focus:wr:border-primary disabled:wr:opacity-50"
|
||||
placeholder={placeholder}
|
||||
readonly
|
||||
disabled={disabled}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
role="combobox"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-trigger`}
|
||||
class="wr:absolute wr:right-0 wr:top-0 wr:h-full wr:px-3 wr:flex wr:items-center wr:text-muted-foreground hover:wr:text-foreground disabled:wr:opacity-50"
|
||||
disabled={disabled}
|
||||
aria-label="Open calendar"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"
|
||||
></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`${fieldId}-popover`}
|
||||
class="wr:hidden wr:absolute wr:top-full wr:left-0 wr:mt-1 wr:bg-card wr:border wr:border-border wr:rounded-lg wr:shadow-lg wr:z-50 wr:min-w-[280px]"
|
||||
role="dialog"
|
||||
aria-label="Calendar"
|
||||
>
|
||||
<div
|
||||
class="wr:flex wr:items-center wr:justify-between wr:p-3 wr:border-b wr:border-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-prev-month`}
|
||||
class="wr:p-1 wr:rounded wr:hover:wr:bg-muted"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="15,18 9,12 15,6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="wr:flex wr:gap-2">
|
||||
<select
|
||||
id={`${fieldId}-month-select`}
|
||||
class="wr:bg-card wr:border-none wr:text-sm wr:font-medium wr:text-foreground focus:wr:outline-none"
|
||||
></select>
|
||||
<!-- UPDATED: Year dropdown instead of input -->
|
||||
<select
|
||||
id={`${fieldId}-year-select`}
|
||||
class="wr:bg-card wr:border-none wr:text-sm wr:font-medium wr:text-foreground focus:wr:outline-none"
|
||||
></select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-next-month`}
|
||||
class="wr:p-1 wr:rounded wr:hover:wr:bg-muted"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="9,18 15,12 9,6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wr:p-3">
|
||||
<div class="wr:grid wr:grid-cols-7 wr:gap-1 wr:mb-2">
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Sun
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Mon
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Tue
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Wed
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Thu
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Fri
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Sat
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id={`${fieldId}-calendar-grid`}
|
||||
class="wr:grid wr:grid-cols-7 wr:gap-1"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wr:flex wr:justify-center wr:p-3 wr:border-t wr:border-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-today`}
|
||||
class="wr:px-3 wr:py-1 wr:text-sm wr:text-primary hover:wr:bg-primary/10 wr:rounded"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`${fieldId}-err`}
|
||||
class="wr:text-sm wr:text-danger wr:mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
name,
|
||||
fieldId,
|
||||
value,
|
||||
format,
|
||||
min,
|
||||
max,
|
||||
firstDayOfWeek,
|
||||
disabled,
|
||||
}}
|
||||
>
|
||||
class DatePicker {
|
||||
constructor() {
|
||||
this.name = name;
|
||||
this.fieldId = fieldId;
|
||||
this.format = format;
|
||||
this.min = min ? new Date(min) : null;
|
||||
this.max = max ? new Date(max) : null;
|
||||
this.firstDayOfWeek = firstDayOfWeek;
|
||||
this.disabled = disabled;
|
||||
this.selectedDate = value ? new Date(value) : null;
|
||||
this.currentMonth = this.selectedDate || new Date();
|
||||
this.isOpen = false;
|
||||
|
||||
this.container = document.getElementById(fieldId);
|
||||
this.hiddenInput = this.container.querySelector(
|
||||
`#${fieldId}-hidden`,
|
||||
);
|
||||
this.displayInput = this.container.querySelector(
|
||||
`#${fieldId}-display`,
|
||||
);
|
||||
this.trigger = this.container.querySelector(`#${fieldId}-trigger`);
|
||||
this.popover = this.container.querySelector(`#${fieldId}-popover`);
|
||||
this.prevMonthBtn = this.container.querySelector(
|
||||
`#${fieldId}-prev-month`,
|
||||
);
|
||||
this.nextMonthBtn = this.container.querySelector(
|
||||
`#${fieldId}-next-month`,
|
||||
);
|
||||
this.monthSelect = this.container.querySelector(
|
||||
`#${fieldId}-month-select`,
|
||||
);
|
||||
this.yearSelect = this.container.querySelector(
|
||||
`#${fieldId}-year-select`,
|
||||
); // UPDATED
|
||||
this.calendarGrid = this.container.querySelector(
|
||||
`#${fieldId}-calendar-grid`,
|
||||
);
|
||||
this.todayBtn = this.container.querySelector(`#${fieldId}-today`);
|
||||
|
||||
this.form = this.container.closest("form");
|
||||
|
||||
this.initializeEventListeners();
|
||||
this.setupMonthSelect();
|
||||
this.setupYearSelect(); // UPDATED
|
||||
this.updateDisplay();
|
||||
this.renderCalendar();
|
||||
|
||||
// UPDATED: Auto validation on render
|
||||
setTimeout(() => this.triggerAutoValidation(), 50);
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
this.trigger.addEventListener("click", () => this.toggle());
|
||||
this.displayInput.addEventListener("click", () => this.open());
|
||||
this.prevMonthBtn.addEventListener("click", () =>
|
||||
this.previousMonth(),
|
||||
);
|
||||
this.nextMonthBtn.addEventListener("click", () => this.nextMonth());
|
||||
this.monthSelect.addEventListener("change", () =>
|
||||
this.handleMonthChange(),
|
||||
);
|
||||
this.yearSelect.addEventListener("change", () =>
|
||||
this.handleYearChange(),
|
||||
); // UPDATED
|
||||
this.todayBtn.addEventListener("click", () => this.selectToday());
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.container.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.container.addEventListener("keydown", (e) =>
|
||||
this.handleKeydown(e),
|
||||
);
|
||||
|
||||
if (this.form) {
|
||||
this.form.addEventListener("reset", () =>
|
||||
this.handleFormReset(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setupMonthSelect() {
|
||||
const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
this.monthSelect.innerHTML = months
|
||||
.map(
|
||||
(month, index) =>
|
||||
`<option value="${index}">${month}</option>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// UPDATED: Setup year dropdown with range
|
||||
setupYearSelect() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startYear = currentYear - 50;
|
||||
const endYear = currentYear + 50;
|
||||
|
||||
this.yearSelect.innerHTML = "";
|
||||
for (let year = startYear; year <= endYear; year++) {
|
||||
const option = document.createElement("option");
|
||||
option.value = year;
|
||||
option.textContent = year;
|
||||
this.yearSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
if (this.selectedDate) {
|
||||
this.displayInput.value = this.formatDate(this.selectedDate);
|
||||
this.hiddenInput.value = this.formatISO(this.selectedDate);
|
||||
} else {
|
||||
this.displayInput.value = "";
|
||||
this.hiddenInput.value = "";
|
||||
}
|
||||
|
||||
const changeEvent = new Event("change", { bubbles: true });
|
||||
this.hiddenInput.dispatchEvent(changeEvent);
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
switch (this.format) {
|
||||
case "dd/mm/yyyy":
|
||||
return `${day}/${month}/${year}`;
|
||||
case "mm/dd/yyyy":
|
||||
return `${month}/${day}/${year}`;
|
||||
default:
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
formatISO(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
renderCalendar() {
|
||||
const year = this.currentMonth.getFullYear();
|
||||
const month = this.currentMonth.getMonth();
|
||||
|
||||
this.monthSelect.value = month;
|
||||
this.yearSelect.value = year; // UPDATED
|
||||
|
||||
this.calendarGrid.innerHTML = "";
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
|
||||
const offset = (startingDayOfWeek - this.firstDayOfWeek + 7) % 7;
|
||||
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const emptyCell = document.createElement("div");
|
||||
emptyCell.className = "wr:p-2";
|
||||
this.calendarGrid.appendChild(emptyCell);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const currentDate = new Date(year, month, day);
|
||||
const dayButton = document.createElement("button");
|
||||
dayButton.type = "button";
|
||||
dayButton.textContent = day;
|
||||
dayButton.className =
|
||||
"wr:w-8 wr:h-8 wr:text-sm wr:rounded wr:transition-colors wr:hover:wr:bg-muted focus:wr:outline-none focus:wr:ring-2 focus:wr:ring-primary";
|
||||
|
||||
const isDisabled = this.isDateDisabled(currentDate);
|
||||
if (isDisabled) {
|
||||
dayButton.disabled = true;
|
||||
dayButton.className +=
|
||||
" wr:opacity-50 wr:cursor-not-allowed";
|
||||
}
|
||||
|
||||
if (
|
||||
this.selectedDate &&
|
||||
this.isSameDate(currentDate, this.selectedDate)
|
||||
) {
|
||||
dayButton.className +=
|
||||
" wr:bg-primary wr:text-primary-foreground";
|
||||
}
|
||||
|
||||
if (this.isSameDate(currentDate, new Date())) {
|
||||
dayButton.className +=
|
||||
" wr:font-bold wr:border wr:border-primary";
|
||||
}
|
||||
|
||||
if (!isDisabled) {
|
||||
dayButton.addEventListener("click", () =>
|
||||
this.selectDateImmediate(currentDate),
|
||||
);
|
||||
}
|
||||
|
||||
this.calendarGrid.appendChild(dayButton);
|
||||
}
|
||||
}
|
||||
|
||||
isDateDisabled(date) {
|
||||
if (this.min && date < this.min) return true;
|
||||
if (this.max && date > this.max) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
isSameDate(date1, date2) {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
selectDateImmediate(date) {
|
||||
this.selectedDate = new Date(date);
|
||||
this.updateDisplay();
|
||||
this.close();
|
||||
}
|
||||
|
||||
selectToday() {
|
||||
const today = new Date();
|
||||
if (!this.isDateDisabled(today)) {
|
||||
this.selectDateImmediate(today);
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATED: Auto validation on render
|
||||
triggerAutoValidation() {
|
||||
const blurEvent = new FocusEvent("blur", { bubbles: true });
|
||||
this.displayInput.dispatchEvent(blurEvent);
|
||||
}
|
||||
|
||||
previousMonth() {
|
||||
this.currentMonth = new Date(
|
||||
this.currentMonth.getFullYear(),
|
||||
this.currentMonth.getMonth() - 1,
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
|
||||
nextMonth() {
|
||||
this.currentMonth = new Date(
|
||||
this.currentMonth.getFullYear(),
|
||||
this.currentMonth.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
|
||||
handleMonthChange() {
|
||||
this.currentMonth = new Date(
|
||||
this.currentMonth.getFullYear(),
|
||||
parseInt(this.monthSelect.value),
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
|
||||
// UPDATED: Handle year dropdown change
|
||||
handleYearChange() {
|
||||
const year = parseInt(this.yearSelect.value);
|
||||
if (year) {
|
||||
this.currentMonth = new Date(
|
||||
year,
|
||||
this.currentMonth.getMonth(),
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.disabled) return;
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.disabled) return;
|
||||
this.isOpen = true;
|
||||
this.popover.classList.remove("wr:hidden");
|
||||
this.displayInput.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.popover.classList.add("wr:hidden");
|
||||
this.displayInput.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (!this.isOpen) return;
|
||||
if (e.key === "Escape") this.close();
|
||||
}
|
||||
|
||||
handleFormReset() {
|
||||
this.selectedDate = value ? new Date(value) : null;
|
||||
this.currentMonth = this.selectedDate || new Date();
|
||||
this.updateDisplay();
|
||||
this.renderCalendar();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
if (!document.getElementById(fieldId) || disabled) return;
|
||||
new DatePicker();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
document.addEventListener("astro:page-load", init);
|
||||
</script>
|
||||
@@ -0,0 +1,856 @@
|
||||
---
|
||||
// DateTimePicker.astro - Smart tab switching and auto validation
|
||||
export interface Props {
|
||||
label?: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
dateFormat?: "yyyy-mm-dd" | "dd/mm/yyyy" | "mm/dd/yyyy";
|
||||
timeFormat?: 12 | 24;
|
||||
timeStep?: number;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label = "Select Date & Time",
|
||||
description,
|
||||
name,
|
||||
value = "",
|
||||
placeholder = "Select date and time",
|
||||
required = false,
|
||||
disabled = false,
|
||||
dateFormat = "yyyy-mm-dd",
|
||||
timeFormat = 12,
|
||||
timeStep = 15,
|
||||
minDate,
|
||||
maxDate,
|
||||
class: className = "",
|
||||
} = Astro.props;
|
||||
|
||||
const fieldId = `field-${name.replace(/[[\]]/g, "-")}`;
|
||||
|
||||
let initialDate = "";
|
||||
let initialTime = "";
|
||||
if (value) {
|
||||
const datetime = new Date(value);
|
||||
if (!isNaN(datetime.getTime())) {
|
||||
initialDate = datetime.toISOString().split("T")[0];
|
||||
initialTime = datetime.toTimeString().slice(0, 5);
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<div class={`wr:space-y-2 ${className}`} data-field={name}>
|
||||
<label
|
||||
for={fieldId}
|
||||
class="wr:block wr:text-sm wr:font-medium wr:text-foreground"
|
||||
>
|
||||
{label}
|
||||
{required && <span class="wr:text-danger wr:ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{
|
||||
description && (
|
||||
<p class="wr:text-muted-foreground wr:text-sm wr:leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="wr:relative" id={fieldId}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
id={`${fieldId}-hidden`}
|
||||
data-control
|
||||
value={value}
|
||||
required={required}
|
||||
/>
|
||||
|
||||
<div class="wr:relative">
|
||||
<input
|
||||
type="text"
|
||||
id={`${fieldId}-display`}
|
||||
class="wr:w-full wr:px-3 wr:py-2 wr:pr-10 wr:border wr:border-border wr:rounded-lg wr:bg-background wr:text-foreground focus:wr:ring-2 focus:wr:ring-primary focus:wr:border-primary disabled:wr:opacity-50"
|
||||
placeholder={placeholder}
|
||||
readonly
|
||||
disabled={disabled}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
role="combobox"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-trigger`}
|
||||
class="wr:absolute wr:right-0 wr:top-0 wr:h-full wr:px-3 wr:flex wr:items-center wr:text-muted-foreground hover:wr:text-foreground disabled:wr:opacity-50"
|
||||
disabled={disabled}
|
||||
aria-label="Open datetime picker"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"
|
||||
></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
<circle cx="8" cy="16" r="2"></circle>
|
||||
<path d="m14.5 17.5 3 3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`${fieldId}-popover`}
|
||||
class="wr:hidden wr:absolute wr:top-full wr:left-0 wr:mt-1 wr:bg-card wr:border wr:border-border wr:rounded-lg wr:shadow-lg wr:z-50 wr:min-w-[320px]"
|
||||
role="dialog"
|
||||
aria-label="DateTime picker"
|
||||
>
|
||||
<!-- Tabs -->
|
||||
<div class="wr:flex wr:border-b wr:border-border">
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-date-tab`}
|
||||
class="wr:flex-1 wr:px-4 wr:py-2 wr:text-sm wr:font-medium wr:border-b-2 wr:border-primary wr:text-primary"
|
||||
data-tab="date"
|
||||
>
|
||||
Date
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-time-tab`}
|
||||
class="wr:flex-1 wr:px-4 wr:py-2 wr:text-sm wr:font-medium wr:border-b-2 wr:border-transparent wr:text-muted-foreground hover:wr:text-foreground"
|
||||
data-tab="time"
|
||||
>
|
||||
Time
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Date Panel -->
|
||||
<div id={`${fieldId}-date-panel`} class="wr:block">
|
||||
<div class="wr:p-3">
|
||||
<div
|
||||
class="wr:flex wr:items-center wr:justify-between wr:mb-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-prev-month`}
|
||||
class="wr:p-1 wr:rounded wr:hover:wr:bg-muted"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="15,18 9,12 15,6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="wr:flex wr:gap-2">
|
||||
<select
|
||||
id={`${fieldId}-month-select`}
|
||||
class="wr:bg-transparent wr:border-none wr:text-sm wr:font-medium"
|
||||
></select>
|
||||
<select
|
||||
id={`${fieldId}-year-select`}
|
||||
class="wr:bg-transparent wr:border-none wr:text-sm wr:font-medium"
|
||||
></select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-next-month`}
|
||||
class="wr:p-1 wr:rounded wr:hover:wr:bg-muted"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="9,18 15,12 9,6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wr:grid wr:grid-cols-7 wr:gap-1 wr:mb-2">
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Sun
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Mon
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Tue
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Wed
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Thu
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Fri
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-xs wr:font-medium wr:text-muted-foreground wr:text-center wr:p-2"
|
||||
>
|
||||
Sat
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id={`${fieldId}-calendar-grid`}
|
||||
class="wr:grid wr:grid-cols-7 wr:gap-1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wr:text-sm wr:text-muted-foreground wr:mt-2 wr:text-center"
|
||||
>
|
||||
Selected: <span id={`${fieldId}-selected-date`}
|
||||
>None</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Panel -->
|
||||
<div id={`${fieldId}-time-panel`} class="wr:hidden">
|
||||
<div class="wr:p-4">
|
||||
<div class="wr:flex wr:items-center wr:gap-2 wr:mb-4">
|
||||
<div class="wr:flex-1">
|
||||
<label
|
||||
class="wr:block wr:text-xs wr:text-muted-foreground wr:mb-1"
|
||||
>Hour</label
|
||||
>
|
||||
<select
|
||||
id={`${fieldId}-hour`}
|
||||
class="wr:w-full wr:px-2 wr:py-1 wr:text-center wr:border wr:border-border wr:rounded focus:wr:ring-1 focus:wr:ring-primary wr:bg-background"
|
||||
></select>
|
||||
</div>
|
||||
<div
|
||||
class="wr:text-lg wr:font-bold wr:text-muted-foreground wr:mt-4"
|
||||
>
|
||||
:
|
||||
</div>
|
||||
<div class="wr:flex-1">
|
||||
<label
|
||||
class="wr:block wr:text-xs wr:text-muted-foreground wr:mb-1"
|
||||
>Min</label
|
||||
>
|
||||
<select
|
||||
id={`${fieldId}-minute`}
|
||||
class="wr:w-full wr:px-2 wr:py-1 wr:text-center wr:border wr:border-border wr:rounded focus:wr:ring-1 focus:wr:ring-primary wr:bg-background"
|
||||
></select>
|
||||
</div>
|
||||
{
|
||||
timeFormat === 12 && (
|
||||
<div class="wr:flex-1">
|
||||
<label class="wr:block wr:text-xs wr:text-muted-foreground wr:mb-1">
|
||||
Period
|
||||
</label>
|
||||
<select
|
||||
id={`${fieldId}-period`}
|
||||
class="wr:w-full wr:px-2 wr:py-1 wr:border wr:border-border wr:rounded focus:wr:ring-1 focus:wr:ring-primary wr:bg-background"
|
||||
>
|
||||
<option value="AM">AM</option>
|
||||
<option value="PM">PM</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wr:text-sm wr:text-muted-foreground wr:text-center"
|
||||
>
|
||||
Selected: <span id={`${fieldId}-selected-time`}
|
||||
>None</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="wr:flex wr:justify-between wr:p-3 wr:border-t wr:border-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-now`}
|
||||
class="wr:px-3 wr:py-1 wr:text-sm wr:text-primary hover:wr:bg-primary/10 wr:rounded"
|
||||
>
|
||||
Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-apply`}
|
||||
class="wr:px-3 wr:py-1 wr:text-sm wr:bg-primary wr:text-primary-foreground hover:wr:bg-primary/90 wr:rounded"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`${fieldId}-err`}
|
||||
class="wr:text-sm wr:text-danger wr:mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
name,
|
||||
fieldId,
|
||||
value,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
timeStep,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabled,
|
||||
initialDate,
|
||||
initialTime,
|
||||
}}
|
||||
>
|
||||
class DateTimePicker {
|
||||
constructor() {
|
||||
this.name = name;
|
||||
this.fieldId = fieldId;
|
||||
this.dateFormat = dateFormat;
|
||||
this.timeFormat = timeFormat;
|
||||
this.timeStep = timeStep;
|
||||
this.disabled = disabled;
|
||||
this.selectedDate = initialDate;
|
||||
this.selectedTime = initialTime;
|
||||
this.currentMonth = this.selectedDate
|
||||
? new Date(this.selectedDate)
|
||||
: new Date();
|
||||
this.isOpen = false;
|
||||
this.activeTab = "date";
|
||||
|
||||
this.container = document.getElementById(fieldId);
|
||||
this.hiddenInput = this.container.querySelector(
|
||||
`#${fieldId}-hidden`,
|
||||
);
|
||||
this.displayInput = this.container.querySelector(
|
||||
`#${fieldId}-display`,
|
||||
);
|
||||
this.trigger = this.container.querySelector(`#${fieldId}-trigger`);
|
||||
this.popover = this.container.querySelector(`#${fieldId}-popover`);
|
||||
this.dateTab = this.container.querySelector(`#${fieldId}-date-tab`);
|
||||
this.timeTab = this.container.querySelector(`#${fieldId}-time-tab`);
|
||||
this.datePanel = this.container.querySelector(
|
||||
`#${fieldId}-date-panel`,
|
||||
);
|
||||
this.timePanel = this.container.querySelector(
|
||||
`#${fieldId}-time-panel`,
|
||||
);
|
||||
|
||||
// Calendar elements
|
||||
this.prevMonthBtn = this.container.querySelector(
|
||||
`#${fieldId}-prev-month`,
|
||||
);
|
||||
this.nextMonthBtn = this.container.querySelector(
|
||||
`#${fieldId}-next-month`,
|
||||
);
|
||||
this.monthSelect = this.container.querySelector(
|
||||
`#${fieldId}-month-select`,
|
||||
);
|
||||
this.yearSelect = this.container.querySelector(
|
||||
`#${fieldId}-year-select`,
|
||||
);
|
||||
this.calendarGrid = this.container.querySelector(
|
||||
`#${fieldId}-calendar-grid`,
|
||||
);
|
||||
|
||||
// Time elements
|
||||
this.hourSelect = this.container.querySelector(`#${fieldId}-hour`);
|
||||
this.minuteSelect = this.container.querySelector(
|
||||
`#${fieldId}-minute`,
|
||||
);
|
||||
this.periodSelect = this.container.querySelector(
|
||||
`#${fieldId}-period`,
|
||||
);
|
||||
|
||||
// Action buttons
|
||||
this.nowBtn = this.container.querySelector(`#${fieldId}-now`);
|
||||
this.applyBtn = this.container.querySelector(`#${fieldId}-apply`);
|
||||
|
||||
this.form = this.container.closest("form");
|
||||
|
||||
this.initializeEventListeners();
|
||||
this.setupMonthSelect();
|
||||
this.setupYearSelect();
|
||||
this.setupTimeDropdowns();
|
||||
this.renderCalendar();
|
||||
this.updateDisplay();
|
||||
|
||||
// UPDATED: Auto validation on render
|
||||
setTimeout(() => this.triggerAutoValidation(), 50);
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
this.trigger.addEventListener("click", () => this.toggle());
|
||||
this.displayInput.addEventListener("click", () => this.open());
|
||||
|
||||
// Tab switching
|
||||
this.dateTab.addEventListener("click", () =>
|
||||
this.switchTab("date"),
|
||||
);
|
||||
this.timeTab.addEventListener("click", () =>
|
||||
this.switchTab("time"),
|
||||
);
|
||||
|
||||
// Calendar navigation
|
||||
this.prevMonthBtn.addEventListener("click", () =>
|
||||
this.previousMonth(),
|
||||
);
|
||||
this.nextMonthBtn.addEventListener("click", () => this.nextMonth());
|
||||
this.monthSelect.addEventListener("change", () =>
|
||||
this.handleMonthChange(),
|
||||
);
|
||||
this.yearSelect.addEventListener("change", () =>
|
||||
this.handleYearChange(),
|
||||
);
|
||||
|
||||
// UPDATED: Smart time selection behavior
|
||||
this.hourSelect.addEventListener("change", () =>
|
||||
this.onTimeChange(),
|
||||
);
|
||||
this.minuteSelect.addEventListener("change", () =>
|
||||
this.onTimeChange(),
|
||||
);
|
||||
if (this.periodSelect) {
|
||||
this.periodSelect.addEventListener("change", () =>
|
||||
this.onTimeChange(),
|
||||
);
|
||||
}
|
||||
|
||||
this.nowBtn.addEventListener("click", () => this.setNow());
|
||||
this.applyBtn.addEventListener("click", () => this.apply());
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.container.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.form) {
|
||||
this.form.addEventListener("reset", () =>
|
||||
this.handleFormReset(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setupMonthSelect() {
|
||||
const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
this.monthSelect.innerHTML = months
|
||||
.map(
|
||||
(month, index) =>
|
||||
`<option value="${index}">${month}</option>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
setupYearSelect() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startYear = currentYear - 50;
|
||||
const endYear = currentYear + 50;
|
||||
|
||||
this.yearSelect.innerHTML = "";
|
||||
for (let year = startYear; year <= endYear; year++) {
|
||||
const option = document.createElement("option");
|
||||
option.value = year;
|
||||
option.textContent = year;
|
||||
this.yearSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
setupTimeDropdowns() {
|
||||
this.hourSelect.innerHTML = "";
|
||||
const hourMin = this.timeFormat === 12 ? 1 : 0;
|
||||
const hourMax = this.timeFormat === 12 ? 12 : 23;
|
||||
|
||||
for (let h = hourMin; h <= hourMax; h++) {
|
||||
const option = document.createElement("option");
|
||||
option.value = h;
|
||||
option.textContent = h.toString().padStart(2, "0");
|
||||
this.hourSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.minuteSelect.innerHTML = "";
|
||||
for (let m = 0; m < 60; m += this.timeStep) {
|
||||
const option = document.createElement("option");
|
||||
option.value = m;
|
||||
option.textContent = m.toString().padStart(2, "0");
|
||||
this.minuteSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
renderCalendar() {
|
||||
const year = this.currentMonth.getFullYear();
|
||||
const month = this.currentMonth.getMonth();
|
||||
|
||||
this.monthSelect.value = month;
|
||||
this.yearSelect.value = year;
|
||||
this.calendarGrid.innerHTML = "";
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
|
||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||
const emptyCell = document.createElement("div");
|
||||
emptyCell.className = "wr:p-2";
|
||||
this.calendarGrid.appendChild(emptyCell);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const currentDate = new Date(year, month, day);
|
||||
const dayButton = document.createElement("button");
|
||||
dayButton.type = "button";
|
||||
dayButton.textContent = day;
|
||||
dayButton.className =
|
||||
"wr:w-8 wr:h-8 wr:text-sm wr:rounded wr:transition-colors wr:hover:wr:bg-muted focus:wr:outline-none focus:wr:ring-2 focus:wr:ring-primary";
|
||||
|
||||
const dateStr = currentDate.toISOString().split("T")[0];
|
||||
if (this.selectedDate === dateStr) {
|
||||
dayButton.className +=
|
||||
" wr:bg-primary wr:text-primary-foreground";
|
||||
}
|
||||
|
||||
if (this.isSameDate(currentDate, new Date())) {
|
||||
dayButton.className +=
|
||||
" wr:font-bold wr:border wr:border-primary";
|
||||
}
|
||||
|
||||
// UPDATED: On date select, switch to time tab
|
||||
dayButton.addEventListener("click", () =>
|
||||
this.onDateSelect(dateStr),
|
||||
);
|
||||
this.calendarGrid.appendChild(dayButton);
|
||||
}
|
||||
}
|
||||
|
||||
isSameDate(date1, date2) {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
// UPDATED: On date select, switch to time tab instead of closing
|
||||
onDateSelect(dateStr) {
|
||||
this.selectedDate = dateStr;
|
||||
this.updateDisplay();
|
||||
this.renderCalendar();
|
||||
|
||||
// Switch to time tab to continue selection
|
||||
this.switchTab("time");
|
||||
}
|
||||
|
||||
// UPDATED: Smart time selection behavior
|
||||
onTimeChange() {
|
||||
this.updateTimeFromInputs();
|
||||
|
||||
// If no date selected when time changes, force user to select date first
|
||||
if (!this.selectedDate) {
|
||||
this.switchTab("date");
|
||||
return;
|
||||
}
|
||||
|
||||
// If both date and time are selected, close picker
|
||||
if (this.selectedDate && this.selectedTime) {
|
||||
setTimeout(() => this.close(), 200); // Small delay for better UX
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeFromInputs() {
|
||||
const hour = parseInt(this.hourSelect.value) || 0;
|
||||
const minute = parseInt(this.minuteSelect.value) || 0;
|
||||
|
||||
let hour24 = hour;
|
||||
if (this.timeFormat === 12 && this.periodSelect) {
|
||||
const period = this.periodSelect.value;
|
||||
if (period === "PM" && hour !== 12) {
|
||||
hour24 = hour + 12;
|
||||
} else if (period === "AM" && hour === 12) {
|
||||
hour24 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const hourStr = String(hour24).padStart(2, "0");
|
||||
const minuteStr = String(minute).padStart(2, "0");
|
||||
this.selectedTime = `${hourStr}:${minuteStr}`;
|
||||
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
switchTab(tab) {
|
||||
this.activeTab = tab;
|
||||
|
||||
if (tab === "date") {
|
||||
this.dateTab.className =
|
||||
"wr:flex-1 wr:px-4 wr:py-2 wr:text-sm wr:font-medium wr:border-b-2 wr:border-primary wr:text-primary";
|
||||
this.timeTab.className =
|
||||
"wr:flex-1 wr:px-4 wr:py-2 wr:text-sm wr:font-medium wr:border-b-2 wr:border-transparent wr:text-muted-foreground hover:wr:text-foreground";
|
||||
this.datePanel.className = "wr:block";
|
||||
this.timePanel.className = "wr:hidden";
|
||||
} else {
|
||||
this.timeTab.className =
|
||||
"wr:flex-1 wr:px-4 wr:py-2 wr:text-sm wr:font-medium wr:border-b-2 wr:border-primary wr:text-primary";
|
||||
this.dateTab.className =
|
||||
"wr:flex-1 wr:px-4 wr:py-2 wr:text-sm wr:font-medium wr:border-b-2 wr:border-transparent wr:text-muted-foreground hover:wr:text-foreground";
|
||||
this.timePanel.className = "wr:block";
|
||||
this.datePanel.className = "wr:hidden";
|
||||
}
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
let displayText = "";
|
||||
let isoValue = "";
|
||||
|
||||
if (this.selectedDate && this.selectedTime) {
|
||||
const dateObj = new Date(
|
||||
`${this.selectedDate}T${this.selectedTime}`,
|
||||
);
|
||||
displayText = this.formatDateTime(dateObj);
|
||||
isoValue = dateObj.toISOString();
|
||||
} else if (this.selectedDate) {
|
||||
displayText = this.formatDate(this.selectedDate);
|
||||
isoValue = `${this.selectedDate}T00:00:00.000Z`;
|
||||
} else if (this.selectedTime) {
|
||||
displayText = this.formatTime(this.selectedTime);
|
||||
}
|
||||
|
||||
this.displayInput.value = displayText;
|
||||
this.hiddenInput.value = isoValue;
|
||||
|
||||
// Update status displays
|
||||
const selectedDateEl = this.container.querySelector(
|
||||
`#${fieldId}-selected-date`,
|
||||
);
|
||||
const selectedTimeEl = this.container.querySelector(
|
||||
`#${fieldId}-selected-time`,
|
||||
);
|
||||
|
||||
if (selectedDateEl) {
|
||||
selectedDateEl.textContent = this.selectedDate
|
||||
? this.formatDate(this.selectedDate)
|
||||
: "None";
|
||||
}
|
||||
if (selectedTimeEl) {
|
||||
selectedTimeEl.textContent = this.selectedTime
|
||||
? this.formatTime(this.selectedTime)
|
||||
: "None";
|
||||
}
|
||||
|
||||
if (this.selectedTime) {
|
||||
this.parseTimeToInputs(this.selectedTime);
|
||||
}
|
||||
|
||||
const changeEvent = new Event("change", { bubbles: true });
|
||||
this.hiddenInput.dispatchEvent(changeEvent);
|
||||
}
|
||||
|
||||
formatDateTime(date) {
|
||||
const dateStr = this.formatDate(date.toISOString().split("T")[0]);
|
||||
const timeStr = this.formatTime(date.toTimeString().slice(0, 5));
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const [year, month, day] = dateStr.split("-");
|
||||
|
||||
switch (this.dateFormat) {
|
||||
case "dd/mm/yyyy":
|
||||
return `${day}/${month}/${year}`;
|
||||
case "mm/dd/yyyy":
|
||||
return `${month}/${day}/${year}`;
|
||||
default:
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(timeStr) {
|
||||
if (!timeStr) return "";
|
||||
|
||||
if (this.timeFormat === 12) {
|
||||
const [hours, minutes] = timeStr.split(":");
|
||||
const hour24 = parseInt(hours);
|
||||
const hour12 =
|
||||
hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
|
||||
const period = hour24 >= 12 ? "PM" : "AM";
|
||||
return `${hour12}:${minutes} ${period}`;
|
||||
}
|
||||
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
parseTimeToInputs(time24) {
|
||||
if (!time24) return;
|
||||
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour24 = parseInt(hours);
|
||||
|
||||
if (this.timeFormat === 12) {
|
||||
const hour12 =
|
||||
hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
|
||||
const period = hour24 >= 12 ? "PM" : "AM";
|
||||
|
||||
this.hourSelect.value = hour12;
|
||||
this.minuteSelect.value = minutes;
|
||||
if (this.periodSelect) this.periodSelect.value = period;
|
||||
} else {
|
||||
this.hourSelect.value = hours;
|
||||
this.minuteSelect.value = minutes;
|
||||
}
|
||||
}
|
||||
|
||||
setNow() {
|
||||
const now = new Date();
|
||||
this.selectedDate = now.toISOString().split("T")[0];
|
||||
this.selectedTime = now.toTimeString().slice(0, 5);
|
||||
this.currentMonth = now;
|
||||
this.renderCalendar();
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
previousMonth() {
|
||||
this.currentMonth = new Date(
|
||||
this.currentMonth.getFullYear(),
|
||||
this.currentMonth.getMonth() - 1,
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
|
||||
nextMonth() {
|
||||
this.currentMonth = new Date(
|
||||
this.currentMonth.getFullYear(),
|
||||
this.currentMonth.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
|
||||
handleMonthChange() {
|
||||
this.currentMonth = new Date(
|
||||
this.currentMonth.getFullYear(),
|
||||
parseInt(this.monthSelect.value),
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
|
||||
handleYearChange() {
|
||||
const year = parseInt(this.yearSelect.value);
|
||||
if (year) {
|
||||
this.currentMonth = new Date(
|
||||
year,
|
||||
this.currentMonth.getMonth(),
|
||||
1,
|
||||
);
|
||||
this.renderCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATED: Auto validation on render
|
||||
triggerAutoValidation() {
|
||||
const blurEvent = new FocusEvent("blur", { bubbles: true });
|
||||
this.displayInput.dispatchEvent(blurEvent);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.disabled) return;
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.disabled) return;
|
||||
this.isOpen = true;
|
||||
this.popover.classList.remove("wr:hidden");
|
||||
this.displayInput.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.popover.classList.add("wr:hidden");
|
||||
this.displayInput.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleFormReset() {
|
||||
this.selectedDate = initialDate;
|
||||
this.selectedTime = initialTime;
|
||||
this.currentMonth = this.selectedDate
|
||||
? new Date(this.selectedDate)
|
||||
: new Date();
|
||||
this.renderCalendar();
|
||||
this.updateDisplay();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
if (!document.getElementById(fieldId) || disabled) return;
|
||||
new DateTimePicker();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
document.addEventListener("astro:page-load", init);
|
||||
</script>
|
||||
@@ -0,0 +1,862 @@
|
||||
---
|
||||
// FileUploader.astro - Fixed infinite recursion issue
|
||||
export interface Props {
|
||||
label?: string;
|
||||
description?: string;
|
||||
multiple?: boolean;
|
||||
maxFileSize?: number;
|
||||
acceptedTypes?: string[];
|
||||
maxFiles?: number;
|
||||
required?: boolean;
|
||||
name: string;
|
||||
autoUpload?: boolean;
|
||||
uploadFunction?: string;
|
||||
returnUrls?: boolean;
|
||||
validationFunction?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label = "Upload Files",
|
||||
description = "Select or drag and drop your files",
|
||||
multiple = true,
|
||||
maxFileSize = 10,
|
||||
acceptedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||
maxFiles = 5,
|
||||
required = false,
|
||||
name,
|
||||
autoUpload = false,
|
||||
uploadFunction = null,
|
||||
returnUrls = false,
|
||||
validationFunction = null,
|
||||
class: className = "",
|
||||
} = Astro.props;
|
||||
|
||||
const fieldId = `field-${name.replace(/[[\]]/g, "-")}`;
|
||||
---
|
||||
|
||||
<!-- Form Field Container with data-field attribute and custom class -->
|
||||
<div class={`wr:space-y-2 ${className}`} data-field={name}>
|
||||
<label
|
||||
for={fieldId}
|
||||
class="wr:block wr:text-sm wr:font-medium wr:text-foreground"
|
||||
>
|
||||
{label}
|
||||
{required && <span class="wr:text-danger wr:ml-1">*</span>}
|
||||
{
|
||||
autoUpload && (
|
||||
<span class="wr:text-xs wr:text-success wr:ml-2 wr:bg-success/10 wr:px-2 wr:py-1 wr:rounded-full">
|
||||
Auto Upload
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</label>
|
||||
|
||||
{
|
||||
description && (
|
||||
<p class="wr:text-muted-foreground wr:text-sm wr:leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- File Uploader Container -->
|
||||
<div
|
||||
class="wr:w-full wr:bg-card wr:rounded-xl wr:border wr:border-border wr:p-4 wr:transition-all wr:duration-300"
|
||||
id={fieldId}
|
||||
>
|
||||
<!-- Hidden form inputs -->
|
||||
{
|
||||
returnUrls ? (
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
id={`${fieldId}-urls`}
|
||||
data-control
|
||||
value=""
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="file"
|
||||
name={name}
|
||||
id={`${fieldId}-files`}
|
||||
data-control
|
||||
accept={acceptedTypes.join(",")}
|
||||
multiple={multiple}
|
||||
required={required}
|
||||
class="wr:sr-only"
|
||||
style="position: absolute; left: -9999px;"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
id="drop-zone"
|
||||
class="wr:border-2 wr:border-dashed wr:border-border wr:rounded-lg wr:p-6 wr:text-center wr:bg-background/50 wr:transition-all wr:duration-300 wr:cursor-pointer wr:min-h-[120px] wr:flex wr:items-center wr:justify-center hover:wr:border-primary hover:wr:bg-background focus:wr:outline-none focus:wr:ring-2 focus:wr:ring-primary focus:wr:ring-offset-2"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Click to select files or drag and drop files here"
|
||||
>
|
||||
<div class="wr:pointer-events-none wr:max-w-full">
|
||||
<svg
|
||||
class="wr:w-8 wr:h-8 wr:mx-auto wr:mb-2 wr:text-muted-foreground wr:block"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7,10 12,15 17,10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<p class="wr:text-sm wr:text-muted-foreground wr:mb-1">
|
||||
Drop files here or <button
|
||||
type="button"
|
||||
id="browse-btn"
|
||||
class="wr:text-primary wr:underline wr:font-medium wr:pointer-events-auto wr:bg-none wr:border-none wr:cursor-pointer hover:wr:text-primary/80"
|
||||
>browse</button
|
||||
>
|
||||
</p>
|
||||
<div
|
||||
class="wr:flex wr:flex-wrap wr:gap-2 wr:justify-center wr:mt-2"
|
||||
>
|
||||
<span
|
||||
class="wr:text-xs wr:text-muted-foreground wr:bg-muted wr:px-2 wr:py-1 wr:rounded"
|
||||
>
|
||||
{multiple ? `Up to ${maxFiles}` : "Single file"}
|
||||
</span>
|
||||
<span
|
||||
class="wr:text-xs wr:text-muted-foreground wr:bg-muted wr:px-2 wr:py-1 wr:rounded"
|
||||
>
|
||||
Max {maxFileSize}MB
|
||||
</span>
|
||||
<span
|
||||
class="wr:text-xs wr:text-muted-foreground wr:bg-muted wr:px-2 wr:py-1 wr:rounded"
|
||||
>
|
||||
{
|
||||
acceptedTypes
|
||||
.map((type) => type.split("/")[1].toUpperCase())
|
||||
.join(", ")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temporary file input -->
|
||||
<input
|
||||
type="file"
|
||||
id={`${fieldId}-temp-input`}
|
||||
accept={acceptedTypes.join(",")}
|
||||
multiple={multiple}
|
||||
class="wr:hidden"
|
||||
/>
|
||||
|
||||
<!-- File List -->
|
||||
<div id="file-list" class="wr:space-y-2 wr:mt-4"></div>
|
||||
|
||||
<!-- Upload Controls -->
|
||||
<div
|
||||
id="upload-controls"
|
||||
class={`${autoUpload || !uploadFunction ? "wr:hidden" : "wr:hidden"} wr:flex wr:gap-2 wr:justify-center wr:mt-4`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="upload-btn"
|
||||
class="wr:flex wr:items-center wr:gap-2 wr:px-4 wr:py-2 wr:rounded-lg wr:text-sm wr:font-medium wr:bg-primary wr:text-primary-foreground hover:wr:bg-primary/90 disabled:wr:opacity-50 disabled:wr:cursor-not-allowed focus:wr:outline-none focus:wr:ring-2 focus:wr:ring-primary focus:wr:ring-offset-1"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M21 2v6h-6M21 13a9 9 0 1 1-3-7.7l3 3.7"></path>
|
||||
</svg>
|
||||
Process Files
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="clear-btn"
|
||||
class="wr:flex wr:items-center wr:gap-2 wr:px-4 wr:py-2 wr:rounded-lg wr:text-sm wr:font-medium wr:bg-secondary wr:text-secondary-foreground wr:border wr:border-border hover:wr:bg-secondary/80 focus:wr:outline-none focus:wr:ring-2 focus:wr:ring-secondary focus:wr:ring-offset-1"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12"></path>
|
||||
</svg>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display (Form component will manage this) -->
|
||||
<div
|
||||
id={`${fieldId}-err`}
|
||||
class="wr:text-sm wr:text-danger wr:mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
multiple,
|
||||
maxFileSize,
|
||||
acceptedTypes,
|
||||
maxFiles,
|
||||
name,
|
||||
fieldId,
|
||||
autoUpload,
|
||||
uploadFunction,
|
||||
returnUrls,
|
||||
validationFunction,
|
||||
}}
|
||||
>
|
||||
class FormFileUploader {
|
||||
constructor() {
|
||||
this.files = [];
|
||||
this.name = name;
|
||||
this.fieldId = fieldId;
|
||||
this.autoUpload = autoUpload;
|
||||
this.uploadFunction = uploadFunction;
|
||||
this.validationFunction = validationFunction;
|
||||
this.returnUrls = returnUrls;
|
||||
this.isUpdatingFormInput = false; // FLAG TO PREVENT RECURSION
|
||||
|
||||
this.container = document.getElementById(fieldId);
|
||||
this.dropZone = this.container.querySelector("#drop-zone");
|
||||
this.tempFileInput = this.container.querySelector(
|
||||
`#${fieldId}-temp-input`,
|
||||
);
|
||||
this.formInput = this.container.querySelector(
|
||||
returnUrls ? `#${fieldId}-urls` : `#${fieldId}-files`,
|
||||
);
|
||||
this.browseBtn = this.container.querySelector("#browse-btn");
|
||||
this.fileList = this.container.querySelector("#file-list");
|
||||
this.uploadBtn = this.container.querySelector("#upload-btn");
|
||||
this.clearBtn = this.container.querySelector("#clear-btn");
|
||||
this.uploadControls =
|
||||
this.container.querySelector("#upload-controls");
|
||||
|
||||
// Find the parent form for reset event listening
|
||||
this.form = this.container.closest("form");
|
||||
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
// Drag and drop
|
||||
this.dropZone.addEventListener(
|
||||
"dragover",
|
||||
this.handleDragOver.bind(this),
|
||||
);
|
||||
this.dropZone.addEventListener(
|
||||
"dragleave",
|
||||
this.handleDragLeave.bind(this),
|
||||
);
|
||||
this.dropZone.addEventListener("drop", this.handleDrop.bind(this));
|
||||
this.dropZone.addEventListener("click", () =>
|
||||
this.tempFileInput.click(),
|
||||
);
|
||||
|
||||
// File selection
|
||||
this.browseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.tempFileInput.click();
|
||||
});
|
||||
this.tempFileInput.addEventListener(
|
||||
"change",
|
||||
this.handleFileSelect.bind(this),
|
||||
);
|
||||
|
||||
// Controls
|
||||
if (this.uploadFunction) {
|
||||
this.uploadBtn?.addEventListener(
|
||||
"click",
|
||||
this.processFiles.bind(this),
|
||||
);
|
||||
this.clearBtn?.addEventListener(
|
||||
"click",
|
||||
this.clearFiles.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
this.dropZone.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this.tempFileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Form reset event listener
|
||||
if (this.form) {
|
||||
this.form.addEventListener(
|
||||
"reset",
|
||||
this.handleFormReset.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
// REMOVED THE PROBLEMATIC EVENT LISTENER
|
||||
// The form validation will work without this recursive listener
|
||||
// Instead, we'll trigger validation manually when needed
|
||||
}
|
||||
|
||||
// NEW METHOD: Handle form reset
|
||||
handleFormReset() {
|
||||
// Clear all files and reset the component state
|
||||
this.clearFiles();
|
||||
|
||||
// Reset any error states
|
||||
this.dropZone.classList.remove(
|
||||
"wr:border-danger",
|
||||
"wr:border-primary",
|
||||
"wr:bg-primary/5",
|
||||
"wr:bg-danger/5",
|
||||
);
|
||||
this.dropZone.classList.add("wr:border-border");
|
||||
|
||||
// Reset temp file input
|
||||
this.tempFileInput.value = "";
|
||||
|
||||
// Reset form input WITHOUT triggering events
|
||||
this.isUpdatingFormInput = true;
|
||||
if (this.returnUrls) {
|
||||
this.formInput.value = "";
|
||||
} else {
|
||||
this.formInput.value = null;
|
||||
// Create empty FileList
|
||||
const dt = new DataTransfer();
|
||||
this.formInput.files = dt.files;
|
||||
}
|
||||
this.isUpdatingFormInput = false;
|
||||
|
||||
console.log(`FileUploader ${this.name} has been reset`);
|
||||
}
|
||||
|
||||
handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
this.dropZone.classList.add("wr:border-primary", "wr:bg-primary/5");
|
||||
this.dropZone.classList.remove("wr:border-border");
|
||||
}
|
||||
|
||||
handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
if (!this.dropZone.contains(e.relatedTarget)) {
|
||||
this.dropZone.classList.remove(
|
||||
"wr:border-primary",
|
||||
"wr:bg-primary/5",
|
||||
);
|
||||
this.dropZone.classList.add("wr:border-border");
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop(e) {
|
||||
e.preventDefault();
|
||||
this.dropZone.classList.remove(
|
||||
"wr:border-primary",
|
||||
"wr:bg-primary/5",
|
||||
);
|
||||
this.dropZone.classList.add("wr:border-border");
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
this.processNewFiles(files);
|
||||
}
|
||||
|
||||
handleFileSelect(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
this.processNewFiles(files);
|
||||
}
|
||||
|
||||
async processNewFiles(newFiles) {
|
||||
const validFiles = [];
|
||||
const errors = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
// Basic validation
|
||||
if (!acceptedTypes.includes(file.type)) {
|
||||
errors.push(`${file.name}: Invalid file type`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > maxFileSize * 1024 * 1024) {
|
||||
errors.push(`${file.name}: File too large`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.files.some(
|
||||
(f) => f.name === file.name && f.size === file.size,
|
||||
)
|
||||
) {
|
||||
errors.push(`${file.name}: Already added`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// Handle single file mode
|
||||
if (!multiple && this.files.length > 0) {
|
||||
this.clearFiles();
|
||||
}
|
||||
|
||||
// Check file count
|
||||
if (this.files.length + validFiles.length > maxFiles) {
|
||||
const allowedCount = maxFiles - this.files.length;
|
||||
if (allowedCount > 0) {
|
||||
errors.push(`Only ${allowedCount} more file(s) allowed`);
|
||||
validFiles.splice(allowedCount);
|
||||
} else {
|
||||
errors.push(`Maximum ${maxFiles} files allowed`);
|
||||
validFiles.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (
|
||||
this.validationFunction &&
|
||||
typeof window[this.validationFunction] === "function"
|
||||
) {
|
||||
try {
|
||||
const validationResult = await window[
|
||||
this.validationFunction
|
||||
](validFiles, this.files);
|
||||
if (validationResult && !validationResult.success) {
|
||||
if (Array.isArray(validationResult.errors)) {
|
||||
errors.push(...validationResult.errors);
|
||||
} else if (validationResult.error) {
|
||||
errors.push(validationResult.error);
|
||||
}
|
||||
validFiles.length = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Validation error: ${error.message}`);
|
||||
validFiles.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Add valid files
|
||||
validFiles.forEach((file) => {
|
||||
const fileObj = {
|
||||
file,
|
||||
id: Date.now() + Math.random(),
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
error: null,
|
||||
result: null,
|
||||
url: null,
|
||||
};
|
||||
this.files.push(fileObj);
|
||||
});
|
||||
|
||||
this.renderFileList();
|
||||
this.updateFormInputs();
|
||||
this.updateControls();
|
||||
|
||||
// Auto-process if enabled
|
||||
if (
|
||||
this.autoUpload &&
|
||||
this.uploadFunction &&
|
||||
validFiles.length > 0
|
||||
) {
|
||||
setTimeout(() => this.processFiles(), 100);
|
||||
}
|
||||
|
||||
// Reset temp input
|
||||
this.tempFileInput.value = "";
|
||||
}
|
||||
|
||||
renderFileList() {
|
||||
this.fileList.innerHTML = "";
|
||||
|
||||
this.files.forEach((fileObj, index) => {
|
||||
const { file, status, progress, error } = fileObj;
|
||||
|
||||
const fileItem = document.createElement("div");
|
||||
fileItem.className =
|
||||
"wr:flex wr:items-center wr:gap-3 wr:p-3 wr:border wr:border-border wr:rounded-lg wr:bg-background wr:transition-all wr:duration-200 hover:wr:shadow-sm";
|
||||
|
||||
// Create preview
|
||||
const previewUrl = file.type.startsWith("image/")
|
||||
? URL.createObjectURL(file)
|
||||
: "";
|
||||
const previewElement = file.type.startsWith("image/")
|
||||
? `<img class="wr:w-10 wr:h-10 wr:rounded wr:object-cover wr:bg-muted wr:border wr:border-border wr:flex-shrink-0" src="${previewUrl}" alt="Preview" loading="lazy">`
|
||||
: `<div class="wr:w-10 wr:h-10 wr:rounded wr:bg-muted wr:border wr:border-border wr:flex-shrink-0 wr:flex wr:items-center wr:justify-center">
|
||||
<svg class="wr:w-5 wr:h-5 wr:text-muted-foreground" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
fileItem.innerHTML = `
|
||||
${previewElement}
|
||||
|
||||
<div class="wr:flex-1 wr:min-w-0">
|
||||
<div class="wr:font-medium wr:text-sm wr:text-foreground wr:truncate" title="${file.name}">
|
||||
${this.truncateFileName(file.name, 25)}
|
||||
</div>
|
||||
<div class="wr:text-xs wr:text-muted-foreground wr:mt-0.5">
|
||||
${this.formatFileSize(file.size)} • ${file.type.split("/")[1].toUpperCase()}
|
||||
</div>
|
||||
${
|
||||
status === "processing"
|
||||
? `
|
||||
<div class="wr:w-full wr:bg-muted wr:rounded-full wr:h-1.5 wr:mt-2">
|
||||
<div class="wr:bg-primary wr:h-1.5 wr:rounded-full wr:transition-all wr:duration-300" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${error ? `<div class="wr:text-xs wr:text-danger wr:mt-1">${error}</div>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="wr:flex wr:gap-1 wr:flex-shrink-0">
|
||||
<span class="wr:text-xs wr:px-2 wr:py-1 wr:rounded-full ${this.getStatusClasses(status)}">
|
||||
${this.getStatusText(status)}
|
||||
</span>
|
||||
|
||||
${
|
||||
status === "pending" && !this.autoUpload && this.uploadFunction
|
||||
? `
|
||||
<button class="wr:p-1 wr:rounded wr:bg-primary/10 wr:text-primary wr:border wr:border-primary/20 hover:wr:bg-primary/20"
|
||||
data-action="process"
|
||||
data-index="${index}"
|
||||
title="Process">
|
||||
<svg class="wr:w-3 wr:h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 2v6h-6M21 13a9 9 0 1 1-3-7.7l3 3.7" />
|
||||
</svg>
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<button class="wr:p-1 wr:rounded wr:bg-danger/10 wr:text-danger wr:border wr:border-danger/20 hover:wr:bg-danger/20 ${status === "processing" ? "wr:opacity-50 wr:cursor-not-allowed" : ""}"
|
||||
data-action="delete"
|
||||
data-index="${index}"
|
||||
title="Remove"
|
||||
${status === "processing" ? "disabled" : ""}>
|
||||
<svg class="wr:w-3 wr:h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
const processBtn = fileItem.querySelector(
|
||||
'[data-action="process"]',
|
||||
);
|
||||
const deleteBtn = fileItem.querySelector(
|
||||
'[data-action="delete"]',
|
||||
);
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener("click", () =>
|
||||
this.processSingleFile(index),
|
||||
);
|
||||
}
|
||||
|
||||
if (deleteBtn && status !== "processing") {
|
||||
deleteBtn.addEventListener("click", () =>
|
||||
this.removeFile(index),
|
||||
);
|
||||
}
|
||||
|
||||
this.fileList.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
getStatusText(status) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Ready";
|
||||
case "processing":
|
||||
return "Processing";
|
||||
case "success":
|
||||
return "Complete";
|
||||
case "error":
|
||||
return "Failed";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
getStatusClasses(status) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "wr:bg-neutral/10 wr:text-neutral-foreground";
|
||||
case "processing":
|
||||
return "wr:bg-warning/10 wr:text-warning";
|
||||
case "success":
|
||||
return "wr:bg-success/10 wr:text-success";
|
||||
case "error":
|
||||
return "wr:bg-danger/10 wr:text-danger";
|
||||
default:
|
||||
return "wr:bg-neutral/10 wr:text-neutral-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
truncateFileName(name, maxLength) {
|
||||
if (name.length <= maxLength) return name;
|
||||
const extension = name.split(".").pop();
|
||||
const nameWithoutExt = name.substring(0, name.lastIndexOf("."));
|
||||
const truncated =
|
||||
nameWithoutExt.substring(0, maxLength - extension.length - 4) +
|
||||
"...";
|
||||
return `${truncated}.${extension}`;
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
);
|
||||
}
|
||||
|
||||
removeFile(index) {
|
||||
// Clean up object URLs
|
||||
const fileObj = this.files[index];
|
||||
if (fileObj.file.type.startsWith("image/")) {
|
||||
const preview =
|
||||
this.fileList.children[index]?.querySelector("img");
|
||||
if (preview?.src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(preview.src);
|
||||
}
|
||||
}
|
||||
|
||||
this.files.splice(index, 1);
|
||||
this.renderFileList();
|
||||
this.updateFormInputs();
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
clearFiles() {
|
||||
// Clean up all object URLs
|
||||
this.files.forEach((fileObj, index) => {
|
||||
if (fileObj.file.type.startsWith("image/")) {
|
||||
const preview =
|
||||
this.fileList.children[index]?.querySelector("img");
|
||||
if (preview?.src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(preview.src);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.files = [];
|
||||
this.renderFileList();
|
||||
this.updateFormInputs();
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
if (
|
||||
!this.autoUpload &&
|
||||
this.uploadFunction &&
|
||||
this.files.some((f) => f.status === "pending")
|
||||
) {
|
||||
this.uploadControls.classList.remove("wr:hidden");
|
||||
this.uploadControls.classList.add("wr:flex");
|
||||
} else {
|
||||
this.uploadControls.classList.add("wr:hidden");
|
||||
this.uploadControls.classList.remove("wr:flex");
|
||||
}
|
||||
}
|
||||
|
||||
// FIXED METHOD: Update form inputs without causing recursion
|
||||
updateFormInputs() {
|
||||
// Prevent recursion by using a flag
|
||||
if (this.isUpdatingFormInput) return;
|
||||
|
||||
this.isUpdatingFormInput = true;
|
||||
|
||||
try {
|
||||
if (this.returnUrls) {
|
||||
// Set URLs as comma-separated string
|
||||
const urls = this.files
|
||||
.filter((f) => f.status === "success" && f.url)
|
||||
.map((f) => f.url);
|
||||
this.formInput.value = urls.join(",");
|
||||
} else {
|
||||
// Set files to the hidden file input
|
||||
const dataTransfer = new DataTransfer();
|
||||
this.files
|
||||
.filter(
|
||||
(f) =>
|
||||
f.status === "success" ||
|
||||
f.status === "pending",
|
||||
)
|
||||
.forEach((fileObj) => {
|
||||
dataTransfer.items.add(fileObj.file);
|
||||
});
|
||||
this.formInput.files = dataTransfer.files;
|
||||
}
|
||||
|
||||
// Manually trigger form validation instead of dispatching change event
|
||||
this.triggerFormValidation();
|
||||
} finally {
|
||||
this.isUpdatingFormInput = false;
|
||||
}
|
||||
}
|
||||
|
||||
// NEW METHOD: Trigger form validation without causing recursion
|
||||
triggerFormValidation() {
|
||||
// Find the form and trigger validation for this field
|
||||
if (this.form && this.form.dataset.__afInit) {
|
||||
// Create a custom event that the form can listen to
|
||||
const validationEvent = new CustomEvent(
|
||||
"fileuploader:validation-needed",
|
||||
{
|
||||
detail: {
|
||||
fieldName: this.name,
|
||||
element: this.formInput,
|
||||
},
|
||||
bubbles: true,
|
||||
},
|
||||
);
|
||||
this.container.dispatchEvent(validationEvent);
|
||||
|
||||
// Alternative: Directly call validation if we can access the form's validation function
|
||||
// This is safer than dispatching change events
|
||||
const fieldContainer = this.container.closest(
|
||||
`[data-field="${this.name}"]`,
|
||||
);
|
||||
if (fieldContainer) {
|
||||
// The form will handle validation through its normal mechanisms
|
||||
// when it detects changes in the data during submit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async processFiles() {
|
||||
const pendingFiles = this.files.filter(
|
||||
(f) => f.status === "pending",
|
||||
);
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
if (this.uploadBtn) {
|
||||
this.uploadBtn.disabled = true;
|
||||
this.uploadBtn.innerHTML = `
|
||||
<svg class="wr:w-4 wr:h-4 wr:animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 2v6h-6M21 13a9 9 0 1 1-3-7.7l3 3.7" />
|
||||
</svg>
|
||||
Processing...
|
||||
`;
|
||||
}
|
||||
|
||||
// Process all pending files
|
||||
const processPromises = this.files
|
||||
.map((fileObj, index) => ({ fileObj, index }))
|
||||
.filter(({ fileObj }) => fileObj.status === "pending")
|
||||
.map(({ index }) => this.processSingleFile(index));
|
||||
|
||||
await Promise.all(processPromises);
|
||||
|
||||
if (this.uploadBtn) {
|
||||
this.uploadBtn.disabled = false;
|
||||
this.uploadBtn.innerHTML = `
|
||||
<svg class="wr:w-4 wr:h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 2v6h-6M21 13a9 9 0 1 1-3-7.7l3 3.7" />
|
||||
</svg>
|
||||
Process Files
|
||||
`;
|
||||
}
|
||||
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
async processSingleFile(index) {
|
||||
const fileObj = this.files[index];
|
||||
if (
|
||||
!fileObj ||
|
||||
fileObj.status !== "pending" ||
|
||||
!this.uploadFunction
|
||||
)
|
||||
return;
|
||||
|
||||
// Update status
|
||||
fileObj.status = "processing";
|
||||
fileObj.progress = 0;
|
||||
this.renderFileList();
|
||||
|
||||
try {
|
||||
// Progress simulation
|
||||
const progressInterval = setInterval(() => {
|
||||
if (fileObj.progress < 90) {
|
||||
fileObj.progress += Math.random() * 20;
|
||||
this.updateFileProgress(index);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// Call upload function
|
||||
let result;
|
||||
if (typeof window[this.uploadFunction] === "function") {
|
||||
result = await window[this.uploadFunction](
|
||||
fileObj.file,
|
||||
index,
|
||||
this.files,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Function ${this.uploadFunction} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
// Success
|
||||
fileObj.status = "success";
|
||||
fileObj.progress = 100;
|
||||
fileObj.result = result;
|
||||
|
||||
if (this.returnUrls && typeof result === "string") {
|
||||
fileObj.url = result;
|
||||
} else if (this.returnUrls && result && result.url) {
|
||||
fileObj.url = result.url;
|
||||
}
|
||||
} catch (error) {
|
||||
fileObj.status = "error";
|
||||
fileObj.progress = 100;
|
||||
fileObj.error = error.message;
|
||||
} finally {
|
||||
this.renderFileList();
|
||||
this.updateFormInputs();
|
||||
this.updateControls();
|
||||
}
|
||||
}
|
||||
|
||||
updateFileProgress(index) {
|
||||
const fileObj = this.files[index];
|
||||
if (!fileObj) return;
|
||||
|
||||
const fileItem = this.fileList.children[index];
|
||||
if (fileItem) {
|
||||
const progressBar = fileItem.querySelector(".wr\\:bg-primary");
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${fileObj.progress}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
const init = () => {
|
||||
if (!document.getElementById(fieldId)) return;
|
||||
new FormFileUploader();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
document.addEventListener("astro:page-load", init);
|
||||
</script>
|
||||
@@ -0,0 +1,465 @@
|
||||
---
|
||||
// TimePicker.astro - Auto validation on render
|
||||
export interface Props {
|
||||
label?: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
format?: 12 | 24;
|
||||
step?: number;
|
||||
min?: string;
|
||||
max?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label = "Select Time",
|
||||
description,
|
||||
name,
|
||||
value = "",
|
||||
placeholder = "Select time",
|
||||
required = false,
|
||||
disabled = false,
|
||||
format = 12,
|
||||
step = 15,
|
||||
min,
|
||||
max,
|
||||
class: className = "",
|
||||
} = Astro.props;
|
||||
|
||||
const fieldId = `field-${name.replace(/[[\]]/g, "-")}`;
|
||||
---
|
||||
|
||||
<div class={`wr:space-y-2 ${className}`} data-field={name}>
|
||||
<label
|
||||
for={fieldId}
|
||||
class="wr:block wr:text-sm wr:font-medium wr:text-foreground"
|
||||
>
|
||||
{label}
|
||||
{required && <span class="wr:text-danger wr:ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{
|
||||
description && (
|
||||
<p class="wr:text-muted-foreground wr:text-sm wr:leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="wr:relative" id={fieldId}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
id={`${fieldId}-hidden`}
|
||||
data-control
|
||||
value={value}
|
||||
required={required}
|
||||
/>
|
||||
|
||||
<div class="wr:relative">
|
||||
<input
|
||||
type="text"
|
||||
id={`${fieldId}-display`}
|
||||
class="wr:w-full wr:px-3 wr:py-2 wr:pr-10 wr:border wr:border-border wr:rounded-lg wr:bg-background wr:text-foreground focus:wr:ring-2 focus:wr:ring-primary focus:wr:border-primary disabled:wr:opacity-50"
|
||||
placeholder={placeholder}
|
||||
readonly
|
||||
disabled={disabled}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
role="combobox"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-trigger`}
|
||||
class="wr:absolute wr:right-0 wr:top-0 wr:h-full wr:px-3 wr:flex wr:items-center wr:text-muted-foreground hover:wr:text-foreground disabled:wr:opacity-50"
|
||||
disabled={disabled}
|
||||
aria-label="Open time picker"
|
||||
>
|
||||
<svg
|
||||
class="wr:w-4 wr:h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12,6 12,12 16,14"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`${fieldId}-popover`}
|
||||
class="wr:hidden wr:absolute wr:top-full wr:left-0 wr:mt-1 wr:bg-card wr:border wr:border-border wr:rounded-lg wr:shadow-lg wr:z-50 wr:min-w-[220px]"
|
||||
role="dialog"
|
||||
aria-label="Time picker"
|
||||
>
|
||||
<div class="wr:p-4">
|
||||
<div class="wr:flex wr:items-center wr:gap-2 wr:mb-4">
|
||||
<div class="wr:flex-1">
|
||||
<label
|
||||
class="wr:block wr:text-xs wr:text-muted-foreground wr:mb-1"
|
||||
>Hour</label
|
||||
>
|
||||
<select
|
||||
id={`${fieldId}-hour`}
|
||||
class="wr:w-full wr:px-2 wr:py-1 wr:text-center wr:border wr:border-border wr:rounded focus:wr:ring-1 focus:wr:ring-primary focus:wr:border-primary wr:bg-background"
|
||||
></select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wr:text-lg wr:font-bold wr:text-muted-foreground wr:mt-4"
|
||||
>
|
||||
:
|
||||
</div>
|
||||
|
||||
<div class="wr:flex-1">
|
||||
<label
|
||||
class="wr:block wr:text-xs wr:text-muted-foreground wr:mb-1"
|
||||
>Minute</label
|
||||
>
|
||||
<select
|
||||
id={`${fieldId}-minute`}
|
||||
class="wr:w-full wr:px-2 wr:py-1 wr:text-center wr:border wr:border-border wr:rounded focus:wr:ring-1 focus:wr:ring-primary focus:wr:border-primary wr:bg-background"
|
||||
></select>
|
||||
</div>
|
||||
|
||||
{
|
||||
format === 12 && (
|
||||
<div class="wr:flex-1">
|
||||
<label class="wr:block wr:text-xs wr:text-muted-foreground wr:mb-1">
|
||||
Period
|
||||
</label>
|
||||
<select
|
||||
id={`${fieldId}-period`}
|
||||
class="wr:w-full wr:px-2 wr:py-1 wr:border wr:border-border wr:rounded focus:wr:ring-1 focus:wr:ring-primary focus:wr:border-primary wr:bg-background"
|
||||
>
|
||||
<option value="AM">AM</option>
|
||||
<option value="PM">PM</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="wr:grid wr:grid-cols-3 wr:gap-1 wr:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="wr:px-2 wr:py-1 wr:text-xs wr:bg-muted wr:rounded hover:wr:bg-muted/80"
|
||||
data-time="09:00">9:00</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wr:px-2 wr:py-1 wr:text-xs wr:bg-muted wr:rounded hover:wr:bg-muted/80"
|
||||
data-time="12:00">12:00</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wr:px-2 wr:py-1 wr:text-xs wr:bg-muted wr:rounded hover:wr:bg-muted/80"
|
||||
data-time="17:00">17:00</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="wr:flex wr:justify-between wr:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-now`}
|
||||
class="wr:px-3 wr:py-1 wr:text-sm wr:text-primary hover:wr:bg-primary/10 wr:rounded"
|
||||
>
|
||||
Now
|
||||
</button>
|
||||
<div class="wr:flex wr:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-cancel`}
|
||||
class="wr:px-3 wr:py-1 wr:text-sm wr:text-muted-foreground hover:wr:bg-muted wr:rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id={`${fieldId}-apply`}
|
||||
class="wr:px-3 wr:py-1 wr:text-sm wr:bg-primary wr:text-primary-foreground hover:wr:bg-primary/90 wr:rounded"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`${fieldId}-err`}
|
||||
class="wr:text-sm wr:text-danger wr:mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{ name, fieldId, value, format, step, min, max, disabled }}
|
||||
>
|
||||
class TimePicker {
|
||||
constructor() {
|
||||
this.name = name;
|
||||
this.fieldId = fieldId;
|
||||
this.format = format;
|
||||
this.step = step;
|
||||
this.disabled = disabled;
|
||||
this.selectedTime = value || "";
|
||||
this.isOpen = false;
|
||||
|
||||
this.container = document.getElementById(fieldId);
|
||||
this.hiddenInput = this.container.querySelector(
|
||||
`#${fieldId}-hidden`,
|
||||
);
|
||||
this.displayInput = this.container.querySelector(
|
||||
`#${fieldId}-display`,
|
||||
);
|
||||
this.trigger = this.container.querySelector(`#${fieldId}-trigger`);
|
||||
this.popover = this.container.querySelector(`#${fieldId}-popover`);
|
||||
this.hourSelect = this.container.querySelector(`#${fieldId}-hour`);
|
||||
this.minuteSelect = this.container.querySelector(
|
||||
`#${fieldId}-minute`,
|
||||
);
|
||||
this.periodSelect = this.container.querySelector(
|
||||
`#${fieldId}-period`,
|
||||
);
|
||||
this.nowBtn = this.container.querySelector(`#${fieldId}-now`);
|
||||
this.cancelBtn = this.container.querySelector(`#${fieldId}-cancel`);
|
||||
this.applyBtn = this.container.querySelector(`#${fieldId}-apply`);
|
||||
|
||||
this.form = this.container.closest("form");
|
||||
|
||||
this.initializeEventListeners();
|
||||
this.setupDropdowns();
|
||||
this.updateDisplay();
|
||||
|
||||
// UPDATED: Auto validation on render
|
||||
setTimeout(() => this.triggerAutoValidation(), 50);
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
this.trigger.addEventListener("click", () => this.toggle());
|
||||
this.displayInput.addEventListener("click", () => this.open());
|
||||
|
||||
this.hourSelect.addEventListener("change", () =>
|
||||
this.updateTimeFromInputs(),
|
||||
);
|
||||
this.minuteSelect.addEventListener("change", () =>
|
||||
this.updateTimeFromInputs(),
|
||||
);
|
||||
if (this.periodSelect) {
|
||||
this.periodSelect.addEventListener("change", () =>
|
||||
this.updateTimeFromInputs(),
|
||||
);
|
||||
}
|
||||
|
||||
this.container.querySelectorAll("[data-time]").forEach((btn) => {
|
||||
btn.addEventListener("click", () =>
|
||||
this.setQuickTime(btn.dataset.time),
|
||||
);
|
||||
});
|
||||
|
||||
this.nowBtn.addEventListener("click", () => this.setNow());
|
||||
this.cancelBtn.addEventListener("click", () => this.cancel());
|
||||
this.applyBtn.addEventListener("click", () => this.apply());
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.container.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.container.addEventListener("keydown", (e) =>
|
||||
this.handleKeydown(e),
|
||||
);
|
||||
|
||||
if (this.form) {
|
||||
this.form.addEventListener("reset", () =>
|
||||
this.handleFormReset(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setupDropdowns() {
|
||||
this.hourSelect.innerHTML = "";
|
||||
const hourMin = this.format === 12 ? 1 : 0;
|
||||
const hourMax = this.format === 12 ? 12 : 23;
|
||||
|
||||
for (let h = hourMin; h <= hourMax; h++) {
|
||||
const option = document.createElement("option");
|
||||
option.value = h;
|
||||
option.textContent = h.toString().padStart(2, "0");
|
||||
this.hourSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.minuteSelect.innerHTML = "";
|
||||
for (let m = 0; m < 60; m += this.step) {
|
||||
const option = document.createElement("option");
|
||||
option.value = m;
|
||||
option.textContent = m.toString().padStart(2, "0");
|
||||
this.minuteSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeFromInputs() {
|
||||
this.selectedTime = this.getTimeFromInputs();
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
if (this.selectedTime) {
|
||||
this.displayInput.value = this.formatTimeDisplay(
|
||||
this.selectedTime,
|
||||
);
|
||||
this.hiddenInput.value = this.selectedTime;
|
||||
this.parseTimeToInputs(this.selectedTime);
|
||||
} else {
|
||||
this.displayInput.value = "";
|
||||
this.hiddenInput.value = "";
|
||||
}
|
||||
|
||||
const changeEvent = new Event("change", { bubbles: true });
|
||||
this.hiddenInput.dispatchEvent(changeEvent);
|
||||
}
|
||||
|
||||
formatTimeDisplay(time24) {
|
||||
if (!time24) return "";
|
||||
|
||||
const [hours, minutes] = time24.split(":");
|
||||
|
||||
if (this.format === 12) {
|
||||
const hour24 = parseInt(hours);
|
||||
const hour12 =
|
||||
hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
|
||||
const period = hour24 >= 12 ? "PM" : "AM";
|
||||
return `${hour12}:${minutes} ${period}`;
|
||||
}
|
||||
|
||||
return time24;
|
||||
}
|
||||
|
||||
parseTimeToInputs(time24) {
|
||||
if (!time24) return;
|
||||
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour24 = parseInt(hours);
|
||||
|
||||
if (this.format === 12) {
|
||||
const hour12 =
|
||||
hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
|
||||
const period = hour24 >= 12 ? "PM" : "AM";
|
||||
|
||||
this.hourSelect.value = hour12;
|
||||
this.minuteSelect.value = minutes;
|
||||
if (this.periodSelect) this.periodSelect.value = period;
|
||||
} else {
|
||||
this.hourSelect.value = hours;
|
||||
this.minuteSelect.value = minutes;
|
||||
}
|
||||
}
|
||||
|
||||
getTimeFromInputs() {
|
||||
const hour = parseInt(this.hourSelect.value) || 0;
|
||||
const minute = parseInt(this.minuteSelect.value) || 0;
|
||||
|
||||
let hour24 = hour;
|
||||
|
||||
if (this.format === 12 && this.periodSelect) {
|
||||
const period = this.periodSelect.value;
|
||||
if (period === "PM" && hour !== 12) {
|
||||
hour24 = hour + 12;
|
||||
} else if (period === "AM" && hour === 12) {
|
||||
hour24 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const hourStr = String(hour24).padStart(2, "0");
|
||||
const minuteStr = String(minute).padStart(2, "0");
|
||||
|
||||
return `${hourStr}:${minuteStr}`;
|
||||
}
|
||||
|
||||
setQuickTime(time) {
|
||||
this.selectedTime = time;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setNow() {
|
||||
const now = new Date();
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||
this.setQuickTime(`${hours}:${minutes}`);
|
||||
}
|
||||
|
||||
// UPDATED: Auto validation on render
|
||||
triggerAutoValidation() {
|
||||
const blurEvent = new FocusEvent("blur", { bubbles: true });
|
||||
this.displayInput.dispatchEvent(blurEvent);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.disabled) return;
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.disabled) return;
|
||||
this.isOpen = true;
|
||||
this.popover.classList.remove("wr:hidden");
|
||||
this.displayInput.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.popover.classList.add("wr:hidden");
|
||||
this.displayInput.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.selectedTime = value || "";
|
||||
this.updateDisplay();
|
||||
this.close();
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (!this.isOpen) return;
|
||||
if (e.key === "Escape") this.cancel();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.apply();
|
||||
}
|
||||
}
|
||||
|
||||
handleFormReset() {
|
||||
this.selectedTime = value || "";
|
||||
this.updateDisplay();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
if (!document.getElementById(fieldId) || disabled) return;
|
||||
new TimePicker();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
document.addEventListener("astro:page-load", init);
|
||||
</script>
|
||||
Vendored
+2
-1
@@ -1,7 +1,8 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
schema: any;
|
||||
submit: any
|
||||
submit: any;
|
||||
upload: any;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -1,9 +1,18 @@
|
||||
export { default as Button } from "./components/Button.astro";
|
||||
export { default as Form } from "./components/Form.astro";
|
||||
export { default as Field } from "./components/Field.astro";
|
||||
export { default as MultiSelect } from "./components/MultiSelect.astro";
|
||||
export { default as ThemeSwitcher } from "./components/ThemeSwitcher.astro";
|
||||
export { default as ColorSwitcher } from "./components/ColorSwitcher.astro";
|
||||
|
||||
export { default as Button } from "../src/components/Button.astro";
|
||||
export { default as ColorSwitcher } from "../src/components/ColorSwitcher.astro";
|
||||
export { default as DatePicker } from "../src/components/DatePicker.astro";
|
||||
export { default as DateTimePicker } from "../src/components/DateTimePicker.astro";
|
||||
export { default as Dropdown } from "../src/components/Dropdown.astro";
|
||||
export { default as Field } from "../src/components/Field.astro";
|
||||
export { default as FieldRow } from "../src/components/FieldRow.astro";
|
||||
export { default as FileUploader } from "../src/components/FileUploader.astro";
|
||||
export { default as Form } from "../src/components/Form.astro";
|
||||
export { default as MultiSelect } from "../src/components/MultiSelect.astro";
|
||||
export { default as OtpField } from "../src/components/OtpField.astro";
|
||||
export { default as PasswordField } from "../src/components/PasswordField.astro";
|
||||
export { default as ThemeSwitcher } from "../src/components/ThemeSwitcher.astro";
|
||||
export { default as TimePicker } from "../src/components/TimePicker.astro";
|
||||
|
||||
// Utils
|
||||
export { cn } from "./utils/cn";
|
||||
@@ -38,6 +38,9 @@ import Base from "./Base.astro";
|
||||
<li class="list-item">
|
||||
<a href="/forms4">Forms 4</a>
|
||||
</li>
|
||||
<li class="list-item">
|
||||
<a href="/dateform">Date Time Form</a>
|
||||
</li>
|
||||
<li class="list-item">
|
||||
<a href="/otp-form">OTP Form</a>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
import Form from "../components/Form.astro";
|
||||
import DatePicker from "../components/DatePicker.astro";
|
||||
import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
import TimePicker from "../components/TimePicker.astro";
|
||||
import DateTimePicker from "../components/DateTimePicker.astro";
|
||||
---
|
||||
|
||||
<ComponentLayout>
|
||||
<Form
|
||||
title="Event Registration"
|
||||
schemaName="schema"
|
||||
onSubmit="submit"
|
||||
resetOnSubmit={true}
|
||||
>
|
||||
<DatePicker
|
||||
name="event_date"
|
||||
label="Event Date"
|
||||
description="Select the date for your event"
|
||||
required={true}
|
||||
min="2025-01-01"
|
||||
max="2025-12-31"
|
||||
format="dd/mm/yyyy"
|
||||
/>
|
||||
|
||||
<TimePicker
|
||||
name="appointment_time"
|
||||
label="Appointment Time"
|
||||
required={true}
|
||||
format={12}
|
||||
min="09:00"
|
||||
max="17:00"
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
name="meeting_datetime"
|
||||
label="Meeting Date & Time"
|
||||
description="Schedule your meeting"
|
||||
required={true}
|
||||
dateFormat="dd/mm/yyyy"
|
||||
timeFormat={12}
|
||||
timeStep={1}
|
||||
class="wr:col-span-full"
|
||||
/>
|
||||
|
||||
<div data-field="event_name">
|
||||
<label class="wr:block wr:text-sm wr:font-medium wr:mb-2"
|
||||
>Event Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="event_name"
|
||||
data-control
|
||||
class="wr:w-full wr:px-3 wr:py-2 wr:border wr:border-border wr:rounded-lg"
|
||||
required
|
||||
/>
|
||||
<div
|
||||
id="event_name-err"
|
||||
class="wr:text-sm wr:text-danger wr:mt-1"
|
||||
role="alert"
|
||||
hidden
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</ComponentLayout>
|
||||
|
||||
<script>
|
||||
import { z } from "zod";
|
||||
|
||||
window.schema = z.object({
|
||||
event_date: z.string().min(1, "Event date is required"),
|
||||
appointment_time: z.string().min(1, "Appointment Time is required"),
|
||||
meeting_datetime: z.string().min(1, "Meeting date time is required"),
|
||||
event_name: z.string().min(1, "Event name is required"),
|
||||
});
|
||||
|
||||
window.submit = async (data: any, form: any) => {
|
||||
console.log("Event data:", data);
|
||||
};
|
||||
</script>
|
||||
+20
-19
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Field from "../components/Field.astro";
|
||||
import FileUploader from "../components/FileUploader.astro";
|
||||
import Form from "../components/Form.astro";
|
||||
import MultiSelect from "../components/MultiSelect.astro";
|
||||
import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
@@ -132,13 +133,21 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
class="wr:sm:col-span-2"
|
||||
/>
|
||||
|
||||
<!-- Resume/Cover -->
|
||||
<Field
|
||||
name="resume"
|
||||
label="Resume (PDF)"
|
||||
kind="file"
|
||||
class="wr:sm:col-span-2"
|
||||
<FileUploader
|
||||
name="images"
|
||||
label="Product Images"
|
||||
description="Upload high-quality product photos"
|
||||
multiple={true}
|
||||
maxFiles={5}
|
||||
maxFileSize={10}
|
||||
acceptedTypes={["image/jpeg", "image/png", "image/webp"]}
|
||||
returnUrls={true}
|
||||
uploadFunction="upload"
|
||||
autoUpload={true}
|
||||
required={true}
|
||||
class="wr:col-span-2"
|
||||
/>
|
||||
|
||||
<Field
|
||||
name="coverLetter"
|
||||
label="Cover letter"
|
||||
@@ -163,18 +172,6 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
const phoneRegex = /^\+?[0-9\s().-]{7,20}$/i;
|
||||
const urlRegex = /^https?:\/\/.+/i;
|
||||
|
||||
const resumeSchema = z
|
||||
.union([z.instanceof(File), z.null()])
|
||||
.refine(
|
||||
(f) => f === null || /pdf$/i.test(f.type),
|
||||
"Resume must be a PDF.",
|
||||
)
|
||||
.refine(
|
||||
(f) => f === null || f.size <= 5 * 1024 * 1024,
|
||||
"Max file size is 5MB.",
|
||||
)
|
||||
.optional();
|
||||
|
||||
window.schema = z
|
||||
.object({
|
||||
firstName: z.string().min(2, "Enter at least 2 characters."),
|
||||
@@ -205,7 +202,7 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
.number()
|
||||
.positive("Enter a positive number.")
|
||||
.optional(),
|
||||
// resume: resumeSchema,
|
||||
images: z.string().min(1, "At least one image is required"),
|
||||
coverLetter: z
|
||||
.string()
|
||||
.max(1000, "Keep it under 1000 characters.")
|
||||
@@ -228,4 +225,8 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
window.submit = async (data: any, form: any) => {
|
||||
console.log("Application:", data);
|
||||
};
|
||||
|
||||
window.upload = async (file: any, index: any, allFiles: any) => {
|
||||
return "/image/file-" + file.name;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
@@ -47,7 +47,7 @@
|
||||
--color-background-100: var(--background-100);
|
||||
--color-background-200: var(--background-200);
|
||||
--color-background-300: var(--background-300);
|
||||
--color-background-400: var(--background-400);
|
||||
--color-background-400: var(--background-400);
|
||||
--color-background-500: var(--background-500);
|
||||
--color-background-600: var(--background-600);
|
||||
--color-background-700: var(--background-700);
|
||||
|
||||
Reference in New Issue
Block a user