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