375 lines
14 KiB
Plaintext
375 lines
14 KiB
Plaintext
package datepicker
|
|
|
|
// DatePicker renders a reusable date picker component with DD/MM/YYYY format
|
|
//
|
|
// Features:
|
|
// - Interactive calendar dropdown with month/year selectors
|
|
// - DD/MM/YYYY format (proper date format, none of that American nonsense)
|
|
// - Year range: 2001-2051
|
|
// - Today's date highlighted in blue
|
|
// - Click outside to close
|
|
// - Consistent with Catppuccin theme
|
|
//
|
|
// Parameters:
|
|
// - id: unique ID for the input field (e.g., "start_date")
|
|
// - name: form field name (e.g., "start_date")
|
|
// - label: display label (e.g., "Start Date")
|
|
// - placeholder: input placeholder (default: "DD/MM/YYYY")
|
|
// - required: whether the field is required (true/false)
|
|
// - onChange: Alpine.js expression to run when date changes (e.g., "dateIsEmpty = false; updateCanSubmit();")
|
|
// Set to empty string "" if no custom onChange handler is needed
|
|
//
|
|
// Usage Example:
|
|
// import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker"
|
|
//
|
|
// @datepicker.DatePicker("birth_date", "birth_date", "Date of Birth", "DD/MM/YYYY", true, "handleDateChange();")
|
|
//
|
|
// The component submits the date in DD/MM/YYYY format. To parse on the server:
|
|
// date, err := time.Parse("02/01/2006", dateString)
|
|
templ DatePicker(id, name, label, placeholder string, required bool, onChange string) {
|
|
@DatePickerWithDefault(id, name, label, placeholder, required, onChange, "")
|
|
}
|
|
|
|
// DatePickerWithDefault is the same as DatePicker but accepts a default value in DD/MM/YYYY format
|
|
templ DatePickerWithDefault(id, name, label, placeholder string, required bool, onChange, defaultValue string) {
|
|
<div
|
|
x-data={ templ.JSFuncCall("datePickerData", defaultValue).CallInline }
|
|
>
|
|
<script>
|
|
function datePickerData(defaultValue) {
|
|
return {
|
|
showPicker: false,
|
|
selectedDate: defaultValue || "",
|
|
displayDate: defaultValue || "",
|
|
lastValidDate: defaultValue || "",
|
|
tempYear: new Date().getFullYear(),
|
|
tempMonth: new Date().getMonth(),
|
|
months: [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
],
|
|
init() {
|
|
if (this.displayDate) {
|
|
const parts = this.displayDate.split("/");
|
|
if (parts.length === 3) {
|
|
this.tempYear = parseInt(parts[2]);
|
|
this.tempMonth = parseInt(parts[1]) - 1;
|
|
}
|
|
} else {
|
|
this.tempYear = new Date().getFullYear();
|
|
this.tempMonth = new Date().getMonth();
|
|
}
|
|
},
|
|
getDaysInMonth(year, month) {
|
|
return new Date(year, month + 1, 0).getDate();
|
|
},
|
|
getFirstDayOfMonth(year, month) {
|
|
return new Date(year, month, 1).getDay();
|
|
},
|
|
selectDate(day) {
|
|
const d = String(day).padStart(2, "0");
|
|
const m = String(this.tempMonth + 1).padStart(2, "0");
|
|
const y = this.tempYear;
|
|
this.displayDate = d + "/" + m + "/" + y;
|
|
this.selectedDate = this.displayDate;
|
|
this.lastValidDate = this.displayDate;
|
|
this.$refs.dateInput.value = this.displayDate;
|
|
this.showPicker = false;
|
|
// Manually trigger input event so Alpine.js @input handler fires
|
|
this.$refs.dateInput.dispatchEvent(
|
|
new Event("input", { bubbles: true }),
|
|
);
|
|
},
|
|
prevMonth() {
|
|
if (this.tempMonth === 0) {
|
|
this.tempMonth = 11;
|
|
this.tempYear--;
|
|
} else {
|
|
this.tempMonth--;
|
|
}
|
|
},
|
|
nextMonth() {
|
|
if (this.tempMonth === 11) {
|
|
this.tempMonth = 0;
|
|
this.tempYear++;
|
|
} else {
|
|
this.tempMonth++;
|
|
}
|
|
},
|
|
isToday(day) {
|
|
const today = new Date();
|
|
return (
|
|
day === today.getDate() &&
|
|
this.tempMonth === today.getMonth() &&
|
|
this.tempYear === today.getFullYear()
|
|
);
|
|
},
|
|
handleInput(event) {
|
|
let value = event.target.value;
|
|
const oldValue = this.displayDate;
|
|
|
|
// Remove non-numeric characters except /
|
|
value = value.replace(/[^\d/]/g, '');
|
|
|
|
// Handle backspace - if user deletes a slash, delete the number before it too
|
|
if (value.length < oldValue.length) {
|
|
// User is deleting
|
|
if (oldValue.charAt(value.length) === '/' && value.charAt(value.length - 1) !== '/') {
|
|
// They just deleted a slash, remove the digit before it
|
|
value = value.substring(0, value.length - 1);
|
|
}
|
|
} else {
|
|
// Auto-insert slashes when typing forward
|
|
if (value.length === 2 && !value.includes('/')) {
|
|
value = value + '/';
|
|
} else if (value.length === 5 && value.split('/').length === 2) {
|
|
value = value + '/';
|
|
}
|
|
}
|
|
|
|
// Limit to DD/MM/YYYY format (10 characters)
|
|
if (value.length > 10) {
|
|
value = value.substring(0, 10);
|
|
}
|
|
|
|
this.displayDate = value;
|
|
event.target.value = value;
|
|
|
|
// Validate complete date
|
|
if (value.length === 10) {
|
|
const parts = value.split('/');
|
|
if (parts.length === 3) {
|
|
const day = parseInt(parts[0]);
|
|
const month = parseInt(parts[1]);
|
|
const year = parseInt(parts[2]);
|
|
|
|
// Basic validation
|
|
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year >= 2001 && year <= 2051) {
|
|
this.selectedDate = value;
|
|
this.lastValidDate = value;
|
|
this.tempYear = year;
|
|
this.tempMonth = month - 1;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
handleBackspace(event) {
|
|
const input = event.target;
|
|
const cursorPos = input.selectionStart;
|
|
const value = input.value;
|
|
|
|
// Check if we're about to delete a slash at position 2 or 5
|
|
if (cursorPos === 3 && value.charAt(2) === '/') {
|
|
// Delete both the slash and the character before it
|
|
event.preventDefault();
|
|
const newValue = value.substring(0, 1) + value.substring(3);
|
|
this.displayDate = newValue;
|
|
input.value = newValue;
|
|
this.$nextTick(() => {
|
|
input.setSelectionRange(1, 1);
|
|
});
|
|
} else if (cursorPos === 6 && value.charAt(5) === '/') {
|
|
// Delete both the slash and the character before it
|
|
event.preventDefault();
|
|
const newValue = value.substring(0, 4) + value.substring(6);
|
|
this.displayDate = newValue;
|
|
input.value = newValue;
|
|
this.$nextTick(() => {
|
|
input.setSelectionRange(4, 4);
|
|
});
|
|
}
|
|
// Otherwise, let default backspace behavior work
|
|
},
|
|
isValidDate(dateString) {
|
|
if (!dateString || dateString.length !== 10) return false;
|
|
const parts = dateString.split('/');
|
|
if (parts.length !== 3) return false;
|
|
|
|
const day = parseInt(parts[0]);
|
|
const month = parseInt(parts[1]);
|
|
const year = parseInt(parts[2]);
|
|
|
|
// Check ranges
|
|
if (month < 1 || month > 12) return false;
|
|
if (year < 2001 || year > 2051) return false;
|
|
if (day < 1 || day > 31) return false;
|
|
|
|
// Check valid day for month
|
|
const daysInMonth = new Date(year, month, 0).getDate();
|
|
if (day > daysInMonth) return false;
|
|
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
<label for={ id } class="block text-sm font-medium mb-2">{ label }</label>
|
|
<div class="relative" x-ref="datePickerContainer">
|
|
<input
|
|
type="text"
|
|
id={ id }
|
|
name={ name }
|
|
x-ref="dateInput"
|
|
class="py-3 px-4 pr-10 block w-full rounded-lg text-sm bg-base border-2 border-overlay0 focus:border-blue outline-none"
|
|
placeholder={ placeholder }
|
|
pattern="\d{2}/\d{2}/\d{4}"
|
|
inputmode="numeric"
|
|
if required {
|
|
required
|
|
}
|
|
x-model="displayDate"
|
|
@input="handleInput($event)"
|
|
@focus="showPicker = true"
|
|
@keydown.backspace="handleBackspace($event)"
|
|
@keydown.enter.prevent="if (isValidDate(displayDate)) { lastValidDate = displayDate; } else { displayDate = lastValidDate; $event.target.value = lastValidDate; } $event.target.blur(); showPicker = false;"
|
|
if onChange != "" {
|
|
@input.debounce.500ms={ onChange }
|
|
}
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-subtext0 hover:text-text"
|
|
@click="showPicker = !showPicker; if(showPicker) { $refs.dateInput.focus(); }"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
</svg>
|
|
</button>
|
|
<!-- Date Picker Dropdown -->
|
|
<div
|
|
x-show="showPicker"
|
|
x-cloak
|
|
@click.outside="if(!$refs.datePickerContainer.contains($event.target)) { showPicker = false }"
|
|
class="absolute z-50 mt-2 bg-mantle border-2 border-surface1 rounded-lg shadow-lg p-4 w-80"
|
|
>
|
|
<!-- Month/Year Navigation -->
|
|
<div class="flex items-center justify-between gap-2 mb-4">
|
|
<button type="button" @click="prevMonth()" class="p-1 hover:bg-surface0 rounded shrink-0">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
</svg>
|
|
</button>
|
|
<div class="flex gap-2 flex-1">
|
|
<select
|
|
x-model.number="tempMonth"
|
|
class="flex-1 px-2 py-1 text-sm bg-surface0 border border-surface1 rounded outline-none focus:border-blue"
|
|
>
|
|
<option value="0">January</option>
|
|
<option value="1">February</option>
|
|
<option value="2">March</option>
|
|
<option value="3">April</option>
|
|
<option value="4">May</option>
|
|
<option value="5">June</option>
|
|
<option value="6">July</option>
|
|
<option value="7">August</option>
|
|
<option value="8">September</option>
|
|
<option value="9">October</option>
|
|
<option value="10">November</option>
|
|
<option value="11">December</option>
|
|
</select>
|
|
<select
|
|
x-model.number="tempYear"
|
|
class="px-2 py-1 text-sm bg-surface0 border border-surface1 rounded outline-none focus:border-blue"
|
|
>
|
|
<option value="2001">2001</option>
|
|
<option value="2002">2002</option>
|
|
<option value="2003">2003</option>
|
|
<option value="2004">2004</option>
|
|
<option value="2005">2005</option>
|
|
<option value="2006">2006</option>
|
|
<option value="2007">2007</option>
|
|
<option value="2008">2008</option>
|
|
<option value="2009">2009</option>
|
|
<option value="2010">2010</option>
|
|
<option value="2011">2011</option>
|
|
<option value="2012">2012</option>
|
|
<option value="2013">2013</option>
|
|
<option value="2014">2014</option>
|
|
<option value="2015">2015</option>
|
|
<option value="2016">2016</option>
|
|
<option value="2017">2017</option>
|
|
<option value="2018">2018</option>
|
|
<option value="2019">2019</option>
|
|
<option value="2020">2020</option>
|
|
<option value="2021">2021</option>
|
|
<option value="2022">2022</option>
|
|
<option value="2023">2023</option>
|
|
<option value="2024">2024</option>
|
|
<option value="2025">2025</option>
|
|
<option value="2026">2026</option>
|
|
<option value="2027">2027</option>
|
|
<option value="2028">2028</option>
|
|
<option value="2029">2029</option>
|
|
<option value="2030">2030</option>
|
|
<option value="2031">2031</option>
|
|
<option value="2032">2032</option>
|
|
<option value="2033">2033</option>
|
|
<option value="2034">2034</option>
|
|
<option value="2035">2035</option>
|
|
<option value="2036">2036</option>
|
|
<option value="2037">2037</option>
|
|
<option value="2038">2038</option>
|
|
<option value="2039">2039</option>
|
|
<option value="2040">2040</option>
|
|
<option value="2041">2041</option>
|
|
<option value="2042">2042</option>
|
|
<option value="2043">2043</option>
|
|
<option value="2044">2044</option>
|
|
<option value="2045">2045</option>
|
|
<option value="2046">2046</option>
|
|
<option value="2047">2047</option>
|
|
<option value="2048">2048</option>
|
|
<option value="2049">2049</option>
|
|
<option value="2050">2050</option>
|
|
<option value="2051">2051</option>
|
|
</select>
|
|
</div>
|
|
<button type="button" @click="nextMonth()" class="p-1 hover:bg-surface0 rounded shrink-0">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Day Headers -->
|
|
<div class="grid grid-cols-7 gap-1 mb-2">
|
|
<div class="text-center text-xs font-medium text-subtext0 py-1">Su</div>
|
|
<div class="text-center text-xs font-medium text-subtext0 py-1">Mo</div>
|
|
<div class="text-center text-xs font-medium text-subtext0 py-1">Tu</div>
|
|
<div class="text-center text-xs font-medium text-subtext0 py-1">We</div>
|
|
<div class="text-center text-xs font-medium text-subtext0 py-1">Th</div>
|
|
<div class="text-center text-xs font-medium text-subtext0 py-1">Fr</div>
|
|
<div class="text-center text-xs font-medium text-subtext0 py-1">Sa</div>
|
|
</div>
|
|
<!-- Calendar Days -->
|
|
<div class="grid grid-cols-7 gap-1">
|
|
<template x-for="blank in getFirstDayOfMonth(tempYear, tempMonth)" :key="'blank-' + blank">
|
|
<div class="text-center py-2"></div>
|
|
</template>
|
|
<template x-for="day in getDaysInMonth(tempYear, tempMonth)" :key="'day-' + day">
|
|
<button
|
|
type="button"
|
|
@click="selectDate(day)"
|
|
class="text-center py-2 rounded hover:bg-surface0 transition"
|
|
:class="{
|
|
'bg-blue text-mantle font-bold': isToday(day),
|
|
'hover:bg-surface1': !isToday(day)
|
|
}"
|
|
x-text="day"
|
|
></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|