320 lines
11 KiB
Go
320 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-surface border border-line 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-line bg-surface-muted"
|
|
|
|
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-surface-raised 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-line"
|
|
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-line text-left gap-1 text-xs bg-transparent cursor-pointer"
|
|
|
|
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-line rounded-sm bg-surface 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-surface-raised")
|
|
}
|
|
if today {
|
|
c = cx(c, "font-bold text-accent")
|
|
}
|
|
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-surface-muted cursor-default")
|
|
} else {
|
|
c = cx(c, "hover:bg-surface-muted")
|
|
}
|
|
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,
|
|
)
|
|
}
|