682 lines
25 KiB
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),
|
|
)
|
|
}
|