Updated Date Picker and All New Pickers
This commit is contained in:
+24
-8
@@ -7,22 +7,38 @@ if (!fs.existsSync(distDir)) fs.mkdirSync(distDir, { recursive: true });
|
|||||||
|
|
||||||
const indexJs = `/* Auto-generated by scripts/make-dist.mjs */
|
const indexJs = `/* Auto-generated by scripts/make-dist.mjs */
|
||||||
export { default as Button } from "../src/components/Button.astro";
|
export { default as Button } from "../src/components/Button.astro";
|
||||||
export { default as Form } from "../src/components/Form.astro";
|
|
||||||
export { default as Field } from "../src/components/Field.astro";
|
|
||||||
export { default as MultiSelect } from "../src/components/MultiSelect.astro";
|
|
||||||
export { default as ThemeSwitcher } from "../src/components/ThemeSwitcher.astro";
|
|
||||||
export { default as ColorSwitcher } from "../src/components/ColorSwitcher.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";
|
||||||
export { cn } from "../src/utils/cn.js";
|
export { cn } from "../src/utils/cn.js";
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const indexDts = `/* Auto-generated types (lightweight) */
|
const indexDts = `/* Auto-generated types (lightweight) */
|
||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
export const Button: any;
|
export const Button: any;
|
||||||
export const Form: any;
|
|
||||||
export const Field: any;
|
|
||||||
export const MultiSelect: any;
|
|
||||||
export const ThemeSwitcher: any;
|
|
||||||
export const ColorSwitcher: any;
|
export const ColorSwitcher: any;
|
||||||
|
export const DatePicker: any;
|
||||||
|
export const DateTimePicker: any;
|
||||||
|
export const Dropdown: any;
|
||||||
|
export const Field: any;
|
||||||
|
export const FieldRow: any;
|
||||||
|
export const FileUploader: any;
|
||||||
|
export const Form: any;
|
||||||
|
export const MultiSelect: any;
|
||||||
|
export const OtpField: any;
|
||||||
|
export const PasswordField: any;
|
||||||
|
export const ThemeSwitcher: any;
|
||||||
|
export const TimePicker: any;
|
||||||
export function cn(...classes: Array<string | number | false | null | undefined>): string;
|
export function cn(...classes: Array<string | number | false | null | undefined>): string;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
schema: any;
|
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 Button } from "../src/components/Button.astro";
|
||||||
export { default as Field } from "./components/Field.astro";
|
export { default as ColorSwitcher } from "../src/components/ColorSwitcher.astro";
|
||||||
export { default as MultiSelect } from "./components/MultiSelect.astro";
|
export { default as DatePicker } from "../src/components/DatePicker.astro";
|
||||||
export { default as ThemeSwitcher } from "./components/ThemeSwitcher.astro";
|
export { default as DateTimePicker } from "../src/components/DateTimePicker.astro";
|
||||||
export { default as ColorSwitcher } from "./components/ColorSwitcher.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
|
// Utils
|
||||||
export { cn } from "./utils/cn";
|
export { cn } from "./utils/cn";
|
||||||
@@ -38,6 +38,9 @@ import Base from "./Base.astro";
|
|||||||
<li class="list-item">
|
<li class="list-item">
|
||||||
<a href="/forms4">Forms 4</a>
|
<a href="/forms4">Forms 4</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/dateform">Date Time Form</a>
|
||||||
|
</li>
|
||||||
<li class="list-item">
|
<li class="list-item">
|
||||||
<a href="/otp-form">OTP Form</a>
|
<a href="/otp-form">OTP Form</a>
|
||||||
</li>
|
</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 Field from "../components/Field.astro";
|
||||||
|
import FileUploader from "../components/FileUploader.astro";
|
||||||
import Form from "../components/Form.astro";
|
import Form from "../components/Form.astro";
|
||||||
import MultiSelect from "../components/MultiSelect.astro";
|
import MultiSelect from "../components/MultiSelect.astro";
|
||||||
import ComponentLayout from "../layouts/ComponentLayout.astro";
|
import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||||
@@ -132,13 +133,21 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
|||||||
class="wr:sm:col-span-2"
|
class="wr:sm:col-span-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Resume/Cover -->
|
<FileUploader
|
||||||
<Field
|
name="images"
|
||||||
name="resume"
|
label="Product Images"
|
||||||
label="Resume (PDF)"
|
description="Upload high-quality product photos"
|
||||||
kind="file"
|
multiple={true}
|
||||||
class="wr:sm:col-span-2"
|
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
|
<Field
|
||||||
name="coverLetter"
|
name="coverLetter"
|
||||||
label="Cover letter"
|
label="Cover letter"
|
||||||
@@ -163,18 +172,6 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
|||||||
const phoneRegex = /^\+?[0-9\s().-]{7,20}$/i;
|
const phoneRegex = /^\+?[0-9\s().-]{7,20}$/i;
|
||||||
const urlRegex = /^https?:\/\/.+/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
|
window.schema = z
|
||||||
.object({
|
.object({
|
||||||
firstName: z.string().min(2, "Enter at least 2 characters."),
|
firstName: z.string().min(2, "Enter at least 2 characters."),
|
||||||
@@ -205,7 +202,7 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
|||||||
.number()
|
.number()
|
||||||
.positive("Enter a positive number.")
|
.positive("Enter a positive number.")
|
||||||
.optional(),
|
.optional(),
|
||||||
// resume: resumeSchema,
|
images: z.string().min(1, "At least one image is required"),
|
||||||
coverLetter: z
|
coverLetter: z
|
||||||
.string()
|
.string()
|
||||||
.max(1000, "Keep it under 1000 characters.")
|
.max(1000, "Keep it under 1000 characters.")
|
||||||
@@ -228,4 +225,8 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
|||||||
window.submit = async (data: any, form: any) => {
|
window.submit = async (data: any, form: any) => {
|
||||||
console.log("Application:", data);
|
console.log("Application:", data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.upload = async (file: any, index: any, allFiles: any) => {
|
||||||
|
return "/image/file-" + file.name;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
--color-card: hsl(var(--card));
|
--color-card: hsl(var(--card));
|
||||||
--color-card-foreground: hsl(var(--card-foreground));
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
--color-popover: hsl(var(--popover));
|
--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: hsl(var(--muted));
|
||||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
--color-border: hsl(var(--border));
|
--color-border: hsl(var(--border));
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
--color-background-100: var(--background-100);
|
--color-background-100: var(--background-100);
|
||||||
--color-background-200: var(--background-200);
|
--color-background-200: var(--background-200);
|
||||||
--color-background-300: var(--background-300);
|
--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-500: var(--background-500);
|
||||||
--color-background-600: var(--background-600);
|
--color-background-600: var(--background-600);
|
||||||
--color-background-700: var(--background-700);
|
--color-background-700: var(--background-700);
|
||||||
|
|||||||
Reference in New Issue
Block a user