From 0006d1d9a9d1742fb19bcc80118c31280a899b6a Mon Sep 17 00:00:00 2001 From: Natxo <1172351+natxocc@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:12:30 +0200 Subject: [PATCH] Add Datepicker component for date selection --- src/components/Datepicker.js | 252 +++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/components/Datepicker.js diff --git a/src/components/Datepicker.js b/src/components/Datepicker.js new file mode 100644 index 0000000..91d7f11 --- /dev/null +++ b/src/components/Datepicker.js @@ -0,0 +1,252 @@ +import { $, $html, $if } from "sigpro"; +import { val } from "../core/utils.js"; +import { + iconCalendar, + iconLeft, + iconRight, + iconLLeft, + iconRRight +} from "../core/icons.js"; +import { Input } from "./Input.js"; + +/** DATEPICKER */ +export const Datepicker = (props) => { + const { value, range, label, placeholder, hour = false, ...rest } = props; + + const isOpen = $(false); + const internalDate = $(new Date()); + const hoverDate = $(null); + const startHour = $(0); + const endHour = $(0); + const isRangeMode = () => val(range) === true; + + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + + const formatDate = (d) => { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const selectDate = (date) => { + const dateStr = formatDate(date); + const current = val(value); + + if (isRangeMode()) { + if (!current?.start || (current.start && current.end)) { + if (typeof value === "function") { + value({ + start: dateStr, + end: null, + ...(hour && { startHour: startHour() }), + }); + } + } else { + const start = current.start; + if (typeof value === "function") { + const newValue = dateStr < start ? { start: dateStr, end: start } : { start, end: dateStr }; + if (hour) { + newValue.startHour = current.startHour || startHour(); + newValue.endHour = current.endHour || endHour(); + } + value(newValue); + } + isOpen(false); + } + } else { + if (typeof value === "function") { + value(hour ? `${dateStr}T${String(startHour()).padStart(2, "0")}:00:00` : dateStr); + } + isOpen(false); + } + }; + + const displayValue = $(() => { + const v = val(value); + if (!v) return ""; + if (typeof v === "string") { + if (hour && v.includes("T")) return v.replace("T", " "); + return v; + } + if (v.start && v.end) { + const startStr = hour && v.startHour ? `${v.start} ${String(v.startHour).padStart(2, "0")}:00` : v.start; + const endStr = hour && v.endHour ? `${v.end} ${String(v.endHour).padStart(2, "0")}:00` : v.end; + return `${startStr} - ${endStr}`; + } + if (v.start) { + const startStr = hour && v.startHour ? `${v.start} ${String(v.startHour).padStart(2, "0")}:00` : v.start; + return `${startStr}...`; + } + return ""; + }); + + const move = (m) => { + const d = internalDate(); + internalDate(new Date(d.getFullYear(), d.getMonth() + m, 1)); + }; + + const moveYear = (y) => { + const d = internalDate(); + internalDate(new Date(d.getFullYear() + y, d.getMonth(), 1)); + }; + + const HourSlider = ({ value: hVal, onChange }) => { + return $html("div", { class: "flex-1" }, [ + $html("div", { class: "flex gap-2 items-center" }, [ + $html("input", { + type: "range", + min: 0, + max: 23, + value: hVal, + class: "range range-xs flex-1", + oninput: (e) => { + const newHour = parseInt(e.target.value); + onChange(newHour); + }, + }), + $html("span", { class: "text-sm font-mono min-w-[48px] text-center" }, + () => String(val(hVal)).padStart(2, "0") + ":00" + ), + ]), + ]); + }; + + return $html("div", { class: "relative w-full" }, [ + Input({ + label, + placeholder: placeholder || (isRangeMode() ? "Seleccionar rango..." : "Seleccionar fecha..."), + value: displayValue, + readonly: true, + icon: $html("img", { src: iconCalendar, class: "opacity-40" }), + onclick: (e) => { + e.stopPropagation(); + isOpen(!isOpen()); + }, + ...rest, + }), + + $if(isOpen, () => + $html( + "div", + { + class: "absolute left-0 mt-2 p-4 bg-base-100 border border-base-300 shadow-2xl rounded-box z-[100] w-80 select-none", + onclick: (e) => e.stopPropagation(), + }, + [ + $html("div", { class: "flex justify-between items-center mb-4 gap-1" }, [ + $html("div", { class: "flex gap-0.5" }, [ + $html("button", { type: "button", class: "btn btn-ghost btn-xs px-1", onclick: () => moveYear(-1) }, + $html("img", { src: iconLLeft, class: "opacity-40" }) + ), + $html("button", { type: "button", class: "btn btn-ghost btn-xs px-1", onclick: () => move(-1) }, + $html("img", { src: iconLeft, class: "opacity-40" }) + ), + ]), + $html("span", { class: "font-bold uppercase flex-1 text-center" }, [ + () => internalDate().toLocaleString("es-ES", { month: "short", year: "numeric" }), + ]), + $html("div", { class: "flex gap-0.5" }, [ + $html("button", { type: "button", class: "btn btn-ghost btn-xs px-1", onclick: () => move(1) }, + $html("img", { src: iconRight, class: "opacity-40" }) + ), + $html("button", { type: "button", class: "btn btn-ghost btn-xs px-1", onclick: () => moveYear(1) }, + $html("img", { src: iconRRight, class: "opacity-40" }) + ), + ]), + ]), + + $html("div", { class: "grid grid-cols-7 gap-1", onmouseleave: () => hoverDate(null) }, [ + ...["L", "M", "X", "J", "V", "S", "D"].map((d) => $html("div", { class: "text-[10px] opacity-40 font-bold text-center" }, d)), + () => { + const d = internalDate(); + const year = d.getFullYear(); + const month = d.getMonth(); + const firstDay = new Date(year, month, 1).getDay(); + const offset = firstDay === 0 ? 6 : firstDay - 1; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const nodes = []; + for (let i = 0; i < offset; i++) nodes.push($html("div")); + + for (let i = 1; i <= daysInMonth; i++) { + const date = new Date(year, month, i); + const dStr = formatDate(date); + + nodes.push( + $html( + "button", + { + type: "button", + class: () => { + const v = val(value); + const h = hoverDate(); + const isStart = typeof v === "string" ? v.split("T")[0] === dStr : v?.start === dStr; + const isEnd = v?.end === dStr; + let inRange = false; + + if (isRangeMode() && v?.start) { + const start = v.start; + if (!v.end && h) { + inRange = (dStr > start && dStr <= h) || (dStr < start && dStr >= h); + } else if (v.end) { + inRange = dStr > start && dStr < v.end; + } + } + + const base = "btn btn-xs p-0 aspect-square min-h-0 h-auto font-normal relative"; + const state = isStart || isEnd ? "btn-primary z-10" : inRange ? "bg-primary/20 border-none rounded-none" : "btn-ghost"; + const today = dStr === todayStr ? "ring-1 ring-primary ring-inset font-black text-primary" : ""; + + return `${base} ${state} ${today}`; + }, + onmouseenter: () => { if (isRangeMode()) hoverDate(dStr); }, + onclick: () => selectDate(date), + }, + [i.toString()], + ), + ); + } + return nodes; + }, + ]), + + hour ? $html("div", { class: "mt-3 pt-2 border-t border-base-300" }, [ + isRangeMode() + ? $html("div", { class: "flex gap-4" }, [ + HourSlider({ + value: startHour, + onChange: (newHour) => { + startHour(newHour); + const currentVal = val(value); + if (currentVal?.start) value({ ...currentVal, startHour: newHour }); + }, + }), + HourSlider({ + value: endHour, + onChange: (newHour) => { + endHour(newHour); + const currentVal = val(value); + if (currentVal?.end) value({ ...currentVal, endHour: newHour }); + }, + }), + ]) + : HourSlider({ + value: startHour, + onChange: (newHour) => { + startHour(newHour); + const currentVal = val(value); + if (currentVal && typeof currentVal === "string" && currentVal.includes("-")) { + value(currentVal.split("T")[0] + "T" + String(newHour).padStart(2, "0") + ":00:00"); + } + }, + }), + ]) : null, + ], + ), + ), + + $if(isOpen, () => $html("div", { class: "fixed inset-0 z-[90]", onclick: () => isOpen(false) })), + ]); +};