Files
kjol/go/webui/datepicker.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...)
}