308 lines
10 KiB
Go
308 lines
10 KiB
Go
// Port of web/kit/DatePicker.tsx. Date math uses the stdlib time package.
|
|
package webui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"kjol/vdom"
|
|
)
|
|
|
|
const datePickerWrap = "relative w-full min-w-0"
|
|
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
|
|
const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
|
|
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
|
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
|
|
|
// NOTE: the TSX renders the dropdown into a <Portal> with fixed coordinates from
|
|
// getBoundingClientRect (tracked each frame). Portals, refs and element
|
|
// measurement have no equivalent here, so the dropdown is rendered inline and
|
|
// positioned with static Tailwind (absolute, below the field).
|
|
const datePickerDropdownPos = "absolute left-0 top-full mt-1 z-[200]"
|
|
|
|
// datePickerInputBase mirrors Forms.tsx INPUT_BASE (light, no error/success).
|
|
const datePickerInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
|
|
|
|
// datepickerInputCls reproduces Forms.tsx _inputCls for the light, no-state case.
|
|
func datepickerInputCls(small bool, extra string) string {
|
|
h, pad := "h-[38px]", "p-2"
|
|
if small {
|
|
h, pad = "h-[30px]", "p-1"
|
|
}
|
|
return cx(datePickerInputBase, h, pad, "border-neutral-300 focus:outline-sky-500", extra)
|
|
}
|
|
|
|
// datepickerParseInput parses typed/pasted text into a "YYYY-MM-DD" key, or "".
|
|
//
|
|
// NOTE: the TSX first tries YYYY-MM-DD, then falls back to the very lenient
|
|
// `new Date(text)`. Go has no equivalent, so a fixed set of common layouts is
|
|
// tried instead.
|
|
func datepickerParseInput(text string) string {
|
|
s := strings.TrimSpace(text)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
for _, layout := range []string{"2006-01-02", "1/2/2006", "01/02/2006", "2006/01/02", "January 2, 2006", "Jan 2, 2006"} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t.Format("2006-01-02")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// datepickerFormatDisplay renders a "YYYY-MM-DD" key for display.
|
|
//
|
|
// NOTE: the TSX uses Date.toLocaleDateString() (locale-dependent). Go has no
|
|
// locale formatter, so this approximates the common en-US "M/D/YYYY" form.
|
|
func datepickerFormatDisplay(iso string) string {
|
|
t, ok := calParseKey(iso)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d/%d/%d", int(t.Month()), t.Day(), t.Year())
|
|
}
|
|
|
|
// datepickerInput renders the FormInput approximation: a .ui-form wrapper around
|
|
// a styled <input>. onChange commits the parsed value on the native change event.
|
|
//
|
|
// NOTE: the TSX FormInput tracks an editing/draft state machine across
|
|
// focus/input/blur. Without persistent local signals that collapses to a single
|
|
// onchange commit; the input shows the formatted external value otherwise.
|
|
func datepickerInput(value, placeholder string, small bool, extra string, onChange func(string)) *vdom.VNode {
|
|
mods := []vdom.Mod{
|
|
vdom.Attr("type", "text"),
|
|
vdom.Attr("class", datepickerInputCls(small, extra)),
|
|
vdom.Attr("placeholder", placeholder),
|
|
vdom.Prop("value", value),
|
|
}
|
|
if onChange != nil {
|
|
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
|
onChange(datepickerParseInput(e.Value()))
|
|
}))
|
|
}
|
|
return vdom.El("div", vdom.Attr("class", "ui-form"), vdom.El("input", mods...))
|
|
}
|
|
|
|
func datepickerIconButton(onClick func()) *vdom.VNode {
|
|
return vdom.El("button",
|
|
vdom.Attr("type", "button"),
|
|
vdom.Attr("class", datePickerIconBtn),
|
|
vdom.Attr("aria-label", "Open calendar"),
|
|
vdom.On(vdom.EVENT_CLICK, onClick),
|
|
Icon("calendar", 16, "block leading-none"),
|
|
)
|
|
}
|
|
|
|
// DatePickerProps configures DatePicker and DateOfBirthPicker. It is controlled:
|
|
// Value is the "YYYY-MM-DD" selection, Open is the dropdown state, ViewMonth is
|
|
// the calendar's visible month — each paired with a callback.
|
|
//
|
|
// NOTE: the TSX owns open/editing/localValue/dropdownPos in internal signals and
|
|
// closes the dropdown via a document click listener. Outside-click handling,
|
|
// stopPropagation and computed positioning aren't representable in the neutral
|
|
// runtime, so open/selection/month are lifted to props + callbacks.
|
|
type DatePickerProps struct {
|
|
Value string // selected date "YYYY-MM-DD"
|
|
OnChange func(value string) // new selection (or "" when cleared)
|
|
Placeholder string // input placeholder
|
|
Small bool // compact input height
|
|
Clearable bool // show a clear (x) button when a value is set
|
|
Open bool // dropdown open state (controlled)
|
|
OnToggle func(open bool) // request to open/close the dropdown
|
|
ViewMonth time.Time // visible month of the dropdown calendar
|
|
OnNavigate func(month time.Time) // prev/next month requested in the dropdown
|
|
}
|
|
|
|
// DatePicker renders a text field with a calendar dropdown (picker layout).
|
|
func DatePicker(p DatePickerProps) *vdom.VNode {
|
|
hasValue := p.Value != ""
|
|
|
|
extra := "w-full pr-9"
|
|
if p.Clearable && hasValue {
|
|
extra = "w-full pr-14"
|
|
}
|
|
|
|
fieldMods := []vdom.Mod{
|
|
vdom.Attr("class", datePickerField),
|
|
vdom.On(vdom.EVENT_CLICK, func() {
|
|
if p.OnToggle != nil {
|
|
p.OnToggle(true)
|
|
}
|
|
}),
|
|
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date"), p.Small, extra, p.OnChange),
|
|
}
|
|
if p.Clearable && hasValue {
|
|
fieldMods = append(fieldMods, vdom.El("button",
|
|
vdom.Attr("type", "button"),
|
|
vdom.Attr("class", datePickerClearBtn),
|
|
vdom.Attr("aria-label", "Clear date"),
|
|
vdom.On(vdom.EVENT_CLICK, func() {
|
|
if p.OnChange != nil {
|
|
p.OnChange("")
|
|
}
|
|
if p.OnToggle != nil {
|
|
p.OnToggle(false)
|
|
}
|
|
}),
|
|
Icon("xmark", 14, "block leading-none"),
|
|
))
|
|
}
|
|
fieldMods = append(fieldMods, datepickerIconButton(func() {
|
|
if p.OnToggle != nil {
|
|
p.OnToggle(!p.Open)
|
|
}
|
|
}))
|
|
|
|
wrapMods := []vdom.Mod{
|
|
vdom.Attr("class", datePickerWrap),
|
|
vdom.El("div", fieldMods...),
|
|
}
|
|
|
|
if p.Open {
|
|
cal := Calendar(CalendarProps{
|
|
Selected: p.Value,
|
|
ViewMonth: p.ViewMonth,
|
|
Variant: CalendarVariantPicker,
|
|
OnSelect: func(key string) {
|
|
if p.OnChange != nil {
|
|
p.OnChange(key)
|
|
}
|
|
if p.OnToggle != nil {
|
|
p.OnToggle(false)
|
|
}
|
|
},
|
|
OnNavigate: p.OnNavigate,
|
|
})
|
|
wrapMods = append(wrapMods, vdom.El("div",
|
|
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
|
|
cal,
|
|
))
|
|
}
|
|
|
|
return vdom.El("div", wrapMods...)
|
|
}
|
|
|
|
// datepickerDOBCalendar renders the date-of-birth calendar: the picker grid with
|
|
// month/year <select> dropdowns in place of the static month label. Navigation
|
|
// (prev/next and both selects) is reported through onNavigate as a first-of-month.
|
|
func datepickerDOBCalendar(selKey string, view time.Time, onSelect func(string), onNavigate func(time.Time)) *vdom.VNode {
|
|
now := time.Now()
|
|
sel, hasSel := calParseKey(selKey)
|
|
|
|
if view.IsZero() {
|
|
if hasSel {
|
|
view = sel
|
|
} else {
|
|
view = now
|
|
}
|
|
}
|
|
year := view.Year()
|
|
month := view.Month()
|
|
|
|
navTo := func(m time.Time) {
|
|
if onNavigate != nil {
|
|
onNavigate(m)
|
|
}
|
|
}
|
|
|
|
// Month select (option values are 0-indexed, matching the TSX).
|
|
monthMods := []vdom.Mod{
|
|
vdom.Attr("class", calSelect),
|
|
vdom.Prop("value", strconv.Itoa(int(month)-1)),
|
|
vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
|
if m, err := strconv.Atoi(e.Value()); err == nil {
|
|
navTo(time.Date(year, time.Month(m+1), 1, 0, 0, 0, 0, time.UTC))
|
|
}
|
|
}),
|
|
}
|
|
for i, mn := range calMonthsShort {
|
|
monthMods = append(monthMods, vdom.El("option", vdom.Attr("value", strconv.Itoa(i)), vdom.Text(mn)))
|
|
}
|
|
|
|
// Year select: current year down to current-119 (120 years), like the TSX.
|
|
yearMods := []vdom.Mod{
|
|
vdom.Attr("class", calSelect),
|
|
vdom.Prop("value", strconv.Itoa(year)),
|
|
vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
|
if y, err := strconv.Atoi(e.Value()); err == nil {
|
|
navTo(time.Date(y, month, 1, 0, 0, 0, 0, time.UTC))
|
|
}
|
|
}),
|
|
}
|
|
for y := now.Year(); y > now.Year()-120; y-- {
|
|
ys := strconv.Itoa(y)
|
|
yearMods = append(yearMods, vdom.El("option", vdom.Attr("value", ys), vdom.Text(ys)))
|
|
}
|
|
|
|
header := vdom.El("div", vdom.Attr("class", calHeaderPicker),
|
|
vdom.El("button",
|
|
vdom.Attr("type", "button"),
|
|
vdom.Attr("class", calNavBtn),
|
|
vdom.On(vdom.EVENT_CLICK, func() {
|
|
navTo(time.Date(year, month-1, 1, 0, 0, 0, 0, time.UTC))
|
|
}),
|
|
Icon("chevron-left", 16, ""),
|
|
),
|
|
vdom.El("select", monthMods...),
|
|
vdom.El("select", yearMods...),
|
|
vdom.El("button",
|
|
vdom.Attr("type", "button"),
|
|
vdom.Attr("class", calNavBtn),
|
|
vdom.On(vdom.EVENT_CLICK, func() {
|
|
navTo(time.Date(year, month+1, 1, 0, 0, 0, 0, time.UTC))
|
|
}),
|
|
Icon("chevron-right", 16, ""),
|
|
),
|
|
)
|
|
|
|
return vdom.El("div",
|
|
vdom.Attr("class", calPickerRoot),
|
|
header,
|
|
calWeekdaysRow(calWeekdaysPicker, calWeekdayPicker),
|
|
calPickerDaysGrid(view, sel, hasSel, now, onSelect, nil),
|
|
)
|
|
}
|
|
|
|
// DateOfBirthPicker is DatePicker with a month/year select calendar, suited to
|
|
// picking far-past dates. It reuses DatePickerProps (Clearable is unused).
|
|
func DateOfBirthPicker(p DatePickerProps) *vdom.VNode {
|
|
fieldMods := []vdom.Mod{
|
|
vdom.Attr("class", datePickerField),
|
|
vdom.On(vdom.EVENT_CLICK, func() {
|
|
if p.OnToggle != nil {
|
|
p.OnToggle(true)
|
|
}
|
|
}),
|
|
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date of birth"), p.Small, "w-full pr-9", p.OnChange),
|
|
datepickerIconButton(func() {
|
|
if p.OnToggle != nil {
|
|
p.OnToggle(!p.Open)
|
|
}
|
|
}),
|
|
}
|
|
|
|
wrapMods := []vdom.Mod{
|
|
vdom.Attr("class", datePickerWrap),
|
|
vdom.El("div", fieldMods...),
|
|
}
|
|
|
|
if p.Open {
|
|
cal := datepickerDOBCalendar(p.Value, p.ViewMonth, func(key string) {
|
|
if p.OnChange != nil {
|
|
p.OnChange(key)
|
|
}
|
|
if p.OnToggle != nil {
|
|
p.OnToggle(false)
|
|
}
|
|
}, p.OnNavigate)
|
|
wrapMods = append(wrapMods, vdom.El("div",
|
|
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
|
|
cal,
|
|
))
|
|
}
|
|
|
|
return vdom.El("div", wrapMods...)
|
|
}
|