// Port of web/kit/Calendar.tsx. Date math uses the stdlib time package. package webui import ( "strconv" "time" "kjol/vdom" ) // Day-of-week and month labels (module-level in the TSX). Prefixed to stay // private to the Calendar/DatePicker files that share them. var calDays = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} var calMonths = []string{ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", } var calMonthsShort = []string{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", } // -- Shared Tailwind class constants (also used by datepicker.go). These are // exported from Calendar.tsx; here they stay unexported with a cal prefix so the // two ported files can share them without widening the package's public API. -- const calPickerRoot = "p-2 min-w-[240px]" const calMonthRoot = "p-0 min-w-0 w-full bg-white border border-neutral-200 rounded-default shadow-sm overflow-hidden" const calHeaderPicker = "flex items-center justify-between mb-2 gap-1" const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-neutral-200 bg-neutral-50" const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body" const calMyPicker = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap" const calMyMonth = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading whitespace-nowrap" const calWeekdaysPicker = "grid grid-cols-7 gap-[2px] mb-1" const calWeekdaysMonth = "grid grid-cols-7 border-b border-neutral-200" const calWeekdayPicker = "text-center text-xs font-semibold text-text-muted p-1" const calWeekdayMonth = "text-center text-xs font-semibold text-text-muted p-2 uppercase tracking-wider" const calDaysPicker = "grid grid-cols-7 gap-[2px]" const calDaysMonth = "grid grid-cols-7" const calDayPickerBase = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-text-body p-0" const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-neutral-200 text-left gap-1 text-xs bg-transparent cursor-pointer" const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-neutral-200 rounded-sm bg-white text-text-heading cursor-pointer focus:outline-hidden focus:border-primary" // Calendar variants. const ( CalendarVariantPicker = "picker" CalendarVariantMonth = "month" ) // calDaysInMonth returns the number of days in month (day 0 of the next month). func calDaysInMonth(year int, month time.Month) int { return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() } // calFirstWeekday returns the weekday of the 1st (Sunday=0 .. Saturday=6), // matching JS getDay(). func calFirstWeekday(year int, month time.Month) int { return int(time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Weekday()) } // calDateKey formats a date as the "YYYY-MM-DD" key the callbacks emit. func calDateKey(t time.Time) string { return t.Format("2006-01-02") } // calParseKey strictly parses a "YYYY-MM-DD" key. // // NOTE: the TSX passes selected/viewMonth through `new Date(...)`, which accepts // many free-form strings; the Go API is documented as "YYYY-MM-DD" so parsing is // narrowed to that layout. func calParseKey(s string) (time.Time, bool) { if s == "" { return time.Time{}, false } t, err := time.Parse("2006-01-02", s) if err != nil { return time.Time{}, false } return t, true } func calSameDay(a, b time.Time) bool { return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() } // calCell is one grid cell: a day, or an empty leading blank. type calCell struct { date time.Time empty bool } // calMonthCells builds the padded month grid (leading blanks + each day). func calMonthCells(year int, month time.Month) []calCell { n := calDaysInMonth(year, month) first := calFirstWeekday(year, month) cells := make([]calCell, 0, first+n) for i := 0; i < first; i++ { cells = append(cells, calCell{empty: true}) } for d := 1; d <= n; d++ { cells = append(cells, calCell{date: time.Date(year, month, d, 0, 0, 0, 0, time.UTC)}) } return cells } // calWeekdaysRow renders the Su..Sa header row for the given grid/cell classes. func calWeekdaysRow(rowCls, cellCls string) *vdom.VNode { mods := []vdom.Mod{vdom.Attr("class", rowCls)} for _, d := range calDays { mods = append(mods, vdom.Div(vdom.Attr("class", cellCls), vdom.Text(d))) } return vdom.Div(mods...) } func calDayClassPicker(empty, selected, today bool) string { c := calDayPickerBase if empty { c = cx(c, "cursor-default") } else { c = cx(c, "hover:bg-neutral-100") } if today { c = cx(c, "font-bold text-primary") } if selected { c = cx(c, "!bg-primary !text-white") } return c } // calDayClassMonth mirrors the month-variant class logic. idx%7==6 (last column) // drops the right border since the Tailwind compiler can't do [&:nth-child(7n)]. func calDayClassMonth(idx int, empty, selected bool) string { c := calDayMonthBase if idx%7 == 6 { c = cx(c, "border-r-0") } if empty { c = cx(c, "bg-neutral-50 cursor-default") } else { c = cx(c, "hover:bg-neutral-50") } if selected { c = cx(c, "bg-primary/10 text-text-body") } return c } func calDayNumberClassMonth(today bool) string { if today { return "bg-primary text-white rounded-full w-6 h-6 inline-flex items-center justify-center p-0 self-end text-sm font-semibold" } return "text-sm font-semibold text-text-muted self-end px-1 py-0.5" } // calPickerDaysGrid renders the picker-variant day grid. renderDay is optional. // Shared by Calendar (picker) and the DatePicker date-of-birth calendar. func calPickerDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect func(string), renderDay func(string, time.Time) *vdom.VNode) *vdom.VNode { cells := calMonthCells(view.Year(), view.Month()) mods := []vdom.Mod{vdom.Attr("class", calDaysPicker)} for _, cell := range cells { selected := hasSel && !cell.empty && calSameDay(cell.date, sel) today := !cell.empty && calSameDay(cell.date, now) btnMods := []vdom.Mod{ vdom.Attr("type", "button"), vdom.Attr("class", calDayClassPicker(cell.empty, selected, today)), } if cell.empty { btnMods = append(btnMods, vdom.Attr("disabled", "disabled")) } else { d := cell.date btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { if onSelect != nil { onSelect(calDateKey(d)) } })) } num := "" if !cell.empty { num = strconv.Itoa(cell.date.Day()) } btnMods = append(btnMods, vdom.Span(vdom.Attr("class", "leading-none"), vdom.Text(num))) if !cell.empty && renderDay != nil { if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil { btnMods = append(btnMods, extra) } } mods = append(mods, vdom.Button(btnMods...)) } return vdom.Div(mods...) } // calMonthDaysGrid renders the month-variant day grid. func calMonthDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect func(string), renderDay func(string, time.Time) *vdom.VNode) *vdom.VNode { cells := calMonthCells(view.Year(), view.Month()) mods := []vdom.Mod{vdom.Attr("class", calDaysMonth)} for idx, cell := range cells { selected := hasSel && !cell.empty && calSameDay(cell.date, sel) today := !cell.empty && calSameDay(cell.date, now) btnMods := []vdom.Mod{ vdom.Attr("type", "button"), vdom.Attr("class", calDayClassMonth(idx, cell.empty, selected)), } if cell.empty { btnMods = append(btnMods, vdom.Attr("disabled", "disabled")) } else { d := cell.date btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { if onSelect != nil { onSelect(calDateKey(d)) } })) } num := "" if !cell.empty { num = strconv.Itoa(cell.date.Day()) } btnMods = append(btnMods, vdom.Span(vdom.Attr("class", calDayNumberClassMonth(today)), vdom.Text(num))) if !cell.empty && renderDay != nil { if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil { btnMods = append(btnMods, extra) } } mods = append(mods, vdom.Button(btnMods...)) } return vdom.Div(mods...) } // CalendarProps configures Calendar. It is a controlled component: the visible // month is a plain value (ViewMonth) driven by the OnNavigate callback, and the // selection is a "YYYY-MM-DD" key driven by OnSelect. // // NOTE: the TSX kept ViewMonth in an internal signal synced from props via // effects, plus attr:key / _reset re-render hacks. Those have no equivalent in // the neutral runtime; the month is modeled as an explicit prop + callback. type CalendarProps struct { Selected string // selected date "YYYY-MM-DD" (empty = none) ViewMonth time.Time // visible month (any day within it); zero => Selected's month, else today Variant string // "picker" (default) or "month" OnSelect func(key string) // clicked day, as "YYYY-MM-DD" OnNavigate func(month time.Time) // first-of-month requested by the prev/next buttons RenderDay func(key string, date time.Time) *vdom.VNode // optional per-day content Class string } // Calendar renders a month grid in either the compact "picker" layout or the // large "month" layout. func Calendar(p CalendarProps) *vdom.VNode { now := time.Now() sel, hasSel := calParseKey(p.Selected) view := p.ViewMonth if view.IsZero() { if hasSel { view = sel } else { view = now } } isMonth := p.Variant == CalendarVariantMonth rootCls := calPickerRoot headerCls := calHeaderPicker myCls := calMyPicker weekdaysCls := calWeekdaysPicker weekdayCls := calWeekdayPicker if isMonth { rootCls = calMonthRoot headerCls = calHeaderMonth myCls = calMyMonth weekdaysCls = calWeekdaysMonth weekdayCls = calWeekdayMonth } prev := time.Date(view.Year(), view.Month()-1, 1, 0, 0, 0, 0, time.UTC) next := time.Date(view.Year(), view.Month()+1, 1, 0, 0, 0, 0, time.UTC) navigate := func(m time.Time) func() { return func() { if p.OnNavigate != nil { p.OnNavigate(m) } } } header := vdom.Div(vdom.Attr("class", headerCls), vdom.Button(vdom.Attr("type", "button"), vdom.Attr("class", calNavBtn), vdom.On(vdom.EVENT_CLICK, navigate(prev)), Icon("chevron-left", 16, ""), ), vdom.Span(vdom.Attr("class", myCls), vdom.Text(calMonths[int(view.Month())-1]+" "+strconv.Itoa(view.Year()))), vdom.Button(vdom.Attr("type", "button"), vdom.Attr("class", calNavBtn), vdom.On(vdom.EVENT_CLICK, navigate(next)), Icon("chevron-right", 16, ""), ), ) var daysGrid *vdom.VNode if isMonth { daysGrid = calMonthDaysGrid(view, sel, hasSel, now, p.OnSelect, p.RenderDay) } else { daysGrid = calPickerDaysGrid(view, sel, hasSel, now, p.OnSelect, p.RenderDay) } return vdom.Div(vdom.Attr("class", cx(rootCls, p.Class)), header, calWeekdaysRow(weekdaysCls, weekdayCls), daysGrid, ) }