Date picker Components

Advanced date picker components with different options and features.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project:

import { Controller } from "@hotwired/stimulus";
import { computePosition, offset, flip, shift, arrow, autoUpdate } from "@floating-ui/dom";
import AirDatepicker from "air-datepicker";
import localeEn from "air-datepicker/locale/en";

// AirDatepicker CSS is now imported directly.
// Ensure air-datepicker is added to your package.json and your build system handles these imports.

export default class extends Controller {
  static targets = ["input", "inlineCalendar"];
  static values = {
    placement: { type: String, default: "bottom-start" }, // Placement of the datepicker
    range: { type: Boolean, default: false }, // Whether to allow selecting a range of dates
    disabledDates: { type: Array, default: [] }, // Expects array of 'YYYY-MM-DD' strings
    timepicker: { type: Boolean, default: false },
    timeOnly: { type: Boolean, default: false }, // New value for time-only selection
    weekPicker: { type: Boolean, default: false }, // New value for week selection
    timeFormat: { type: String, default: "" }, // Default empty, logic will apply 'hh:mm AA' if timepicker is true and this is empty
    minHours: Number, // Undefined if not set, AirDatepicker uses its default
    maxHours: Number, // Undefined if not set
    minutesStep: Number, // Undefined if not set
    showTodayButton: { type: Boolean, default: false },
    showClearButton: { type: Boolean, default: false },
    showThisMonthButton: { type: Boolean, default: false },
    showThisYearButton: { type: Boolean, default: false },
    dateFormat: { type: String, default: "" }, // e.g., 'MM/dd/yyyy', AirDatepicker default if empty
    startView: { type: String, default: "days" }, // 'days', 'months', 'years'
    minView: { type: String, default: "days" }, // 'days', 'months', 'years'
    initialDate: { type: String, default: "" }, // 'YYYY-MM-DD' or JSON array of 'YYYY-MM-DD' for range
    minDate: { type: String, default: "" }, // 'YYYY-MM-DD'
    maxDate: { type: String, default: "" }, // 'YYYY-MM-DD'
    inline: { type: Boolean, default: false }, // Makes the calendar permanently visible
  };

  connect() {
    // Check if this element also has dropdown-popover controller
    // If so, don't initialize the datepicker (it's just used for preset methods)
    const hasDropdownController =
      this.element.hasAttribute("data-controller") &&
      this.element.getAttribute("data-controller").includes("dropdown-popover");

    if (!hasDropdownController) {
      this.initializeDatepicker();
      this.inputTarget.addEventListener("keydown", this.handleKeydown.bind(this));
    } else {
      // Listen for input click to detect active preset when dropdown opens
      this.inputTarget.addEventListener("click", () => {
        // Small delay to ensure dropdown is fully rendered
        setTimeout(() => {
          this._detectActivePreset();
        }, 50);
      });
    }
  }

  initializeDatepicker() {
    if (this.datepickerInstance) {
      this.datepickerInstance.destroy();
      // Ensure autoUpdate cleanup is robustly handled if destroy didn't trigger position's cleanup
      if (this.cleanupAutoUpdate) {
        this.cleanupAutoUpdate();
        this.cleanupAutoUpdate = null;
      }
      // Also clear the specific one for the current position call, if any
      if (this.currentPositionCleanupAutoUpdate) {
        this.currentPositionCleanupAutoUpdate();
        this.currentPositionCleanupAutoUpdate = null;
      }
    }

    const options = {
      locale: localeEn,
      autoClose: true,
    };

    // Inline mode support
    if (this.inlineValue) {
      options.inline = true;
      // For inline mode, we typically don't want autoClose
      options.autoClose = false;
    }

    // Determine container: if inside a dialog, use the dialog as container.
    const dialogElement = this.inputTarget.closest("dialog");
    if (dialogElement) {
      options.container = dialogElement;
    }
    // If no dialogElement, AirDatepicker defaults to document.body, which is fine.

    // Date Format
    if (this.timeOnlyValue) {
      // For time-only selection, use empty string to hide date display completely
      options.dateFormat = "";
    } else if (this.hasDateFormatValue && this.dateFormatValue) {
      options.dateFormat = this.dateFormatValue;
    }

    // Initial and Minimum View
    if (this.hasStartViewValue) {
      options.view = this.startViewValue;
    }
    if (this.hasMinViewValue) {
      options.minView = this.minViewValue;
    }

    // Min/Max Dates
    if (this.hasMinDateValue && this.minDateValue) {
      const parsedMinDate = this._parseDateString(this.minDateValue);
      if (parsedMinDate) options.minDate = parsedMinDate;
    }
    if (this.hasMaxDateValue && this.maxDateValue) {
      const parsedMaxDate = this._parseDateString(this.maxDateValue);
      if (parsedMaxDate) options.maxDate = parsedMaxDate;
    }

    // Initial Date (selectedDates)
    if (this.hasInitialDateValue && this.initialDateValue) {
      try {
        let parsedDates = [];
        // Check if initialDateValue is a JSON array string for ranges
        if (this.initialDateValue.startsWith("[") && this.initialDateValue.endsWith("]")) {
          const dateStrings = JSON.parse(this.initialDateValue);
          if (Array.isArray(dateStrings)) {
            parsedDates = dateStrings.map((str) => this._parseDateString(str)).filter((d) => d);
          }
        } else {
          // Single date string
          const date = this._parseDateString(this.initialDateValue);
          if (date) parsedDates.push(date);
        }
        if (parsedDates.length > 0) {
          options.selectedDates = parsedDates;
        }
      } catch (e) {
        console.error("Error parsing initialDateValue:", e, "Value was:", this.initialDateValue);
      }
    }

    // Range
    if (this.rangeValue) {
      options.range = true;
      options.multipleDatesSeparator = " - ";
    }

    // Timepicker related options
    if (this.timepickerValue || this.timeOnlyValue) {
      options.timepicker = true;
      options.timeFormat = this.hasTimeFormatValue && this.timeFormatValue ? this.timeFormatValue : "hh:mm AA";
      if (this.hasMinHoursValue) options.minHours = this.minHoursValue;
      if (this.hasMaxHoursValue) options.maxHours = this.maxHoursValue;
      if (this.hasMinutesStepValue) options.minutesStep = this.minutesStepValue;
    }

    // Add CSS class for time-only selection
    if (this.timeOnlyValue) {
      options.classes = "only-timepicker";
    }

    // Handle time-only display in input
    if (this.timeOnlyValue) {
      options.onSelect = ({ date, formattedDate, datepicker }) => {
        if (date) {
          // Format time only using the timeFormat
          const timeFormat = this.hasTimeFormatValue && this.timeFormatValue ? this.timeFormatValue : "hh:mm AA";
          const timeOnlyValue = datepicker.formatDate(date, timeFormat);
          this.inputTarget.value = timeOnlyValue;

          // Trigger change event to update any display elements
          this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
        }
      };
    }

    // Week picker configuration
    if (this.weekPickerValue) {
      options.dateFormat = ""; // We'll handle formatting manually

      // Handle week selection and formatting
      options.onSelect = ({ date, formattedDate, datepicker }) => {
        if (date) {
          const weekNumber = this._getWeekNumber(date);
          const year = date.getFullYear();
          const weekFormat = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
          this.inputTarget.value = weekFormat;

          // Trigger change event to update any display elements
          this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
        }
      };

      // Parse disabled dates for use in week picker
      let parsedDisabledDates = [];
      if (this.disabledDatesValue && this.disabledDatesValue.length > 0) {
        parsedDisabledDates = this.disabledDatesValue
          .map((str) => {
            const parts = str.split("-");
            if (parts.length === 3) {
              const [year, month, day] = parts.map(Number);
              return new Date(Date.UTC(year, month - 1, day));
            }
            console.warn(`Invalid date string format in disabledDatesValue: ${str}. Expected YYYY-MM-DD.`);
            return null;
          })
          .filter((date) => date !== null);
      }

      // Add custom cell rendering for disabled dates only
      if (parsedDisabledDates.length > 0) {
        options.onRenderCell = ({ date, cellType }) => {
          let result = {};

          if (cellType === "day") {
            // Check if this date is disabled
            const cellDateUTC = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
            if (parsedDisabledDates.some((disabledDate) => disabledDate.getTime() === cellDateUTC.getTime())) {
              result.disabled = true;
            }
          }

          return result;
        };
      }
    }

    // Buttons
    const buttons = [];
    if (this.showTodayButtonValue) {
      const isTimepickerEnabled = this.timepickerValue || this.timeOnlyValue || this.weekPickerValue;
      const buttonText = this.weekPickerValue ? "This week" : isTimepickerEnabled ? "Now" : "Today";
      buttons.push({
        content: buttonText,
        onClick: (dp) => {
          const currentDate = new Date();

          if (isTimepickerEnabled && !this.weekPickerValue) {
            dp.clear(); // Clear existing selection
            // Use a timeout to ensure operations occur in sequence
            setTimeout(() => {
              if (this.rangeValue) {
                // For range, select the same date twice with time update
                dp.selectDate([currentDate, currentDate], { updateTime: true });
              } else {
                // For single date, select with time update
                dp.selectDate(currentDate, { updateTime: true });
              }
              // No need to call dp.update() separately if updateTime works as expected
            }, 0);
          } else {
            // Standard behavior for date-only or week pickers
            if (this.rangeValue) {
              dp.selectDate([currentDate, currentDate]);
            } else {
              dp.selectDate(currentDate);
            }
          }
        },
      });
    }

    if (this.showThisMonthButtonValue) {
      buttons.push({
        content: "This month",
        onClick: (dp) => {
          const currentDate = new Date();
          // For month picker, select the current month
          const firstDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
          dp.selectDate(firstDayOfMonth);
        },
      });
    }

    if (this.showThisYearButtonValue) {
      buttons.push({
        content: "This year",
        onClick: (dp) => {
          const currentDate = new Date();
          // For year picker, select the current year
          const firstDayOfYear = new Date(currentDate.getFullYear(), 0, 1);
          dp.selectDate(firstDayOfYear);
        },
      });
    }

    if (this.showClearButtonValue) buttons.push("clear");
    if (buttons.length > 0) options.buttons = buttons;

    // Disabled Dates (using onRenderCell) - only apply if week picker is not enabled
    if (this.disabledDatesValue && this.disabledDatesValue.length > 0 && !this.weekPickerValue) {
      const parsedDisabledDates = this.disabledDatesValue
        .map((str) => {
          const parts = str.split("-");
          if (parts.length === 3) {
            const [year, month, day] = parts.map(Number);
            // Create date in UTC to avoid timezone issues with YYYY-MM-DD strings
            return new Date(Date.UTC(year, month - 1, day));
          }
          console.warn(`Invalid date string format in disabledDatesValue: ${str}. Expected YYYY-MM-DD.`);
          return null;
        })
        .filter((date) => date !== null);

      if (parsedDisabledDates.length > 0) {
        options.onRenderCell = ({ date, cellType }) => {
          if (cellType === "day") {
            // Get UTC equivalent of the cell's date for comparison
            const cellDateUTC = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
            if (parsedDisabledDates.some((disabledDate) => disabledDate.getTime() === cellDateUTC.getTime())) {
              return { disabled: true };
            }
          }
          return {}; // Return empty object if no changes for this cell by this logic
        };
      }
    }

    // General onSelect handler for regular date pickers (not time-only or week picker)
    if (!this.timeOnlyValue && !this.weekPickerValue) {
      const originalOnSelect = options.onSelect;
      options.onSelect = ({ date, formattedDate, datepicker }) => {
        // Call original onSelect if it exists
        if (originalOnSelect) {
          originalOnSelect({ date, formattedDate, datepicker });
        }

        // For inline calendars, trigger sync to main picker
        if (this.inlineValue) {
          // Small delay to ensure the selection is fully processed
          setTimeout(() => {
            this.syncToMainPicker();
          }, 10);
        }

        // Trigger change event to update any display elements
        this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
      };
    }

    // Position function using Floating UI - skip for inline mode
    if (!this.inlineValue) {
      options.position = ({ $datepicker, $target, $pointer, done }) => {
        const middleware = [offset(8), flip(), shift({ padding: 8 })];

        if ($pointer && $pointer instanceof HTMLElement) {
          middleware.push(arrow({ element: $pointer, padding: 5 }));
        }

        if (this.currentPositionCleanupAutoUpdate) {
          this.currentPositionCleanupAutoUpdate();
          this.currentPositionCleanupAutoUpdate = null;
        }

        this.currentPositionCleanupAutoUpdate = autoUpdate(
          $target,
          $datepicker,
          () => {
            computePosition($target, $datepicker, {
              placement: this.placementValue, // Use Stimulus value here
              middleware: middleware,
            }).then(({ x, y, middlewareData }) => {
              // Removed 'placement' from destructuring as it's not used by this block
              Object.assign($datepicker.style, {
                left: `${x}px`,
                top: `${y}px`,
              });

              if ($pointer && $pointer instanceof HTMLElement && middlewareData.arrow) {
                const { x: arrowX, y: arrowY } = middlewareData.arrow;
                Object.assign($pointer.style, {
                  left: arrowX != null ? `${arrowX}px` : "",
                  top: arrowY != null ? `${arrowY}px` : "",
                });
              }
            });
          },
          { animationFrame: true }
        );

        this.cleanupAutoUpdate = this.currentPositionCleanupAutoUpdate;

        return () => {
          if (this.currentPositionCleanupAutoUpdate) {
            this.currentPositionCleanupAutoUpdate();
            if (this.cleanupAutoUpdate === this.currentPositionCleanupAutoUpdate) {
              this.cleanupAutoUpdate = null;
            }
            this.currentPositionCleanupAutoUpdate = null;
          }
          done();
        };
      };
    }

    this.datepickerInstance = new AirDatepicker(this.inputTarget, options);

    // For inline mode, ensure display elements are updated on initialization
    if (this.inlineValue) {
      setTimeout(() => {
        this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
      }, 0);
    }

    // Format initial value for week picker - use setTimeout to ensure it runs after AirDatepicker initialization
    if (this.weekPickerValue) {
      setTimeout(() => {
        if (this.datepickerInstance) {
          // If there are selected dates, format them as weeks
          if (this.datepickerInstance.selectedDates.length > 0) {
            const initialDate = this.datepickerInstance.selectedDates[0];
            const weekNumber = this._getWeekNumber(initialDate);
            const year = initialDate.getFullYear();
            const weekFormat = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
            this.inputTarget.value = weekFormat;
            this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
          } else {
            // Clear the input if no dates are selected
            this.inputTarget.value = "";
            this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
          }
        }
      }, 0);
    }
  }

  _getWeekNumber(date) {
    // ISO 8601 week number calculation
    // Week 1 is the first week with at least 4 days in the new year
    const tempDate = new Date(date.getTime());

    // Set to nearest Thursday: current date + 4 - current day number
    // Make Sunday's day number 7
    const dayNumber = (tempDate.getDay() + 6) % 7;
    tempDate.setDate(tempDate.getDate() - dayNumber + 3);

    // Get first Thursday of year
    const firstThursday = new Date(tempDate.getFullYear(), 0, 4);
    firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3);

    // Calculate week number
    const weekNumber = Math.round((tempDate.getTime() - firstThursday.getTime()) / 86400000 / 7) + 1;

    return weekNumber;
  }

  _parseDateString(dateString) {
    if (!dateString || typeof dateString !== "string") return null;
    const parts = dateString.split("-");
    if (parts.length === 3) {
      const [year, month, day] = parts.map(Number);
      if (!isNaN(year) && !isNaN(month) && !isNaN(day)) {
        return new Date(Date.UTC(year, month - 1, day));
      }
    }
    console.warn(`Invalid date string format: ${dateString}. Expected YYYY-MM-DD.`);
    return null;
  }

  handleKeydown(event) {
    if (event.key === "Delete" || event.key === "Backspace") {
      if (this.datepickerInstance) {
        this.datepickerInstance.clear();
      }
      // Ensure the input field is also cleared, as AirDatepicker might not always do this
      // especially if no date was selected.
      this.inputTarget.value = "";
    }
  }

  disconnect() {
    if (this.datepickerInstance) {
      this.datepickerInstance.destroy(); // This should trigger the cleanup returned by position()
      this.datepickerInstance = null;
    }
    // Fallback cleanup for autoUpdate, in case destroy() didn't clear it or it was managed outside position's return.
    if (this.cleanupAutoUpdate) {
      this.cleanupAutoUpdate();
      this.cleanupAutoUpdate = null;
    }
    if (this.currentPositionCleanupAutoUpdate) {
      // Also ensure any lingering position-specific cleanup is called
      this.currentPositionCleanupAutoUpdate();
      this.currentPositionCleanupAutoUpdate = null;
    }

    // Only remove event listener if it was added
    if (this.inputTarget && !this.element.getAttribute("data-controller").includes("dropdown-popover")) {
      this.inputTarget.removeEventListener("keydown", this.handleKeydown.bind(this));
    }
  }

  // Preset time range methods
  setToday(event) {
    const today = new Date();
    // Set hours to noon to avoid any timezone edge cases
    today.setHours(12, 0, 0, 0);
    this._setDateRange(today, today);
    this._setActivePreset(event.currentTarget);
  }

  setYesterday(event) {
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    this._setDateRange(yesterday, yesterday);
    this._setActivePreset(event.currentTarget);
  }

  setLastDays(event) {
    const days = parseInt(event.currentTarget.dataset.days, 10);
    if (isNaN(days) || days <= 0) {
      console.warn("Invalid number of days specified in data-days attribute");
      return;
    }

    const endDate = new Date();
    const startDate = new Date();
    startDate.setDate(startDate.getDate() - (days - 1)); // days - 1 because we include today
    this._setDateRange(startDate, endDate);
    this._setActivePreset(event.currentTarget);
  }

  setPreset(event) {
    const presetType = event.currentTarget.dataset.presetType;

    switch (presetType) {
      case "this-month":
        this._setThisMonth();
        break;
      case "last-month":
        this._setLastMonth();
        break;
      case "this-year":
        this._setThisYear();
        break;
      default:
        console.warn(`Unknown preset type: ${presetType}`);
    }
    this._setActivePreset(event.currentTarget);
  }

  _setThisMonth() {
    const now = new Date();
    const startDate = new Date(now.getFullYear(), now.getMonth(), 1);
    const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0);
    this._setDateRange(startDate, endDate);
  }

  _setLastMonth() {
    const now = new Date();
    const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
    const endDate = new Date(now.getFullYear(), now.getMonth(), 0);
    this._setDateRange(startDate, endDate);
  }

  _setThisYear() {
    const now = new Date();
    const startDate = new Date(now.getFullYear(), 0, 1);
    const endDate = new Date(now.getFullYear(), 11, 31);
    this._setDateRange(startDate, endDate);
  }

  clearSelection() {
    if (this.datepickerInstance) {
      this.datepickerInstance.clear();
    }
    this.inputTarget.value = "";
    this._syncToInlineCalendar();
    this._triggerChangeEvent();
    this._clearActivePreset();
  }

  syncFromInlineCalendar(event) {
    // Sync the main input from the inline calendar when it changes
    const inlineInput = event.target;
    if (inlineInput.value) {
      this.inputTarget.value = inlineInput.value;
      this._triggerChangeEvent();
    }
  }

  syncToMainPicker(event = null) {
    // This method is called from the inline calendar to sync to main picker
    // Find the main date picker controller in the parent dropdown
    const dropdownElement = this.element.closest(
      '[data-controller*="dropdown-popover"][data-controller*="date-picker"]'
    );
    if (dropdownElement) {
      const mainController = this.application.getControllerForElementAndIdentifier(dropdownElement, "date-picker");
      if (mainController && mainController !== this) {
        // Get the selected dates from the inline calendar
        const selectedDates = this.datepickerInstance ? this.datepickerInstance.selectedDates : [];

        if (selectedDates.length > 0) {
          // Format the dates for the main input
          const formattedValue = mainController._formatDateRange(
            selectedDates[0],
            selectedDates[selectedDates.length - 1]
          );
          mainController.inputTarget.value = formattedValue;

          // If the main controller has a datepicker instance, sync the selected dates
          if (mainController.datepickerInstance) {
            // For range pickers, ensure we complete the range selection properly
            if (mainController.rangeValue) {
              if (selectedDates.length === 1) {
                // Select the same date twice to complete the range selection
                mainController.datepickerInstance.selectDate([selectedDates[0], selectedDates[0]]);
              } else {
                mainController.datepickerInstance.selectDate(selectedDates);
              }
            } else {
              mainController.datepickerInstance.selectDate(selectedDates[0]);
            }
          }
        } else {
          // Clear the main input if no dates selected
          mainController.inputTarget.value = "";
          if (mainController.datepickerInstance) {
            mainController.datepickerInstance.clear();
          }
        }

        mainController._triggerChangeEvent();

        // Clear active preset when dates are manually selected
        mainController._clearActivePreset();

        // Close the dropdown after selection if we have a complete range or single date
        if (mainController.rangeValue ? selectedDates.length >= 2 : selectedDates.length >= 1) {
          const dropdownController = this.application.getControllerForElementAndIdentifier(
            dropdownElement,
            "dropdown-popover"
          );
          if (dropdownController) {
            dropdownController.close();
          }
        }
      }
    }
  }

  // Helper methods
  _setDateRange(startDate, endDate) {
    // If we have a datepicker instance, use it
    if (this.datepickerInstance) {
      // In range mode, always pass both dates to complete the range selection
      const dates = this.rangeValue ? [startDate, endDate] : [startDate];
      this.datepickerInstance.selectDate(dates);
    } else {
      // For dropdown-popover mode, directly set the input value
      const formattedValue = this._formatDateRange(startDate, endDate);
      this.inputTarget.value = formattedValue;
    }

    this._syncToInlineCalendar();
    this._triggerChangeEvent();
  }

  _formatDateRange(startDate, endDate) {
    const formatDate = (date) => {
      const year = date.getFullYear();
      const month = String(date.getMonth() + 1).padStart(2, "0");
      const day = String(date.getDate()).padStart(2, "0");
      return `${month}/${day}/${year}`;
    };

    // In range mode, always format as a range even if dates are the same
    if (this.rangeValue) {
      return `${formatDate(startDate)} - ${formatDate(endDate)}`;
    } else {
      return formatDate(startDate);
    }
  }

  _syncToInlineCalendar() {
    // Find inline calendar in the same dropdown and sync its value
    const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]');
    if (dropdownMenu) {
      const inlineCalendar = dropdownMenu.querySelector('.inline-calendar[data-controller="date-picker"]');
      if (inlineCalendar) {
        const inlineController = this.application.getControllerForElementAndIdentifier(inlineCalendar, "date-picker");
        if (inlineController && inlineController.datepickerInstance) {
          // Get selected dates from main picker and apply to inline calendar
          if (this.datepickerInstance && this.datepickerInstance.selectedDates.length > 0) {
            // If it's a range picker and both dates are the same, only select one date
            const datesToSelect =
              this.rangeValue &&
              this.datepickerInstance.selectedDates.length === 2 &&
              this.datepickerInstance.selectedDates[0].getTime() === this.datepickerInstance.selectedDates[1].getTime()
                ? [this.datepickerInstance.selectedDates[0]]
                : this.datepickerInstance.selectedDates;
            inlineController.datepickerInstance.selectDate(datesToSelect);
          } else {
            // If no main datepicker instance, parse the input value and set dates
            if (this.inputTarget.value) {
              const dates = this._parseDateRangeValue(this.inputTarget.value);
              if (dates.length > 0) {
                // Clear any existing selection first to ensure clean state
                inlineController.datepickerInstance.clear();

                // If it's a range picker and we have a single date or both parsed dates are the same
                if (inlineController.rangeValue) {
                  if (dates.length === 1) {
                    // For single date in range mode, select it twice to complete the range
                    inlineController.datepickerInstance.selectDate([dates[0], dates[0]]);
                  } else if (dates.length === 2 && dates[0].getTime() === dates[1].getTime()) {
                    // For identical start/end dates, select twice to complete the range
                    inlineController.datepickerInstance.selectDate([dates[0], dates[0]]);
                  } else {
                    // For different dates, select normally
                    inlineController.datepickerInstance.selectDate(dates);
                  }
                } else {
                  // Non-range mode, select normally
                  inlineController.datepickerInstance.selectDate(dates);
                }
              }
            } else {
              inlineController.datepickerInstance.clear();
            }
          }
        }
      }
    }
  }

  _parseDateRangeValue(value) {
    // Parse a date range string like "01/15/2025 - 01/20/2025" or "01/15/2025"
    const dates = [];
    if (value.includes(" - ")) {
      const [startStr, endStr] = value.split(" - ");
      const startDate = this._parseFormattedDate(startStr.trim());
      const endDate = this._parseFormattedDate(endStr.trim());
      if (startDate) dates.push(startDate);
      if (endDate) dates.push(endDate);
    } else {
      const date = this._parseFormattedDate(value.trim());
      if (date) dates.push(date);
    }
    return dates;
  }

  _parseFormattedDate(dateStr) {
    // Parse MM/DD/YYYY format
    const parts = dateStr.split("/");
    if (parts.length === 3) {
      const month = parseInt(parts[0], 10) - 1; // Month is 0-indexed
      const day = parseInt(parts[1], 10);
      const year = parseInt(parts[2], 10);
      return new Date(year, month, day);
    }
    return null;
  }

  _triggerChangeEvent() {
    this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
  }

  _setActivePreset(button) {
    // Remove active class from all preset buttons
    this._clearActivePreset();

    // Add active class to the clicked button
    if (button) {
      button.classList.add(
        "bg-neutral-900",
        "text-white",
        "hover:!bg-neutral-700",
        "dark:bg-neutral-100",
        "dark:hover:!bg-neutral-200",
        "dark:text-neutral-900"
      );
      button.classList.remove(
        "text-neutral-700",
        "hover:bg-neutral-100",
        "dark:text-neutral-300",
        "dark:hover:bg-neutral-700/50"
      );
    }
  }

  _clearActivePreset() {
    // Find all preset buttons and remove active styling
    const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]');
    if (dropdownMenu) {
      const presetButtons = dropdownMenu.querySelectorAll(
        '[data-menu-target="item"]:not([data-action*="clearSelection"])'
      );
      presetButtons.forEach((button) => {
        button.classList.remove(
          "bg-neutral-900",
          "text-white",
          "hover:!bg-neutral-700",
          "dark:bg-neutral-100",
          "dark:hover:!bg-neutral-200",
          "dark:text-neutral-900"
        );
        button.classList.add(
          "text-neutral-700",
          "hover:bg-neutral-100",
          "dark:text-neutral-300",
          "dark:hover:bg-neutral-700/50"
        );
      });
    }
  }

  _detectActivePreset() {
    // Parse the current date range from the input
    const dateRangeValue = this.inputTarget.value;
    if (!dateRangeValue) return;

    const dates = this._parseDateRangeValue(dateRangeValue);
    if (dates.length !== 2) return;

    const [startDate, endDate] = dates;
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    // Normalize dates for comparison
    startDate.setHours(0, 0, 0, 0);
    endDate.setHours(0, 0, 0, 0);

    // Check each preset to see if it matches
    const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]');
    if (!dropdownMenu) return;

    const presetButtons = dropdownMenu.querySelectorAll(
      '[data-menu-target="item"]:not([data-action*="clearSelection"])'
    );

    presetButtons.forEach((button) => {
      let isMatch = false;

      if (button.dataset.action && button.dataset.action.includes("setToday")) {
        isMatch = this._isSameDay(startDate, today) && this._isSameDay(endDate, today);
      } else if (button.dataset.action && button.dataset.action.includes("setYesterday")) {
        const yesterday = new Date(today);
        yesterday.setDate(yesterday.getDate() - 1);
        isMatch = this._isSameDay(startDate, yesterday) && this._isSameDay(endDate, yesterday);
      } else if (button.dataset.action && button.dataset.action.includes("setLastDays")) {
        const days = parseInt(button.dataset.days, 10);
        const expectedStart = new Date(today);
        expectedStart.setDate(expectedStart.getDate() - (days - 1));
        isMatch = this._isSameDay(startDate, expectedStart) && this._isSameDay(endDate, today);
      } else if (button.dataset.action && button.dataset.action.includes("setPreset")) {
        const presetType = button.dataset.presetType;
        switch (presetType) {
          case "this-month":
            const thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1);
            const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
            isMatch = this._isSameDay(startDate, thisMonthStart) && this._isSameDay(endDate, thisMonthEnd);
            break;
          case "last-month":
            const lastMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
            const lastMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0);
            isMatch = this._isSameDay(startDate, lastMonthStart) && this._isSameDay(endDate, lastMonthEnd);
            break;
          case "this-year":
            const thisYearStart = new Date(today.getFullYear(), 0, 1);
            const thisYearEnd = new Date(today.getFullYear(), 11, 31);
            isMatch = this._isSameDay(startDate, thisYearStart) && this._isSameDay(endDate, thisYearEnd);
            break;
        }
      }

      if (isMatch) {
        this._setActivePreset(button);
      }
    });
  }

  _isSameDay(date1, date2) {
    return (
      date1.getFullYear() === date2.getFullYear() &&
      date1.getMonth() === date2.getMonth() &&
      date1.getDate() === date2.getDate()
    );
  }
}

2. Dependencies Installation

The date picker component relies on AirDatepicker and Floating UI for positioning. Choose your preferred installation method:

Terminal
npm install air-datepicker
npm install @floating-ui/dom
Terminal
yarn add air-datepicker
yarn add @floating-ui/dom
pin "air-datepicker", to: "https://esm.sh/[email protected]"
pin "air-datepicker/locale/en", to: "https://esm.sh/[email protected]/locale/en"
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]/+esm"

Now add this to your <head> HTML tag:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/air-datepicker.min.css">

3. Custom CSS

Here are the custom CSS classes that we used on Rails Blocks to style the date picker components. You can copy and paste these into your own CSS file to style & personalize your date pickers.

/* air-datepicker */

.air-datepicker {
  --adp-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
    "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  --adp-font-size: 14px;
  --adp-width: 246px;
  --adp-z-index: 40;
  --adp-padding: 4px;
  --adp-grid-areas: "nav" "body" "timepicker" "buttons";
  --adp-transition-duration: 0.3s;
  --adp-transition-ease: ease-out;
  --adp-transition-offset: 8px;
  --adp-background-color: #fff;
  --adp-background-color-hover: #f0f0f0;
  --adp-background-color-active: #eaeaea;
  --adp-background-color-in-range: rgba(16, 16, 16, 0.1);
  --adp-background-color-in-range-focused: rgba(236, 236, 236, 0.2);
  --adp-background-color-selected-other-month-focused: #f1f1f1;
  --adp-background-color-selected-other-month: #e6e6e6;
  --adp-color: #4a4a4a;
  --adp-color-secondary: #9c9c9c;
  --adp-accent-color: #0a0a0a;
  --adp-color-current-date: var(--adp-accent-color);
  --adp-color-other-month: #dedede;
  --adp-color-disabled: #aeaeae;
  --adp-color-disabled-in-range: #939393;
  --adp-color-other-month-hover: #c5c5c5;
  --adp-border-color: rgba(0, 0, 0, 0.1);
  --adp-border-color-inner: #efefef;
  --adp-border-radius: 8px;
  --adp-border-color-inline: #d7d7d7;
  --adp-nav-height: 32px;
  --adp-nav-arrow-color: var(--adp-color-secondary);
  --adp-nav-action-size: 32px;
  --adp-nav-color-secondary: var(--adp-color-secondary);
  --adp-day-name-color: #464646;
  --adp-day-name-color-hover: #f1f1f1;
  --adp-day-cell-width: 1fr;
  --adp-day-cell-height: 32px;
  --adp-month-cell-height: 42px;
  --adp-year-cell-height: 56px;
  --adp-pointer-size: 10px;
  --adp-poiner-border-radius: 2px;
  --adp-pointer-offset: 14px;
  --adp-cell-border-radius: 4px;
  --adp-cell-background-color-hover: var(--adp-background-color-hover);
  --adp-cell-background-color-selected: #1d1d1d;
  --adp-cell-background-color-selected-hover: #303030;
  --adp-cell-background-color-in-range: rgba(38, 38, 38, 0.1);
  --adp-cell-background-color-in-range-hover: rgba(44, 44, 44, 0.2);
  --adp-cell-border-color-in-range: var(--adp-cell-background-color-selected);
  --adp-btn-height: 32px;
  --adp-btn-color: var(--adp-accent-color);
  --adp-btn-color-hover: var(--adp-color);
  --adp-btn-border-radius: var(--adp-border-radius);
  --adp-btn-background-color-hover: var(--adp-background-color-hover);
  --adp-btn-background-color-active: var(--adp-background-color-active);
  --adp-time-track-height: 1px;
  --adp-time-track-color: #dedede;
  --adp-time-track-color-hover: #b1b1b1;
  --adp-time-thumb-size: 12px;
  --adp-time-padding-inner: 10px;
  --adp-time-day-period-color: var(--adp-color-secondary);
  --adp-mobile-font-size: 16px;
  --adp-mobile-nav-height: 40px;
  --adp-mobile-width: 320px;
  --adp-mobile-day-cell-height: 38px;
  --adp-mobile-month-cell-height: 48px;
  --adp-mobile-year-cell-height: 64px;
}
.air-datepicker-overlay {
  --adp-overlay-background-color: rgba(0, 0, 0, 0.3);
  --adp-overlay-transition-duration: 0.3s;
  --adp-overlay-transition-ease: ease-out;
  --adp-overlay-z-index: 99;
}

.air-datepicker-cell.-selected-.-day- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected-.-year- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected-.-month- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected-.-current- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-current- {
  border: 1px solid #bdbdbd !important;
}

.air-datepicker-cell.-selected-.-day-.-other-month- {
  color: #cccccc; /* Ensure selected cell text is also light in dark mode */
  background: #7e7e7e;
}

@media (prefers-color-scheme: dark) {
  .air-datepicker {
    --adp-background-color: #2d2d2d; /* Dark background */
    --adp-background-color-hover: #3a3a3a;
    --adp-background-color-active: #4a4a4a;
    --adp-background-color-in-range: rgba(196, 196, 196, 0.2);
    --adp-background-color-in-range-focused: rgba(196, 196, 196, 0.3);
    --adp-background-color-selected-other-month-focused: #333;
    --adp-background-color-selected-other-month: #444;
    --adp-color: #e0e0e0; /* Light text color */
    --adp-color-secondary: #a0a0a0;
    --adp-accent-color: #ffffff; /* A light accent color for dark mode */
    --adp-color-other-month: #555;
    --adp-color-disabled: #777;
    --adp-color-disabled-in-range: #888;
    --adp-color-other-month-hover: #666;
    --adp-border-color: #ffffff1a; /* Lighter border for dark mode */
    --adp-border-color-inner: #444;
    --adp-border-color-inline: #444444;
    --adp-nav-arrow-color: var(--adp-color-secondary);
    --adp-day-name-color: #c0c0c0;
    --adp-day-name-color-hover: #3a3a3a;
    --adp-cell-background-color-hover: var(--adp-background-color-hover);
    --adp-cell-background-color-selected: #ffffff;
    --adp-cell-background-color-selected-hover: #e9e9e9;
    --adp-cell-background-color-in-range: rgba(175, 175, 175, 0.2);
    --adp-cell-background-color-in-range-hover: rgba(169, 169, 169, 0.3);
    --adp-btn-color: var(--adp-accent-color);
    --adp-btn-color-hover: var(--adp-color);
    --adp-btn-background-color-hover: var(--adp-background-color-hover);
    --adp-btn-background-color-active: var(--adp-background-color-active);
    --adp-time-track-color: #555;
    --adp-time-track-color-hover: #777;
    --adp-time-day-period-color: var(--adp-color-secondary);
  }

  .air-datepicker-overlay {
    --adp-overlay-background-color: rgba(255, 255, 255, 0.1); /* Lighter overlay for dark mode */
  }

  .air-datepicker-cell.-selected-.-day- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected-.-year- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected-.-month- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected-.-current- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-current- {
    border: 1px solid #505050 !important;
  }

  .air-datepicker-cell.-selected-.-day-.-other-month- {
    color: #cccccc; /* Ensure selected cell text is also light in dark mode */
    background: #7e7e7e;
  }
}

.air-datepicker--navigation {
  width: 100%;
}
.air-datepicker--pointer {
  opacity: 0;
}
.air-datepicker {
  background: var(--adp-background-color);
  border: 1px solid var(--adp-border-color);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  border-radius: var(--adp-border-radius);
  box-sizing: content-box;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: repeat(4, max-content);
  grid-template-areas: var(--adp-grid-areas);
  font-family: var(--adp-font-family), sans-serif;
  font-size: var(--adp-font-size);
  color: var(--adp-color);
  width: var(--adp-width);
  position: absolute;
  transition: opacity var(--adp-transition-duration) var(--adp-transition-ease),
    transform var(--adp-transition-duration) var(--adp-transition-ease);
  z-index: var(--adp-z-index);
}

.air-datepicker-cell.-selected- {
  color: #1d1d1d;
  border: none;
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-disabled- {
  cursor: not-allowed;
}

/* Add a new rule for dark mode selected cells if needed, or adjust the general .air-datepicker-cell.-selected- inside the dark mode media query */
@media (prefers-color-scheme: dark) {
  .air-datepicker-cell.-selected- {
    color: #e0e0e0; /* Example: light text for selected cells in dark mode */
    /* background: var(--adp-cell-background-color-selected); Is already set within .air-datepicker dark vars */
  }
}

/* Time-only picker styles */
.air-datepicker.only-timepicker .air-datepicker--navigation {
  display: none;
}

.air-datepicker.only-timepicker .air-datepicker--content {
  display: none;
}

.air-datepicker.only-timepicker .air-datepicker--time {
  border-top: none;
}

Examples

Basic Date Picker

A simple date picker with default options.

Input Version

Inline Version (Always Visible)

Selected: Today
<%# Basic Date Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Always Visible)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="basic-inline-display-<%= Time.now.to_i %>">Today</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('basic-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date Picker with Initial Date

Pre-populated with a specific date.

Input Version

Inline Version (Pre-selected: August 15, 2024)

Selected: 08/15/2024
<%# Date Picker with Initial Date %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-initial-date-value="2024-08-15"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: August 15, 2024)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-initial-date-value="2024-08-15"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="initial-inline-display-<%= Time.now.to_i %>">08/15/2024</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('initial-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date Range Picker

Select a date range with start and end dates.

Input Version

Inline Version (Pre-selected: Last 7 days)

Selected: 06/20/2025 - 06/26/2025
<%# Date Range Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-range-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date range...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: Last 7 days)</h4>
    <%
      end_date = Date.today
      start_date = end_date - 6
    %>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-range-value="true"
         data-date-picker-initial-date-value='["<%= start_date.strftime('%Y-%m-%d') %>", "<%= end_date.strftime('%Y-%m-%d') %>"]'
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="range-inline-display-<%= Time.now.to_i %>"><%= start_date.strftime('%m/%d/%Y') %> - <%= end_date.strftime('%m/%d/%Y') %></span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('range-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date & Time Picker

Select both date and time in a single picker.

Input Version

Inline Version (Pre-selected: Today at 2:30 PM)

Selected: 06/26/2025 02:30 PM
<%# Date Picker with Time %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-timepicker-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date & time...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: Today at 2:30 PM)</h4>
    <%
      selected_date = Date.today
      formatted_date = "#{selected_date.strftime('%Y-%m-%d')} 14:30"
    %>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-timepicker-value="true"
         data-date-picker-initial-date-value="<%= selected_date.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="datetime-inline-display-<%= Time.now.to_i %>"><%= selected_date.strftime('%m/%d/%Y') %> 02:30 PM</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('datetime-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Time Only Picker

Select only time without date.

Input Version

Inline Version (Pre-selected: 3:00 PM)

Selected: 03:00 PM
<%# Time Only Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-time-only-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select time...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: 3:00 PM)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-time-only-value="true"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="time-inline-display-<%= Time.now.to_i %>">03:00 PM</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('time-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        // Set initial time after a brief delay to ensure datepicker is initialized
        setTimeout(() => {
          const controller = input.closest('[data-controller="date-picker"]').__stimulusController;
          if (controller && controller.datepickerInstance) {
            const date = new Date();
            date.setHours(15, 0, 0, 0); // 3:00 PM
            controller.datepickerInstance.selectDate(date);
          }
        }, 100);

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Week Picker

Select weeks in ISO 8601 format (YYYY-WXX).

Input Version

Inline Version (Pre-selected: This week)

Selected: 2025-W26
<%# Week Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-week-picker-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select week (YYYY-WXX)...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336-.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: This week)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-week-picker-value="true"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        <%
          # Calculate current week number
          current_date = Date.today
          week_number = current_date.strftime('%V').to_i
          year = current_date.year
        %>
        Selected: <span id="week-inline-display-<%= Time.now.to_i %>"><%= year %>-W<%= week_number.to_s.rjust(2, '0') %></span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('week-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Month Selector

Select only month and year.

Input Version

Inline Version (Pre-selected: This month)

Selected: June 2025
<%# Month Selector %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-start-view-value="months"
         data-date-picker-min-view-value="months"
         data-date-picker-date-format-value="MMMM yyyy"
         data-date-picker-show-this-month-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select month...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336-.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: This month)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-start-view-value="months"
         data-date-picker-min-view-value="months"
         data-date-picker-date-format-value="MMMM yyyy"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-show-this-month-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="month-inline-display-<%= Time.now.to_i %>"><%= Date.today.strftime('%B %Y') %></span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('month-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Calendar with constraints

Calendar with constraints to change to picker with constraints

Input with Date Constraints

Inline Calendar with Date & Time Constraints

Selected: 07/03/2025 09:00 AM
Constraints: Today to +30 days, 9AM-5PM, 15min steps
<%# Date Pickers with Constraints %>
<div class="space-y-8">
  <%# Input with Date Constraints %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input with Date Constraints</h4>
    <%
      min_date = Date.today - 7
      max_date = Date.today + 14
    %>
    <div data-controller="date-picker"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-min-date-value="<%= min_date.strftime('%Y-%m-%d') %>"
         data-date-picker-max-date-value="<%= max_date.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date..."
             value="<%= Date.today.strftime('%m/%d/%Y') %>">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Calendar with Date & Time Constraints %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Calendar with Date & Time Constraints</h4>
    <%
      min_date = Date.today
      max_date = Date.today + 30
    %>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-timepicker-value="true"
         data-date-picker-initial-date-value="<%= (Date.today + 7).strftime('%Y-%m-%d') %>"
         data-date-picker-min-date-value="<%= min_date.strftime('%Y-%m-%d') %>"
         data-date-picker-max-date-value="<%= max_date.strftime('%Y-%m-%d') %>"
         data-date-picker-min-hours-value="9"
         data-date-picker-max-hours-value="17"
         data-date-picker-minutes-step-value="15"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        <div>Selected: <span id="inline-constrained-display-<%= Time.now.to_i %>"><%= (Date.today + 7).strftime('%m/%d/%Y') %> 09:00 AM</span></div>
        <div class="text-xs mt-1">Constraints: Today to +30 days, 9AM-5PM, 15min steps</div>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('inline-constrained-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date Range with Presets

Comprehensive date picker with predefined time ranges and inline calendar.



<%# Date Range Picker with Presets %>
<div class="relative w-full max-w-xs"
     data-controller="dropdown-popover date-picker"
     data-dropdown-popover-placement-value="bottom-start"
     data-dropdown-popover-auto-position-value="true"
     data-date-picker-range-value="true">

  <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm pr-10"
         readonly
         data-date-picker-target="input"
         data-dropdown-popover-target="button"
         data-action="click->dropdown-popover#toggle"
         placeholder="Select date range...">

  <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
    <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18">
      <g fill="currentColor">
        <path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path>
        <path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336-.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path>
        <path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path>
      </g>
    </svg>
  </div>

  <dialog data-dropdown-popover-target="menu"
          data-controller="menu"
          data-action="click@document->dropdown-popover#closeOnClickOutside keydown.up->menu#prev keydown.down->menu#next keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
          class="outline-none absolute z-50 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50 [&[open]]:scale-100 [&[open]]:opacity-100"
          data-menu-index-value="-1">

    <div class="flex flex-col sm:flex-row">
      <!-- Left side: Time range presets -->
      <div class="border-b sm:border-b-0 sm:border-r border-neutral-200 dark:border-neutral-700 px-2 py-3">
        <div class="space-y-0">
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setToday">
            Today
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setYesterday">
            Yesterday
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="7">
            Last 7 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="30">
            Last 30 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="90">
            Last 90 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="180">
            Last 180 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="365">
            Last 365 days
          </button>
          <hr class="my-2 border-neutral-200 dark:border-neutral-700">
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setPreset"
                  data-preset-type="this-month">
            This month
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setPreset"
                  data-preset-type="last-month">
            Last month
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setPreset"
                  data-preset-type="this-year">
            This year
          </button>
          <hr class="my-2 border-neutral-200 dark:border-neutral-700">
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-red-600 hover:bg-red-50 focus:bg-red-50 focus:outline-none dark:text-red-400 dark:hover:bg-red-900/20 dark:focus:bg-red-900/20 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#clearSelection">
            Clear & Close
          </button>
        </div>
      </div>

      <!-- Right side: Inline calendar -->
      <div class="flex p-3 justify-center items-center sm:px-6">
        <div data-controller="date-picker"
             data-date-picker-inline-value="true"
             data-date-picker-range-value="true"
             data-date-picker-show-clear-button-value="true"
             class="inline-calendar">
          <input data-date-picker-target="input"
                 class="hidden">
        </div>
      </div>
    </div>
  </dialog>
</div>

Native HTML Date Fields

Browser native date inputs for comparison and fallback scenarios.

Native HTML week input - displays format like "2025-W21" and provides native week selection UI

to

Only allows dates within ±7 days from today

<%# HTML Native Date & Time Fields %>
<div class="w-full max-w-sm space-y-6">
  <div class="relative gap-y-1.5">
    <label for="date">Date</label>
    <input type="date" name="date" value="<%= Date.today.strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="datetime">Date & Time</label>
    <input type="datetime-local" name="datetime" value="<%= DateTime.now.strftime('%Y-%m-%dT%H:%M') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="time">Time Only</label>
    <input type="time" name="time" value="<%= Time.now.strftime('%H:%M') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="week">Week</label>
    <input type="week" name="week" value="<%= Date.today.strftime('%Y-W%V') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
    <p class="text-neutral-600 dark:text-neutral-300/75 text-xs italic mt-1">Native HTML week input - displays format like "2025-W21" and provides native week selection UI</p>
  </div>

  <div class="relative gap-y-1.5">
    <label for="month">Month</label>
    <input type="month" name="month" value="<%= Date.today.strftime('%Y-%m') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="date_range">Date Range (using two inputs)</label>
    <div class="flex items-center gap-2">
      <input type="date" name="range_start" value="<%= (Date.today - 7).strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm" placeholder="Start date">
      <span class="text-neutral-500">to</span>
      <input type="date" name="range_end" value="<%= Date.today.strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm" placeholder="End date">
    </div>
  </div>

  <div class="relative gap-y-1.5">
    <label for="date_min_max">Date with Min/Max Constraints</label>
    <input type="date" name="date_min_max" value="<%= Date.today.strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-none leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
           min="<%= (Date.today - 7).strftime('%Y-%m-%d') %>"
           max="<%= (Date.today + 7).strftime('%Y-%m-%d') %>">
    <p class="text-neutral-600 dark:text-neutral-300/75 text-xs italic mt-1">Only allows dates within ±7 days from today</p>
  </div>
</div>

Configuration

The date picker component is powered by AirDatepicker and a Stimulus controller that provides extensive configuration options.

Controller Setup

Basic date picker structure with required data attributes:

<div data-controller="date-picker">
  <input type="text"
         data-date-picker-target="input"
         class="form-input"
         placeholder="Select date" />
</div>

Configuration Values

Prop Description Type Default
range
Enable date range selection mode Boolean false
timepicker
Enable time selection along with date Boolean false
timeOnly
Show only time picker without date Boolean false
weekPicker
Enable week selection mode (ISO 8601 format) Boolean false
inline
Make the calendar always visible Boolean false
dateFormat
Custom date format (e.g., 'dd/MM/yyyy', 'MMMM yyyy') String "MM/dd/yyyy"
timeFormat
Time format when timepicker is enabled (e.g., 'HH:mm' for 24h) String "hh:mm AA"
minDate
Minimum selectable date in YYYY-MM-DD format String None
maxDate
Maximum selectable date in YYYY-MM-DD format String None
initialDate
Initial selected date(s). Single date as 'YYYY-MM-DD' or range as JSON array String None
disabledDates
Array of dates to disable in YYYY-MM-DD format Array []
showTodayButton
Show Today/Now/This week button (context-aware) Boolean false
showClearButton
Show Clear button to reset selection Boolean false
showThisMonthButton
Show This Month button for month pickers Boolean false
showThisYearButton
Show This Year button for year pickers Boolean false
placement
Floating UI placement for the dropdown String "bottom-start"
startView
Initial view ('days', 'months', 'years') String "days"
minView
Minimum view level ('days', 'months', 'years') String "days"
minHours
Minimum selectable hour (0-23) Number None
maxHours
Maximum selectable hour (0-23) Number None
minutesStep
Step increment for minutes selection Number 1

Targets

Target Description Required
input
The input field that displays the selected date and triggers the calendar Required
inlineCalendar
Container for inline calendar displays (used with inline mode) Optional

Actions

Action Description Usage
setToday
Set date to today click->date-picker#setToday
setYesterday
Set date to yesterday click->date-picker#setYesterday
setLastDays
Set date range for last N days (requires data-days attribute) click->date-picker#setLastDays
setPreset
Set predefined date ranges (requires data-preset-type attribute) click->date-picker#setPreset
clearSelection
Clear the current date selection click->date-picker#clearSelection
syncFromInlineCalendar
Sync selection from inline calendar change->date-picker#syncFromInlineCalendar
syncToMainPicker
Sync inline calendar to main picker change->date-picker#syncToMainPicker

Features

  • Flexible Date Selection: Single dates, date ranges, weeks, months, or years
  • Time Selection: Combined date & time or time-only modes with customizable formats
  • Smart Buttons: Context-aware buttons (Today becomes Now for time pickers, This week for week pickers)
  • Inline Mode: Always-visible calendar widgets for dashboards
  • Presets: Quick selection with predefined date ranges
  • Keyboard Navigation: Full keyboard support with Delete/Backspace to clear
  • Dark Mode: Full dark mode support with Tailwind CSS
  • Floating UI: Smart positioning that adapts to viewport constraints

Internationalization

To use different languages, install locale files from the AirDatepicker repository and import them in your controller:

import localeEs from "air-datepicker/locale/es";
// In the controller
this.datepickerInstance = new AirDatepicker(this.inputTarget, {
  locale: localeEs,
  // ... other options
});

Week Picker Details

The week picker feature provides a more accessible alternative to the native HTML week input:

  • ISO 8601 Format: Outputs weeks in standard YYYY-WXX format (e.g., "2025-W21")
  • Week Calculation: Week 1 is the first week with at least 4 days in the new year
  • Visual Indicators: Shows week numbers on calendar cells
  • Cross-browser: Consistent behavior across all browsers

Table of contents