Updated Date Picker and All New Pickers

This commit is contained in:
2025-08-21 21:14:14 +05:30
parent 2c4b7ddbc6
commit d0e859c07c
11 changed files with 2898 additions and 36 deletions
+568
View File
@@ -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>
+856
View File
@@ -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>
+862
View File
@@ -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>
+465
View File
@@ -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>
+2 -1
View File
@@ -1,7 +1,8 @@
declare global {
interface Window {
schema: any;
submit: any
submit: any;
upload: any;
}
}
+15 -6
View File
@@ -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";
+3
View File
@@ -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>
+81
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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);