// 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/vdom"
	"kjol/wasmruntime"
)

// -- 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),
	)
}
