ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

Range

Submits booking[stay][start] and booking[stay][end].

Inline calendar

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.