Files
kjol/go/webui/datepicker.go

682 lines
25 KiB
Go

// Port of web/kit/DatePicker.tsx. Date math uses the stdlib time package; the
// dropdown is placed by the Floating controller (floating.go) over the host API
// (kjol/wasmruntime), so it portals to <body>, is measured before it is revealed,
// and closes on an outside mousedown or Escape.
//
// This file is a CONSTRUCTOR + Render() pair rather than a bare props->VNode
// function, because it owns live state: a Floating controller, four signals and two
// refs. Those must be created ONCE — building them inside a render function would
// hand every frame a fresh (empty) ref and a fresh controller. Callers keep the
// value:
//
// dp := webui.NewDatePicker(webui.DatePickerProps{OnChange: func(v string) { ... }})
// // ...in the render function:
// dp.Render()
//
// The component is uncontrolled (like the TSX): Props.Value seeds it, OnChange
// reports every commit, and SetValue pushes a new value in from outside.
package webui
import (
"strconv"
"strings"
"time"
"kjol/wasmruntime"
"kjol/wasmruntime/vdom"
)
// -- Tailwind class strings, verbatim from DatePicker.tsx (cmd/twcss scans these) --
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-surface border border-line 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"
// datePickerDropdownPos positions the DATE-OF-BIRTH dropdown only. That one is
// inline in the TSX too (DatePicker.tsx:535-539: a plain <div>, no Portal, no
// measurement), so it stays inline here — anchored with static Tailwind instead of
// being portaled and measured. DatePicker's dropdown does NOT use this; it goes
// through Floating.
const datePickerDropdownPos = "absolute left-0 top-full mt-1 z-[200]"
// datePickerBoxStyle is the declared inline style of the portaled dropdown box.
// It must be IDENTICAL on every render: the reconciler only writes the style
// attribute when the declared string changes, which is what keeps it from
// clobbering the min-width/max-width that applyWidth writes imperatively (and the
// top/left/max-height that Floating writes on its own panel).
const datePickerBoxStyle = "width:max-content"
// Dropdown sizing, from DatePicker.tsx:207-211 (min-width 16rem, max-width 400,
// 8px viewport gutter).
const (
datePickerMinWidth = 256.0
datePickerMaxWidth = 400.0
datePickerGutter = 8.0
)
// -- parsing / formatting -----------------------------------------------------
// datePickerLayouts are tried in order against the (whitespace-normalized) input.
// The TSX fell back to `new Date(anyString)`, which Go has no equivalent of, so the
// lenience is spelled out here instead.
//
// Numeric fields use the NON-padded verbs ("1", "2") on purpose: time.Parse accepts
// both "7" and "07" for those, so one layout covers "7/4/2026" and "07/04/2026".
// Two-digit years need their own entries ("06"); Go maps 69-99 to 19xx and 00-68 to
// 20xx. Month names are matched case-insensitively by time.Parse, so "jan 2 2026"
// works. Ambiguous all-numeric forms are read month-first (US), which is what the
// JS Date constructor the TSX relied on does for slash-separated dates.
var datePickerLayouts = []string{
"2006-01-02", // ISO — what this component emits, so it is the fast path
"2006-1-2",
"2006/1/2",
"20060102",
"1/2/2006", "1-2-2006", "1.2.2006",
"1/2/06", "1-2-06", "1.2.06",
"January 2, 2006", "Jan 2, 2006",
"January 2 2006", "Jan 2 2006",
"2 January 2006", "2 Jan 2006",
"2006-01-02T15:04:05", // a pasted timestamp
"2006-01-02 15:04:05",
time.RFC3339,
}
// datePickerParse parses typed or pasted text into a "YYYY-MM-DD" key, or "" if
// nothing in datePickerLayouts matches. Invalid calendar dates (Feb 30) fail to
// parse, so they clear the field rather than silently rolling over.
func datePickerParse(text string) string {
s := strings.Join(strings.Fields(text), " ") // trim + collapse inner whitespace
if s == "" {
return ""
}
for _, layout := range datePickerLayouts {
if t, err := time.Parse(layout, s); err == nil {
return t.Format("2006-01-02")
}
}
return ""
}
// datePickerFormat renders a "YYYY-MM-DD" key for display.
//
// NOTE: the TSX uses Date.toLocaleDateString() (locale-dependent). Go has no locale
// formatter, so this produces the common en-US "M/D/YYYY" form — which is also the
// first thing datePickerParse accepts, so a value survives a display -> edit ->
// commit round trip unchanged.
func datePickerFormat(iso string) string {
t, ok := calParseKey(iso)
if !ok {
return ""
}
return strconv.Itoa(int(t.Month())) + "/" + strconv.Itoa(t.Day()) + "/" + strconv.Itoa(t.Year())
}
// -- shared state + field rendering -------------------------------------------
// DatePickerProps configures NewDatePicker and NewDateOfBirthPicker.
type DatePickerProps struct {
Value string // initial selection, "YYYY-MM-DD" ("" = none)
OnChange func(value string) // fired on every commit: a picked day, a typed date, or "" when cleared
Placeholder string // input placeholder
Small bool // compact input height
Clearable bool // show a clear (x) button when a value is set (DatePicker only)
}
// datePickerCore is the half the two pickers share: the value/draft/editing state
// machine, the visible month, and the text field. Both pickers embed it.
//
// The state machine (restored from the TSX; the first Go port collapsed it to a
// single onchange commit):
//
// focus -> editing = true, draft = the formatted value
// input -> draft = what was typed
// blur -> commit(draft), editing = false
// outside click -> commit(draft) as well, then close
//
// While editing, the input shows the raw draft — so typing is not fought by a
// reformat on every keystroke.
type datePickerCore struct {
props DatePickerProps
placeholder string
value *vdom.Signal[string] // the committed selection, "YYYY-MM-DD"
draft *vdom.Signal[string] // the raw text in the input while editing
editing *vdom.Signal[bool] // is the input focused / being typed into
view *vdom.Signal[time.Time] // the calendar's visible month; zero = derive from the selection
// fieldRef is on the .ui-form wrapper, which is a block box the full width of
// the field. The dropdown's min-width is the field's width, and Floating owns
// (and does not expose) the trigger ref, so this is what we measure.
fieldRef *vdom.Ref
}
func newDatePickerCore(p DatePickerProps, placeholder string) datePickerCore {
return datePickerCore{
props: p,
placeholder: pick(p.Placeholder, placeholder),
value: vdom.NewSignal(p.Value),
draft: vdom.NewSignal(datePickerFormat(p.Value)),
editing: vdom.NewSignal(false),
view: vdom.NewSignal(time.Time{}),
fieldRef: vdom.NewRef(),
}
}
// Value is the current selection as "YYYY-MM-DD" ("" when empty).
func (c *datePickerCore) Value() string { return c.value.Get() }
// SetValue pushes a selection in from outside. It does NOT fire OnChange — the
// caller already knows.
func (c *datePickerCore) SetValue(key string) { c.set(key, false) }
// display is the formatted committed value; inputValue is what the <input> shows.
func (c *datePickerCore) display() string { return datePickerFormat(c.value.Get()) }
func (c *datePickerCore) inputValue() string {
if c.editing.Get() {
return c.draft.Get()
}
return c.display()
}
// set makes key the selection and ends editing. The visible month follows the
// selection (the TSX did this with a createEffect on `selected`).
func (c *datePickerCore) set(key string, notify bool) {
dpSet(c.editing, false)
if c.value.Get() != key {
dpSet(c.value, key)
dpSet(c.view, time.Time{}) // re-derive from the new selection
}
dpSet(c.draft, datePickerFormat(key))
if notify && c.props.OnChange != nil {
c.props.OnChange(key)
}
}
// commit parses raw text and makes it the selection.
//
// OnChange fires only if the value actually CHANGED. Focus seeds the draft from the
// displayed value, so a plain focus/blur (or a click that moves focus into the
// dropdown) commits a draft identical to what is already selected — the TSX fired
// onchange on every one of those, and on an empty field it fired onchange("") just
// for tabbing through.
func (c *datePickerCore) commit(raw string) {
key := datePickerParse(raw)
c.set(key, key != c.value.Get())
}
// commitDraft commits whatever is in the input, if it is being edited. Called from
// blur and from the outside-click close.
func (c *datePickerCore) commitDraft() {
if c.editing.Get() {
c.commit(c.draft.Get())
}
}
func (c *datePickerCore) navigate(month time.Time) { dpSet(c.view, month) }
// input renders the field's <input>, wrapped in .ui-form — the same markup
// FormInput produces (its classes come from the same formInputCls), reproduced here
// because the datepicker needs two things FormInputProps cannot express: a ref on
// the wrapper, and a keydown handler that can see the key.
//
// onOpen runs when the input is clicked (open, never toggle); onEnter runs after
// Enter has committed.
func (c *datePickerCore) input(extra string, onOpen, onEnter func()) *vdom.VNode {
inputMods := []vdom.Mod{
vdom.Attr("type", "text"),
vdom.Attr("class", formInputCls(c.props.Small, "", "", extra, false)),
vdom.Attr("placeholder", c.placeholder),
vdom.Prop("value", c.inputValue()),
vdom.On(vdom.EVENT_FOCUS, func() {
dpSet(c.draft, c.display())
dpSet(c.editing, true)
}),
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { dpSet(c.draft, e.Value()) }),
vdom.On(vdom.EVENT_BLUR, c.commitDraft),
// The field wrapping this input is the Floating trigger, and a trigger's click
// TOGGLES. Clicking the input must only ever OPEN (the TSX's openCalendar), or
// placing the caret in a field with the calendar already down would close it.
// Hence stopPropagation — exactly what the TSX did, for the same reason.
vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
e.StopPropagation()
if onOpen != nil {
onOpen()
}
}),
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) {
switch e.Key() {
case vdom.KEY_ESCAPE:
// Let it bubble: Floating's document listener closes the topmost panel.
case vdom.KEY_ENTER:
// Floating.Trigger turns Enter into a toggle *and* preventDefaults it on a
// non-button trigger, which would swallow a form submit. Keep it off the
// trigger and commit instead.
e.StopPropagation()
c.commitDraft()
if onEnter != nil {
onEnter()
}
default:
// Every other key — Space above all, which the trigger would otherwise eat
// to toggle the panel — has to reach the input untouched.
e.StopPropagation()
}
}),
}
return vdom.Div(vdom.Attr("class", "ui-form"),
vdom.WithRef(c.fieldRef),
vdom.Input(inputMods...),
)
}
// iconButton / clearButton sit inside the field, i.e. inside the Floating trigger.
// Both stop the click from reaching it, so the trigger's toggle does not undo what
// they just did (the TSX stopped propagation here too).
func (c *datePickerCore) iconButton(onClick func()) *vdom.VNode {
return vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", datePickerIconBtn),
vdom.Attr("aria-label", "Open calendar"),
vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
e.StopPropagation()
onClick()
}),
Icon("calendar", 16, "block leading-none"),
)
}
func (c *datePickerCore) clearButton(onClick func()) *vdom.VNode {
return vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", datePickerClearBtn),
vdom.Attr("aria-label", "Clear date"),
vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
e.StopPropagation()
onClick()
}),
Icon("xmark", 14, "block leading-none"),
)
}
// dpSet writes a signal only when the value actually changes. Every Signal.Set
// schedules a full re-render, so the no-op writes this component would otherwise do
// on focus, blur and every close are worth skipping.
func dpSet[T comparable](s *vdom.Signal[T], v T) {
if s.Get() != v {
s.Set(v)
}
}
// -- DatePicker ----------------------------------------------------------------
// DatePicker is a text field with a portaled calendar dropdown.
//
// The dropdown is a Floating panel: portaled to <body> (so no scrolling or
// overflow-hidden ancestor can clip it — the first Go port used a static
// `absolute top-full`, which any such ancestor cut off), measured before it is
// revealed, repositioned on scroll/resize/content-resize, and closed by an outside
// mousedown or Escape.
//
// Three deliberate departures from the TSX:
//
// 1. FLIP IS ON. The TSX pinned the dropdown below the field and let it run off the
// bottom of the page. Flipping to `top-start` when there is no room below keeps
// the calendar reachable for a field near the bottom of a long form, and
// ConstrainToViewport caps the panel to the room that is actually there so it
// scrolls instead of overflowing. This is an improvement, not a port bug.
// 2. NO PER-FRAME TRACKING LOOP. The TSX re-measured the field on every animation
// frame for as long as the dropdown was open (DatePicker.tsx:213-224) — the most
// expensive thing in the kit. Floating tracks with scroll(capture) + resize +
// ResizeObserver instead: it fires only when something that feeds the math has
// actually moved, and (unlike the rAF loop) it also catches the panel's own
// content changing size.
// 3. The panel sits on the kit's floating layer (z-[110], set by Floating) rather
// than the TSX's ad-hoc z-index 200, so it stacks consistently with menus,
// popovers and tooltips.
type DatePicker struct {
datePickerCore
float *Floating
box *vdom.Ref // the bordered dropdown box inside the Floating panel
unsub wasmruntime.Unsub // window resize -> re-measure the field width
}
// NewDatePicker builds a DatePicker. Call it ONCE, outside your render function
// (see the package doc on floating.go): it creates the Floating controller, the
// signals and the refs, all of which have to survive across renders.
func NewDatePicker(p DatePickerProps) *DatePicker {
d := &DatePicker{
datePickerCore: newDatePickerCore(p, "Select date"),
box: vdom.NewRef(),
}
d.float = NewFloating(FloatingOptions{
Placement: PlacementBottomStart, // DatePicker.tsx: top = field.bottom + 4, left = field.left
Offset: 4,
ConstrainToViewport: true, // scroll, don't overflow, when the calendar is taller than the room below
OnOpenChange: d.onOpenChange,
})
return d
}
// Show / Hide / Toggle drive the dropdown from outside.
func (d *DatePicker) Show() { d.float.Show() }
func (d *DatePicker) Hide() { d.float.Hide() }
func (d *DatePicker) Toggle() { d.float.Toggle() }
func (d *DatePicker) IsOpen() bool { return d.float.IsOpen() }
// Dispose closes the dropdown and drops every listener. Call it if the page owning
// this picker goes away while the dropdown might still be open.
func (d *DatePicker) Dispose() {
d.unsubResize()
d.float.Dispose()
}
func (d *DatePicker) onOpenChange(open bool) {
if open {
// Queued BEFORE Floating queues its own measure pass — Show() calls
// OnOpenChange first, and AfterRender callbacks run in the order they were
// added — so the panel is already at its final width when Floating measures it.
wasmruntime.AfterRender(d.mounted)
return
}
// Closing. An outside mousedown gets here through Floating's own listener, and
// the TSX committed the in-flight draft on exactly that path; so does this. (The
// blur that follows the click finds editing == false and does nothing, instead of
// committing a second time the way the TSX did.)
d.commitDraft()
d.unsubResize()
dpSet(d.view, time.Time{}) // next open starts on the selection's month, as in the TSX
}
// mounted runs once the panel is in the DOM.
func (d *DatePicker) mounted() {
d.applyWidth()
// Floating already repositions on resize; this re-derives the WIDTH, which is a
// function of the field's own width and so can change when the window does.
d.unsub = wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) {
d.applyWidth()
d.float.Reposition()
})
}
// applyWidth writes the dropdown's width constraints (DatePicker.tsx:207-211):
// min-width is the field's own width but never under 16rem, width is max-content,
// max-width is 400px. Written with SetStyle rather than through a signal — a signal
// write re-renders the whole tree.
//
// The TSX also clamped max-width to `innerWidth - left - 8`, its only defence
// against a field near the right edge pushing the dropdown off screen. Floating's
// shift does that properly (it slides the panel back inside the viewport instead of
// squeezing it), so what is kept here is the 400px cap and the 8px gutter.
func (d *DatePicker) applyWidth() {
if !d.box.Mounted() {
return
}
minW := datePickerMinWidth
if f := wasmruntime.Measure(d.fieldRef); f.Width > minW {
minW = f.Width
}
maxW := datePickerMaxWidth
if vp := wasmruntime.Viewport(); vp.Width > 0 {
if room := vp.Width - 2*datePickerGutter; room < maxW {
maxW = room
}
}
if maxW < minW {
minW = maxW // a viewport narrower than the field: the cap wins
}
wasmruntime.SetStyle(d.box, "min-width", px(minW))
wasmruntime.SetStyle(d.box, "max-width", px(maxW))
}
func (d *DatePicker) unsubResize() {
if d.unsub != nil {
d.unsub()
d.unsub = nil
}
}
// Render builds the current frame. Safe to call on every render — it creates no
// state.
func (d *DatePicker) Render() *vdom.VNode {
hasValue := d.value.Get() != ""
extra := "w-full pr-9"
if d.props.Clearable && hasValue {
extra = "w-full pr-14"
}
children := []*vdom.VNode{d.input(extra, d.float.Show, d.float.Hide)}
if d.props.Clearable && hasValue {
children = append(children, d.clearButton(func() {
d.set("", true)
d.float.Hide()
}))
}
children = append(children, d.iconButton(d.float.Toggle))
// The field IS the Floating trigger: it carries the ref Floating measures and
// the ref its outside-click test uses, so a click anywhere inside the field
// (input, buttons) is never "outside".
field := d.float.Trigger(FloatingTriggerProps{
Tag: "div",
Class: datePickerField,
AriaHasPopup: "dialog",
}, children...)
return vdom.Div(vdom.Attr("class", datePickerWrap), field, d.panel())
}
// panel renders the portaled dropdown. Closed, Floating.Panel returns an empty
// portal, which keeps this child's slot in the parent's list (the reconciler diffs
// children by index).
//
// The bordered box is a child of the panel rather than the panel itself, because
// Floating owns the panel's ref and its style (top/left/max-height): this box gets
// our own ref, so applyWidth can size it.
func (d *DatePicker) panel() *vdom.VNode {
if !d.float.IsOpen() {
return d.float.Panel(FloatingPanelProps{Role: "dialog"})
}
cal := Calendar(CalendarProps{
Selected: d.value.Get(),
ViewMonth: d.view.Get(),
Variant: CalendarVariantPicker,
OnSelect: d.selectDay,
OnNavigate: d.navigate,
})
box := vdom.Div(vdom.WithRef(d.box),
vdom.Attr("class", datePickerDropdown),
vdom.Attr("style", datePickerBoxStyle),
cal,
)
return d.float.Panel(FloatingPanelProps{Role: "dialog"}, box)
}
func (d *DatePicker) selectDay(key string) {
d.set(key, true)
d.float.Hide()
}
// -- DateOfBirthPicker ---------------------------------------------------------
// DateOfBirthPicker is a DatePicker whose calendar swaps the month label for
// month/year <select>s, for picking far-past dates.
//
// NOTE: its dropdown is INLINE — not portaled, not measured. That is faithful to the
// TSX (DatePicker.tsx:535-539 renders a plain <div>, with no Portal and no
// getBoundingClientRect), so it is left alone rather than "upgraded" to a Floating
// panel. What it does get back is the outside-click close, hand-rolled here (a
// document mousedown against the wrapper) because there is no Floating to inherit it
// from; that mirrors the TSX's own document listener, on mousedown rather than click
// so it fires before focus moves.
type DateOfBirthPicker struct {
datePickerCore
open *vdom.Signal[bool]
wrap *vdom.Ref
unsub wasmruntime.Unsub // document mousedown, installed only while open
}
// NewDateOfBirthPicker builds a DateOfBirthPicker. Like NewDatePicker, call it once,
// outside your render function. Props.Clearable is ignored (the TSX has no clear
// button on this one).
func NewDateOfBirthPicker(p DatePickerProps) *DateOfBirthPicker {
return &DateOfBirthPicker{
datePickerCore: newDatePickerCore(p, "Select date of birth"),
open: vdom.NewSignal(false),
wrap: vdom.NewRef(),
}
}
func (d *DateOfBirthPicker) IsOpen() bool { return d.open.Get() }
func (d *DateOfBirthPicker) Show() {
if d.open.Get() {
return
}
d.open.Set(true)
d.unsub = wasmruntime.OnDocument(vdom.EVENT_MOUSEDOWN, false, func(e vdom.Event) {
if wasmruntime.Contains(d.wrap, e.Target()) {
return
}
d.commitDraft() // the TSX committed the draft on an outside click too
d.Hide()
})
}
func (d *DateOfBirthPicker) Hide() {
if !d.open.Get() {
return
}
d.open.Set(false)
if d.unsub != nil {
d.unsub()
d.unsub = nil
}
dpSet(d.view, time.Time{})
}
func (d *DateOfBirthPicker) Toggle() {
if d.open.Get() {
d.Hide()
} else {
d.Show()
}
}
// Dispose closes the dropdown and drops its listener.
func (d *DateOfBirthPicker) Dispose() { d.Hide() }
func (d *DateOfBirthPicker) Render() *vdom.VNode {
field := vdom.Div(vdom.Attr("class", datePickerField),
// No Floating trigger here, so this click opens rather than toggles — the
// TSX's openCalendar. The input stops its own click anyway; this catches the
// rest of the field.
vdom.On(vdom.EVENT_CLICK, d.Show),
d.input("w-full pr-9", d.Show, d.Hide),
d.iconButton(d.Toggle),
)
mods := []vdom.Mod{
vdom.Attr("class", datePickerWrap),
vdom.WithRef(d.wrap), // the outside-click test measures against this
field,
}
if d.open.Get() {
mods = append(mods, vdom.Div(vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
datePickerDOBCalendar(d.value.Get(), d.view.Get(), d.selectDay, d.navigate),
))
}
return vdom.Div(mods...)
}
func (d *DateOfBirthPicker) selectDay(key string) {
d.set(key, true)
d.Hide()
}
// 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.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.Option(vdom.Attr("value", ys), vdom.Text(ys)))
}
header := vdom.Div(vdom.Attr("class", calHeaderPicker),
vdom.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.Select(monthMods...),
vdom.Select(yearMods...),
vdom.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.Div(vdom.Attr("class", calPickerRoot),
header,
calWeekdaysRow(calWeekdaysPicker, calWeekdayPicker),
calPickerDaysGrid(view, sel, hasSel, now, onSelect, nil),
)
}