Date Picker
A date field with an input and a calendar in a popover.
Usage
Input
Type a date or open the calendar with the button (or ArrowDown in the input). Picking a day writes it into the input, in date_format:, and a date typed in that format moves the calendar to it.
div(class: "w-48") do
DatePicker(id: "date", name: "date", label: "Subscription Date", placeholder: "yyyy-mm-dd")
end
Selected date
selected_date: fills the input and selects the day in the calendar.
div(class: "w-48") do
DatePicker(id: "date-selected", name: "date_selected", label: "Due date", selected_date: Date.new(2026, 3, 12))
end
Date format
date_format: sets how the picked date is written (yyyy, MM, dd, MMMM, EEEE, do, PPPP).
div(class: "w-72") do
DatePicker(id: "date-format", label: "Event", date_format: "PPPP")
end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component DatePicker
Manual installation
1
Add RubyUI::DatePicker to app/components/ruby_ui/date_picker/date_picker.rb
# frozen_string_literal: true
require "date"
require "securerandom"
module RubyUI
# A date field with a calendar popover, after shadcn's date-picker-input
# example (Base UI + vega): a field with a label, an input group holding the
# text input and an icon button that opens a Popover with a Calendar.
#
# The ruby-ui--date-picker controller wires the parts: ArrowDown in the input
# or Enter/Space on the button opens the calendar with focus on the selected
# day, picking a day writes it into the input and closes the popover, focus
# returns to the button, and a date typed in the input moves the calendar.
#
# input_attrs go to the <input>, trigger_attrs to the icon button,
# content_attrs to PopoverContent and calendar_attrs to Calendar.
class DatePicker < Base
INPUT_GROUP = [
"group/input-group relative flex w-full min-w-0 items-center outline-none",
"h-9 rounded-md border border-input shadow-xs transition-[color,box-shadow] dark:bg-input/30",
"has-[[data-slot=input-group-control]:focus-visible]:border-ring",
"has-[[data-slot=input-group-control]:focus-visible]:ring-3",
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3",
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20",
"dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
"has-[>[data-align=inline-end]]:[&>input]:pr-1.5"
].freeze
# vega's inline-end addon; -mr-1 is its has-[>button] rule (here the
# button sits inside the Popover wrapper).
ADDON = "order-last flex h-auto items-center justify-center gap-2 py-1.5 pr-2 -mr-1 " \
"text-sm font-medium text-muted-foreground select-none"
def initialize(
id: nil,
name: nil,
label: "Select a date",
value: nil,
placeholder: "Select a date",
selected_date: value,
date_format: "yyyy-MM-dd",
popover_options: {},
input_attrs: {},
calendar_attrs: {},
trigger_attrs: {},
content_attrs: {},
**attrs
)
@id = id || "date-picker-#{SecureRandom.hex(4)}"
@name = name
@label = label
@date_format = date_format
# value: is used as given; a selected_date is written in date_format,
# as the calendar writes a picked day.
@value = value || format_date(selected_date)
@placeholder = placeholder
@selected_date = selected_date
@popover_options = {trigger: "click", placement: "bottom-end"}.merge(popover_options)
@input_attrs = input_attrs
@calendar_attrs = calendar_attrs
@trigger_attrs = trigger_attrs
@content_attrs = content_attrs
super(**attrs)
end
def view_template
div(**attrs) do
RubyUI.FormFieldLabel(for: @id) { @label } if @label
div(class: INPUT_GROUP, role: "group", data: {slot: "input-group"}) do
RubyUI.Input(**input_attrs)
div(class: ADDON, role: "group", data: {slot: "input-group-addon", align: "inline-end"}) do
RubyUI.Popover(id: popover_id, options: @popover_options, class: "flex") do
RubyUI.PopoverTrigger(class: "flex") do
RubyUI.Button(**trigger_attrs) { calendar_icon }
end
RubyUI.PopoverContent(**content_attrs) do
RubyUI.Calendar(input_id: "##{@id}", selected_date: @selected_date, date_format: @date_format, **calendar_attrs)
end
end
end
end
end
end
private
# The tokens the calendar controller's formatDate writes (longest first).
# Like the controller, the time is always midnight: it keeps only the day.
def format_date(value)
return nil if value.nil? || value.to_s.empty?
date = value.respond_to?(:year) ? value.to_date : parse_iso_date(value.to_s)
return value.to_s unless date
ordinal = "#{date.day}#{day_suffix(date.day)}"
tokens = {
"yyyy" => date.year.to_s, "MMMM" => Date::MONTHNAMES[date.month], "EEEE" => Date::DAYNAMES[date.wday],
"PPPP" => "#{Date::DAYNAMES[date.wday]}, #{Date::MONTHNAMES[date.month]} #{ordinal}, #{date.year}",
"MM" => date.month.to_s.rjust(2, "0"), "dd" => date.day.to_s.rjust(2, "0"), "do" => ordinal,
"HH" => "00", "mm" => "00", "ss" => "00"
}
@date_format.gsub(/yyyy|MMMM|EEEE|PPPP|MM|dd|do|HH|mm|ss/) { |token| tokens[token] }
end
def parse_iso_date(text)
match = text.match(/\A(\d{4})-(\d{2})-(\d{2})/)
match && Date.new(match[1].to_i, match[2].to_i, match[3].to_i)
rescue Date::Error
nil
end
def day_suffix(day)
return "th" if day.between?(4, 20)
{1 => "st", 2 => "nd", 3 => "rd"}.fetch(day % 10, "th")
end
def popover_id = "#{@id}-popover"
# The outlet selector follows a caller's own id (calendar_attrs: {id:}).
def calendar_id = @calendar_attrs[:id!] || @calendar_attrs[:id] || "#{@id}-calendar"
# One id, not mixed: mix would join a caller's id with the default.
def calendar_attrs = @calendar_attrs.except(:id, :id!).merge(id: calendar_id)
def content_id = "#{@id}-content"
# Same grid as FormField (shadcn's Field), without its validation
# controller: a DatePicker inside a FormField must leave the input to it.
def default_attrs
{
role: "group",
class: "group/field flex w-full flex-col gap-3 *:w-full [&>.sr-only]:w-auto data-[invalid=true]:text-destructive",
data: {
slot: "field",
orientation: "vertical",
controller: "ruby-ui--date-picker",
ruby_ui__date_picker_ruby_ui__popover_outlet: "##{popover_id}",
ruby_ui__date_picker_ruby_ui__calendar_outlet: "##{calendar_id}",
action: "ruby-ui--popover:show->ruby-ui--date-picker#opened ruby-ui--popover:hide->ruby-ui--date-picker#closed"
}
}
end
def input_attrs
mix({
type: "text",
placeholder: @placeholder,
id: @id,
name: @name,
value: @value,
autocomplete: "off",
class: [
"block h-full min-w-0 flex-1 rounded-none border-0 border-input bg-transparent px-2.5 shadow-none ring-0",
"focus-visible:ring-0 focus-visible:shadow-none aria-invalid:ring-0 dark:bg-transparent"
],
data: {
slot: "input-group-control",
controller: "ruby-ui--calendar-input",
ruby_ui__date_picker_target: "input",
action: "keydown->ruby-ui--date-picker#inputKeydown input->ruby-ui--date-picker#syncCalendar"
}
}.compact, @input_attrs)
end
# mix turns slot! into a plain slot when the caller also passes data:, and
# Button's own mix would then join it with its "button" slot. Re-mark it as
# an override so the button keeps a single data-slot.
def trigger_attrs
attrs = mix({
variant: :ghost,
size: :icon_xs,
class: "size-6 gap-2 rounded-[calc(var(--radius)-5px)] p-0 shadow-none",
aria: {label: "Select date", haspopup: "dialog", expanded: "false"},
data: {slot!: "input-group-button", ruby_ui__date_picker_target: "trigger"}
}, @trigger_attrs)
data = attrs[:data]
data[:slot!] = data.delete(:slot!) || data.delete(:slot)
attrs
end
def content_attrs
mix({
id: content_id,
# PopoverContent already renders role="dialog" and data-slot="popover-content".
aria: {label: "Choose date"},
class: "w-auto overflow-hidden p-0 font-normal",
data: {ruby_ui__date_picker_target: "content", action: "click->ruby-ui--date-picker#pick"}
}, @content_attrs)
end
def calendar_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true",
class: "pointer-events-none size-4"
) do |s|
s.path(d: "M8 2v4")
s.path(d: "M16 2v4")
s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2")
s.path(d: "M3 10h18")
end
end
end
end
2
Add RubyUI::DatePickerDocs to app/components/ruby_ui/date_picker/date_picker_docs.rb
# frozen_string_literal: true
class Views::Docs::DatePicker < Views::Base
def view_template
component = "DatePicker"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Date Picker", description: "A date field with an input and a calendar in a popover.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Input", description: "Type a date or open the calendar with the button (or ArrowDown in the input). Picking a day writes it into the input, in date_format:, and a date typed in that format moves the calendar to it.", context: self) do
<<~RUBY
div(class: "w-48") do
DatePicker(id: "date", name: "date", label: "Subscription Date", placeholder: "yyyy-mm-dd")
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Selected date", description: "selected_date: fills the input and selects the day in the calendar.", context: self) do
<<~RUBY
div(class: "w-48") do
DatePicker(id: "date-selected", name: "date_selected", label: "Due date", selected_date: Date.new(2026, 3, 12))
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Date format", description: "date_format: sets how the picked date is written (yyyy, MM, dd, MMMM, EEEE, do, PPPP).", context: self) do
<<~RUBY
div(class: "w-72") do
DatePicker(id: "date-format", label: "Event", date_format: "PPPP")
end
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
3
Add date_picker_controller.js to app/javascript/controllers/ruby_ui/date_picker_controller.js
import { Controller } from "@hotwired/stimulus";
// The tokens the calendar's formatDate writes. PPPP is a shorthand for
// "EEEE, MMMM do, yyyy".
const TOKENS = /PPPP|yyyy|MMMM|EEEE|MM|dd|do|HH|mm|ss/g;
const TOKEN_PATTERNS = {
yyyy: "(\\d{4})",
MMMM: "([A-Za-z]+)",
EEEE: "([A-Za-z]+)",
MM: "(\\d{2})",
dd: "(\\d{2})",
do: "(\\d{1,2})(?:st|nd|rd|th)",
HH: "(\\d{2})",
mm: "(\\d{2})",
ss: "(\\d{2})",
};
const MONTHS = Array.from({ length: 12 }, (_, i) =>
new Date(2000, i, 1).toLocaleString("en-US", { month: "long" }).toLowerCase(),
);
// Wires DatePicker's input, icon button, Popover and Calendar together, as
// shadcn's date-picker-input example does:
// - ArrowDown in the input opens the calendar (Enter/Space on the button open
// it through the Popover's own click handler);
// - the button mirrors the popover state in aria-expanded / aria-controls;
// - on open, focus moves to the selected day (else the calendar's day in the
// Tab sequence), as APG's date picker does;
// - picking a day closes the popover; on close, focus goes back to the button
// unless it already moved somewhere else (an outside click on a field);
// - a date typed in the input, in the calendar's date_format, moves the calendar.
export default class extends Controller {
static targets = ["input", "trigger", "content"];
static outlets = ["ruby-ui--popover", "ruby-ui--calendar"];
// The input may come prefilled (value: without selected_date:).
rubyUiCalendarOutletConnected() {
this.syncCalendar();
}
inputKeydown(event) {
if (event.key !== "ArrowDown" || event.altKey || !this.hasRubyUiPopoverOutlet) return;
event.preventDefault();
if (this.rubyUiPopoverOutlet.openValue) {
this.focusSelectedDay();
} else {
this.rubyUiPopoverOutlet.showPopover();
}
}
// aria-controls only while open, as Base UI does: closed, the popup is hidden.
opened() {
if (this.hasTriggerTarget) {
this.triggerTarget.setAttribute("aria-expanded", "true");
if (this.hasContentTarget && this.contentTarget.id) {
this.triggerTarget.setAttribute("aria-controls", this.contentTarget.id);
}
}
this.focusSelectedDay();
}
closed() {
if (this.hasTriggerTarget) {
this.triggerTarget.setAttribute("aria-expanded", "false");
this.triggerTarget.removeAttribute("aria-controls");
}
const active = document.activeElement;
const focusLost = !active || active === document.body;
const focusInside = this.hasContentTarget && this.contentTarget.contains(active);
if ((focusLost || focusInside) && this.hasTriggerTarget) this.triggerTarget.focus();
}
// Delegated click on the popover content. The calendar re-renders its grid
// when a day is selected, so the clicked button may already be detached:
// closest() still works on it.
pick(event) {
const day = event.target.closest?.("button[data-day]");
if (!day || day.disabled || day.getAttribute("aria-disabled") === "true") return;
if (!this.hasRubyUiPopoverOutlet || !this.rubyUiPopoverOutlet.openValue) return;
this.rubyUiPopoverOutlet.hidePopover();
}
// Selects the typed date in the calendar when it reads back exactly in the
// calendar's format, so a half-typed value is left alone and writing the
// date back into the input never changes what the user typed.
syncCalendar() {
if (!this.hasRubyUiCalendarOutlet || !this.hasInputTarget) return;
const calendar = this.rubyUiCalendarOutlet;
const value = this.inputTarget.value.trim();
const date = this.parseFormatted(value, calendar.formatValue);
if (!date || calendar.formatDate(date) !== value || calendar.isDateDisabled(date)) return;
// A day focused earlier must not win the Tab stop over the typed date.
calendar.focusedDate = null;
const iso = calendar.toISODate(date);
if (iso !== calendar.selectedDateValue) {
calendar.selectedDateValue = iso; // also moves the calendar to its month
return;
}
// Same date as selected: the calendar may show another month (navigated
// while open), so bring it back.
const view = calendar.toISODate(new Date(date.getFullYear(), date.getMonth(), 2));
if (view === calendar.viewDateValue) {
calendar.updateCalendar();
} else {
calendar.viewDateValue = view;
}
}
// Reads value in the calendar's date_format (the tokens formatDate writes).
// Weekday names are matched loosely here and checked by the caller's
// formatDate round trip.
parseFormatted(value, format) {
if (!value || !format) return null;
const pattern = format.replace("PPPP", "EEEE, MMMM do, yyyy");
const order = [];
let source = "";
let last = 0;
for (const match of pattern.matchAll(TOKENS)) {
source += this.escape(pattern.slice(last, match.index)) + TOKEN_PATTERNS[match[0]];
order.push(match[0]);
last = match.index + match[0].length;
}
source += this.escape(pattern.slice(last));
const found = value.match(new RegExp(`^${source}$`, "i"));
if (!found) return null;
const parts = {};
order.forEach((token, i) => {
parts[token] = found[i + 1];
});
const year = Number(parts.yyyy);
const month = parts.MM ? Number(parts.MM) - 1 : MONTHS.indexOf((parts.MMMM || "").toLowerCase());
const day = Number(parts.dd || parts.do);
if (!year || month < 0 || month > 11 || !day) return null;
return new Date(year, month, day, Number(parts.HH || 0), Number(parts.mm || 0), Number(parts.ss || 0));
}
escape(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// The selected day, on its month, even if another day was focused or another
// month shown before the popover closed. With no selection, the calendar's
// own Tab stop (today or the first enabled day).
focusSelectedDay() {
if (this.hasRubyUiCalendarOutlet) {
const calendar = this.rubyUiCalendarOutlet;
const selected = calendar.selectedDate();
if (selected && !calendar.isDateDisabled(selected)) {
calendar.focusedDate = selected;
calendar.viewDateValue = calendar.toISODate(new Date(selected.getFullYear(), selected.getMonth(), 2));
calendar.updateCalendar();
}
}
if (!this.hasContentTarget) return;
this.contentTarget.querySelector('button[data-day][tabindex="0"]')?.focus({ preventScroll: true });
}
}
4
Update the Stimulus controllers manifest file
rake stimulus:manifest:update5
Install required components
Component DatePicker relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
DatePicker | Phlex | |
DatePickerDocs | Phlex | |
DatePickerController | Stimulus JS |