Date Picker
Localized date field (input + popover calendar) and a standalone inline calendar. Single + range selection, Rails-friendly hidden inputs.
Installation
bin/rails g wabi:add date_picker
bin/importmap pin @zag-js/date-picker @internationalized/date @zag-js/vanillaPin @zag-js/date-picker, @internationalized/date and @zag-js/vanilla at 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only fetches the main entry and leaves submodules unresolved.
Example
render Components::UI::DatePicker.new(name: "event[date]", placeholder: "Pick a date")
Range
Submits booking[stay][start] and booking[stay][end].
render Components::UI::DatePicker.new(name: "booking[stay]", selection_mode: :range, placeholder: "Check-in → Check-out")
Inline calendar
render Components::UI::Calendar.new(name: "event[date]", default_value: "2026-06-15")
Source
app/components/ui/calendar.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class Calendar < Wabi::Base
include DatePickerView
def initialize(name: nil, selection_mode: :single, default_value: nil,
min: nil, max: nil, locale: "en-US", num_of_months: nil,
disabled: false, read_only: false, **attrs)
@name = name
@selection_mode = selection_mode
@default_value = default_value
@min = min
@max = max
@locale = locale
@num_of_months = num_of_months
@disabled = disabled
@read_only = read_only
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
user_data = @attrs.delete(:data) || {}
div(**@attrs,
data: user_data.merge(date_picker_root_data),
class: merge_class("inline-block rounded-md border border-border bg-background p-3", user_class)) do
render_calendar_view
render_hidden_inputs
end
end
end
end
end
app/components/ui/date_picker.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DatePicker < Wabi::Base
include DatePickerView
CALENDAR_ICON = %(<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18M8 2v4m8-4v4"/></svg>)
def initialize(name: nil, selection_mode: :single, default_value: nil,
min: nil, max: nil, locale: "en-US", num_of_months: nil,
placeholder: nil, aria_label: "Choose date", disabled: false, read_only: false,
portal: true, **attrs)
@name = name
@selection_mode = selection_mode
@default_value = default_value
@min = min
@max = max
@locale = locale
@num_of_months = num_of_months
@placeholder = placeholder
@aria_label = aria_label
@disabled = disabled
@read_only = read_only
@portal = portal
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
user_data = @attrs.delete(:data) || {}
# user_data.merge(root_data): component/controller keys always win on collision
# so callers can add data-* attrs but cannot clobber the controller wiring.
root_data = date_picker_root_data.merge("wabi--date-picker-portal-value": @portal.to_s)
div(**@attrs, data: user_data.merge(root_data), class: merge_class("inline-block", user_class)) do
div(data: { "wabi--date-picker-target": "control" },
class: "flex items-center rounded-md border border-input bg-background ring-offset-background " \
"focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 " \
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50") do
input(type: "text", placeholder: @placeholder, "aria-label": @aria_label,
data: { "wabi--date-picker-target": "input" },
class: "flex-1 h-10 min-w-0 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground")
# aria-label is the no-JS fallback; the controller localizes it via getTriggerProps.
button(type: "button", "aria-label": "Open calendar",
data: { "wabi--date-picker-target": "trigger" },
class: "h-10 w-10 shrink-0 inline-flex items-center justify-center text-muted-foreground hover:text-foreground") do
raw(safe(CALENDAR_ICON))
end
end
div(data: { "wabi--date-picker-target": "positioner" }, class: "z-50 pointer-events-none") do
# Popover content: starts closed + inert + hidden as a no-JS safety net
# (the controller clears `hidden` on connect; `data-state=closed` keeps it
# visually collapsed via opacity until opened). PopoverContent relies on
# opacity alone; here we also hide for the no-JS case.
div(data: { "wabi--date-picker-target": "content" }, "data-state": "closed", inert: true, hidden: true,
class: "z-50 rounded-md border border-border bg-popover p-3 text-popover-foreground shadow-md outline-none " \
"pointer-events-auto transition-opacity duration-200 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0 data-[state=closed]:pointer-events-none") do
render_calendar_view
end
end
render_hidden_inputs
end
end
end
end
end
app/components/ui/date_picker_view.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Shared markup for Calendar + DatePicker so the calendar view and the
# hidden form inputs never drift. Both components `include` this; the
# methods call Phlex DSL methods on the including instance.
module DatePickerView
CHEVRON_LEFT = %(<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>)
CHEVRON_RIGHT = %(<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>)
private
def date_picker_root_data
{
controller: "wabi--date-picker",
"wabi--date-picker-name-value": @name,
"wabi--date-picker-selection-mode-value": @selection_mode.to_s,
"wabi--date-picker-locale-value": @locale,
"wabi--date-picker-default-value-value": Array(@default_value).compact.map { |d| iso(d) }.join(","),
"wabi--date-picker-min-value": @min ? iso(@min) : "",
"wabi--date-picker-max-value": @max ? iso(@max) : "",
"wabi--date-picker-num-of-months-value": num_of_months.to_s,
"wabi--date-picker-disabled-value": @disabled.to_s,
"wabi--date-picker-readonly-value": @read_only.to_s,
}
end
def num_of_months
@num_of_months || 1
end
def iso(value)
value.is_a?(String) ? value : value.strftime("%Y-%m-%d")
end
def render_calendar_view
div(data: { "wabi--date-picker-target": "viewControl" }, class: "flex items-center justify-between px-1 pb-2") do
button(type: "button", "aria-label": "Previous month",
data: { "wabi--date-picker-target": "prev" }, class: nav_button_class) { raw(safe(CHEVRON_LEFT)) }
# Controller fills the month/year label text at connect.
button(type: "button", data: { "wabi--date-picker-target": "viewTrigger" },
class: "text-sm font-medium px-2 py-1 rounded-md hover:bg-accent")
button(type: "button", "aria-label": "Next month",
data: { "wabi--date-picker-target": "next" }, class: nav_button_class) { raw(safe(CHEVRON_RIGHT)) }
end
table(class: "w-full border-collapse") do
thead do
tr(data: { "wabi--date-picker-target": "gridHead" })
end
tbody(data: { "wabi--date-picker-target": "grid" })
end
end
def render_hidden_inputs
if @selection_mode == :range
input(type: "hidden", name: (@name ? "#{@name}[start]" : nil), data: { "wabi--date-picker-target": "hiddenStart" })
input(type: "hidden", name: (@name ? "#{@name}[end]" : nil), data: { "wabi--date-picker-target": "hiddenEnd" })
else
input(type: "hidden", name: @name, data: { "wabi--date-picker-target": "hiddenStart" })
end
end
def nav_button_class
"h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground " \
"transition-colors motion-reduce:transition-none hover:bg-accent hover:text-accent-foreground"
end
end
end
end
app/javascript/controllers/wabi/date_picker_controller.js
import { Controller } from "@hotwired/stimulus"
import * as datePicker from "@zag-js/date-picker"
import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla"
import { parseDate } from "@internationalized/date"
import { capturePortalRefs, attachToBody, restoreFromBody } from "controllers/wabi/_shared/overlay_portal"
const DAY_CELL_CLASS =
"w-9 h-9 inline-flex items-center justify-center rounded-md text-sm transition-colors motion-reduce:transition-none " +
"hover:bg-accent hover:text-accent-foreground " +
"data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:bg-primary " +
"data-[today]:font-bold data-[outside-range]:text-muted-foreground/40 " +
"data-[disabled]:opacity-40 data-[disabled]:pointer-events-none"
// Range "tunnel": the connecting band lives on the CELL (via :has on the trigger's
// data-in-range / data-*-hover-range), so adjacent cells form a continuous strip and
// the primary-colored endpoints (on the button) sit on top without a class collision.
const RANGE_CELL_CLASS =
"p-0 text-center " +
"[&:has([data-in-range])]:bg-accent [&:has([data-in-hover-range])]:bg-accent/50 " +
"[&:has([data-range-start])]:rounded-l-md [&:has([data-hover-range-start])]:rounded-l-md " +
"[&:has([data-range-end])]:rounded-r-md [&:has([data-hover-range-end])]:rounded-r-md"
export default class extends Controller {
static targets = [
"control", "input", "trigger", "positioner", "content",
"viewControl", "prev", "next", "viewTrigger",
"gridHead", "grid", "hiddenStart", "hiddenEnd",
]
static values = {
name: String,
selectionMode: { type: String, default: "single" },
locale: { type: String, default: "en-US" },
defaultValue: String,
min: String,
max: String,
numOfMonths: { type: Number, default: 1 },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
portal: { type: Boolean, default: true },
}
connect() {
capturePortalRefs(this) // sets this.contentEl / this.positionerEl (null for inline Calendar)
this.controlEl = this.hasControlTarget ? this.controlTarget : null
this.inputEl = this.hasInputTarget ? this.inputTarget : null
this.triggerEl = this.hasTriggerTarget ? this.triggerTarget : null
this.hiddenStartEl = this.hasHiddenStartTarget ? this.hiddenStartTarget : null
this.hiddenEndEl = this.hasHiddenEndTarget ? this.hiddenEndTarget : null
const scope = this.contentEl || this.element
this.viewControlEl = scope.querySelector('[data-wabi--date-picker-target="viewControl"]')
this.prevEl = scope.querySelector('[data-wabi--date-picker-target="prev"]')
this.nextEl = scope.querySelector('[data-wabi--date-picker-target="next"]')
this.viewTriggerEl = scope.querySelector('[data-wabi--date-picker-target="viewTrigger"]')
this.gridHeadEl = scope.querySelector('[data-wabi--date-picker-target="gridHead"]')
this.gridEl = scope.querySelector('[data-wabi--date-picker-target="grid"]')
this.portaled = this.portalValue && !!this.positionerEl
if (this.portaled) attachToBody(this)
const defaults = this.defaultValueValue
? this.defaultValueValue.split(",").filter(Boolean).map((s) => parseDate(s))
: undefined
// Inline Calendar (no positioner at all) renders an always-visible grid.
// Zag's `inline` flag forces the machine into the `open` state so day cells
// are interactive (CELL.CLICK is only handled while open). A field DatePicker
// has a positioner (popover) and opens via its trigger — this must be false
// even when portal:false (positioner kept in-tree rather than moved to body).
this.inline = !this.positionerEl // inline calendar (no positioner) starts open; a field with a positioner opens via its trigger, even when portal:false
this.machine = new VanillaMachine(datePicker.machine, {
id: this.element.id || crypto.randomUUID(),
locale: this.localeValue,
selectionMode: this.selectionModeValue,
numOfMonths: this.numOfMonthsValue,
inline: this.inline,
defaultValue: defaults,
min: this.minValue ? parseDate(this.minValue) : undefined,
max: this.maxValue ? parseDate(this.maxValue) : undefined,
disabled: this.disabledValue,
readOnly: this.readonlyValue,
onValueChange: (details) => {
this.syncHidden(details.value)
this.dispatch("change", { detail: { valueAsString: details.valueAsString } })
},
onOpenChange: ({ open }) => {
if (this.contentEl) {
if (open) this.contentEl.removeAttribute("inert")
else this.contentEl.setAttribute("inert", "")
}
this.dispatch("toggle", { detail: { open } })
},
})
this.unsubscribe = this.machine.subscribe(() => this.render())
this.machine.start()
this.render()
this.syncHidden(this.api.value)
}
disconnect() {
cancelAnimationFrame(this.rangeInputRaf)
this.unsubscribe?.()
this.machine?.stop()
if (this.portaled) restoreFromBody(this)
}
get api() { return datePicker.connect(this.machine.service, normalizeProps) }
render() {
const api = this.api
spreadProps(this.element, api.getRootProps())
if (this.controlEl) spreadProps(this.controlEl, api.getControlProps())
if (this.inputEl) {
spreadProps(this.inputEl, api.getInputProps())
// getInputProps() reflects only index 0 (the start date), so a single
// collapsed field would hide the end of a range. Show the whole selection
// joined as "start – end". Zag ALSO re-syncs this input to the start inside
// a requestAnimationFrame that lands AFTER this synchronous render — so on
// the first range pick the field would flash to start-only until the next
// render. Re-assert the full range on the next frame too: this rAF is
// registered after Zag's (render runs after the machine reaction), so it wins.
if (this.selectionModeValue === "range") {
const rangeText = (api.valueAsString || []).filter(Boolean).join(" – ")
this.inputEl.value = rangeText
cancelAnimationFrame(this.rangeInputRaf)
this.rangeInputRaf = requestAnimationFrame(() => {
if (this.inputEl) this.inputEl.value = rangeText
})
}
}
if (this.triggerEl) spreadProps(this.triggerEl, api.getTriggerProps())
if (this.positionerEl) spreadProps(this.positionerEl, api.getPositionerProps())
if (this.contentEl) { spreadProps(this.contentEl, api.getContentProps()); this.contentEl.hidden = false }
if (this.viewControlEl) spreadProps(this.viewControlEl, api.getViewControlProps({ view: "day" }))
if (this.prevEl) spreadProps(this.prevEl, api.getPrevTriggerProps())
if (this.nextEl) spreadProps(this.nextEl, api.getNextTriggerProps())
if (this.viewTriggerEl) {
spreadProps(this.viewTriggerEl, api.getViewTriggerProps({ view: "day" }))
this.viewTriggerEl.textContent = api.visibleRangeText.start
}
this.renderGrid(api)
}
renderGrid(api) {
// Renders the first visible month from api.weeks. Multi-month side-by-side
// (num_of_months > 1) is deferred — range selection still works across months
// via prev/next navigation.
if (this.gridHeadEl) {
this.gridHeadEl.innerHTML = ""
api.weekDays.forEach((wd) => {
const th = document.createElement("th")
th.scope = "col"
th.setAttribute("aria-label", wd.long)
th.className = "w-9 h-9 text-xs font-normal text-muted-foreground"
th.textContent = wd.narrow
this.gridHeadEl.appendChild(th)
})
}
if (!this.gridEl) return
this.gridEl.innerHTML = ""
api.weeks.forEach((week) => {
const tr = document.createElement("tr")
week.forEach((day) => {
const td = document.createElement("td")
spreadProps(td, api.getDayTableCellProps({ value: day }))
td.className = RANGE_CELL_CLASS
const btn = document.createElement("button")
btn.type = "button"
spreadProps(btn, api.getDayTableCellTriggerProps({ value: day }))
btn.className = DAY_CELL_CLASS
btn.textContent = String(day.day)
td.appendChild(btn)
tr.appendChild(td)
})
this.gridEl.appendChild(tr)
})
}
syncHidden(value) {
const iso = (d) => (d ? d.toString() : "")
if (this.hiddenStartEl) this.hiddenStartEl.value = iso(value && value[0])
if (this.hiddenEndEl) this.hiddenEndEl.value = iso(value && value[1])
}
}
Accessibility
- The day grid uses Zag's grid roles; arrow keys move between days, Enter selects.
- Prev/next buttons and day cells carry accessible labels derived from the locale.
- The field input has an accessible name (aria-label, default "Choose date", overridable); the popover is keyboard-dismissable (Escape).
- Selection is mirrored into hidden inputs as ISO YYYY-MM-DD, so forms submit a value.
- Single-month view; range selection spans months via prev/next navigation.