Files
kjol/go/webui/calendar.go

323 lines
11 KiB
Go

// 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.El("div", vdom.Attr("class", cellCls), vdom.Text(d)))
}
return vdom.El("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.El("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.El("button", btnMods...))
}
return vdom.El("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.El("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.El("button", btnMods...))
}
return vdom.El("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.El("div", vdom.Attr("class", headerCls),
vdom.El("button",
vdom.Attr("type", "button"),
vdom.Attr("class", calNavBtn),
vdom.On(vdom.EVENT_CLICK, navigate(prev)),
Icon("chevron-left", 16, ""),
),
vdom.El("span", vdom.Attr("class", myCls),
vdom.Text(calMonths[int(view.Month())-1]+" "+strconv.Itoa(view.Year()))),
vdom.El("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.El("div",
vdom.Attr("class", cx(rootCls, p.Class)),
header,
calWeekdaysRow(weekdaysCls, weekdayCls),
daysGrid,
)
}