diff --git a/app/components/ruby_ui/calendar_input/calendar_input.rb b/app/components/ruby_ui/calendar_input/calendar_input.rb new file mode 100644 index 00000000..7c7c6370 --- /dev/null +++ b/app/components/ruby_ui/calendar_input/calendar_input.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module RubyUI + class CalendarInput < Input + def initialize(calendar_id: nil, format: "MM-dd-yyyy", placeholder: nil, label_animation: true, label_classes: nil, label_separator: " - ex: ", **attrs) + @calendar_id = calendar_id + @format = format + @placeholder = placeholder || default_placeholder_for_format(format) + @label_animation = label_animation + @label_classes = label_classes || "text-xs text-gray-500 -mt-2 transition-all duration-200" + @label_separator = label_separator + super(type: :string, **attrs) + end + + def view_template + input(**attrs) + end + + private + + def default_placeholder_for_format(format) + case format + when "MM-dd-yyyy" + "(01-16-1998)" + when "dd-MM-yyyy" + "(16-01-1998)" + else + "(01-16-1998)" + end + end + + def default_attrs + parent_attrs = super + parent_attrs.merge( + placeholder: @placeholder, + data: parent_attrs[:data].merge( + controller: "ruby-ui--calendar-input", + ruby_ui__calendar_input_format_value: @format, + ruby_ui__calendar_input_placeholder_value: @placeholder, + ruby_ui__calendar_input_label_animation_value: @label_animation, + ruby_ui__calendar_input_label_classes_value: @label_classes, + ruby_ui__calendar_input_label_separator_value: @label_separator, + ruby_ui__calendar_input_ruby_ui__calendar_outlet: @calendar_id + ) + ) + end + end +end diff --git a/app/javascript/controllers/ruby_ui/calendar_input_controller.js b/app/javascript/controllers/ruby_ui/calendar_input_controller.js index 17b48adc..80550c3b 100644 --- a/app/javascript/controllers/ruby_ui/calendar_input_controller.js +++ b/app/javascript/controllers/ruby_ui/calendar_input_controller.js @@ -1,8 +1,170 @@ import { Controller } from "@hotwired/stimulus" -// Connects to data-controller="input" +// Connects to data-controller="ruby-ui--calendar-input" export default class extends Controller { + static outlets = ["ruby-ui--calendar"] + static values = { + format: { type: String, default: "MM-dd-yyyy" }, + placeholder: { type: String, default: "" }, + labelAnimation: { type: Boolean, default: false }, + labelClasses: { type: String, default: "" }, + labelSeparator: { type: String, default: "" } + } + + connect() { + this.isProgrammaticUpdate = false + this.cacheLabel() + if (!this.element.placeholder && this.placeholderValue) this.element.placeholder = this.placeholderValue + this.addEventListeners() + } + + disconnect() { + this.removeEventListeners() + } + + addEventListeners() { + this.boundHandleInput = this.handleInput.bind(this) + this.element.addEventListener("input", this.boundHandleInput) + } + + removeEventListeners() { + if (this.boundHandleInput) { + this.element.removeEventListener("input", this.boundHandleInput) + } + } + + handleInput(event) { + if (this.isProgrammaticUpdate) return + const value = event.target.value + if (this.matchesFormat(value)) { + const oldValue = this.element.value + this.syncCalendar(value) + this.dispatchDateChange(oldValue, value) + } + if (this.labelEl) this.updateFloatingLabel(value) + } + + syncCalendar(inputValue) { + if (this.rubyUiCalendarOutlets.length === 0) return + if (!this.matchesFormat(inputValue)) return + const date = this.parseDate(inputValue) + if (!this.isValidDate(date)) return + + const iso = this.toISOString(inputValue) + this.rubyUiCalendarOutlets.forEach((outlet) => { + if (outlet.selectedDateValue === iso) return + outlet.selectedDateValue = iso + }) + } + + matchesFormat(value) { + return /^\d{2}-\d{2}-\d{4}$/.test(value) + } + + parseDate(value) { + const m = value.match(/^(\d{2})-(\d{2})-(\d{4})$/) + if (!m) return null + + const [part1, part2, yearStr] = m.slice(1) + const year = parseInt(yearStr, 10) + const isAmericanFormat = this.formatValue === "MM-dd-yyyy" + const month = parseInt(isAmericanFormat ? part1 : part2, 10) + const day = parseInt(isAmericanFormat ? part2 : part1, 10) + + if (!this.#validateDateComponents(month, day, year)) return null + + return new Date(year, month - 1, day) + } + + #validateDateComponents(month, day, year) { + if (month < 1 || month > 12 || day < 1 || day > 31) return false + if (day > new Date(year, month, 0).getDate()) return false + return true + } + + toISOString(value) { + const m = value.match(/^(\d{2})-(\d{2})-(\d{4})$/) + if (!m) return null + + const part1 = m[1] + const part2 = m[2] + const year = m[3] + + if (this.formatValue === "MM-dd-yyyy") { + return `${year}-${part1}-${part2}` + } else { + return `${year}-${part2}-${part1}` + } + } + + isValidDate(date) { + return date instanceof Date && !isNaN(date.getTime()) + } + + cacheLabel() { + this.labelEl = this.element.previousElementSibling + if (this.labelEl?.tagName === "LABEL") { + const separator = this.labelSeparatorValue || " " + this.labelBaseText = this.labelEl.textContent.split(separator)[0].trim() + } else { + this.labelBaseText = null + } + } + + updateFloatingLabel(inputValue) { + if (!this.labelEl || !this.labelBaseText) return + if (!this.labelAnimationValue) return + + if (inputValue && inputValue.length > 0) { + const separator = this.labelSeparatorValue || " " + this.labelEl.textContent = `${this.labelBaseText}${separator}${this.placeholderValue}` + this.addLabelClasses() + } else { + this.labelEl.textContent = this.labelBaseText + this.removeLabelClasses() + } + } + + addLabelClasses() { + if (!this.labelClassesValue) return + this.labelEl.classList.add(...this.labelClassesValue.split(' ')) + } + + removeLabelClasses() { + if (!this.labelClassesValue) return + this.labelEl.classList.remove(...this.labelClassesValue.split(' ')) + } + setValue(value) { - this.element.value = value + this.isProgrammaticUpdate = true + const formattedValue = (this.formatValue && this.formatValue !== "MM-dd-yyyy") ? this.formatDateForInput(value) : value + this.element.value = formattedValue + if (this.labelEl) this.updateFloatingLabel(formattedValue) + requestAnimationFrame(() => { this.isProgrammaticUpdate = false }) + } + + formatDateForInput(dateString) { + const match = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!match) return dateString + + const [, year, month, day] = match + + if (this.formatValue === "MM-dd-yyyy") { + return `${month}-${day}-${year}` + } else { + return `${day}-${month}-${year}` + } + } + + dispatchDateChange(oldValue, newValue) { + if (oldValue === newValue) return + + const event = new CustomEvent("date:changed", { + detail: { oldValue, newValue, format: this.formatValue }, + bubbles: true, + cancelable: true, + }) + + this.element.dispatchEvent(event) } } diff --git a/app/views/docs/date_picker.rb b/app/views/docs/date_picker.rb index c50ece0c..71419abe 100644 --- a/app/views/docs/date_picker.rb +++ b/app/views/docs/date_picker.rb @@ -27,6 +27,30 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Single date with format", context: self) do + <<~RUBY + div(class: 'space-y-4 w-[260px]') do + div(class: 'grid w-full max-w-sm items-center gap-1.5') do + label(for: "date-format-american") { "Select a date" } + CalendarInput(calendar_id: '#calendar-format-american',format: 'MM-dd-yyyy',id: 'date-format-american',class: 'rounded-md border shadow') + end + Calendar(id: 'calendar-format-american', input_id: '#date-format-american', date_format: 'MM-dd-yyyy', class: 'rounded-md border shadow') + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Single date with format (European)", context: self) do + <<~RUBY + div(class: 'space-y-4 w-[260px]') do + div(class: 'grid w-full max-w-sm items-center gap-1.5') do + label(for: "date-format-european") { "Select a date" } + CalendarInput(calendar_id: '#calendar-format-european',format: 'dd-MM-yyyy', id: 'date-format-european', class: 'rounded-md border shadow') + end + Calendar(id: 'calendar-format-european', input_id: '#date-format-european', date_format: 'dd-MM-yyyy', class: 'rounded-md border shadow') + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component))