Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/components/ruby_ui/calendar_input/calendar_input.rb
Original file line number Diff line number Diff line change
@@ -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
166 changes: 164 additions & 2 deletions app/javascript/controllers/ruby_ui/calendar_input_controller.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 24 additions & 0 deletions app/views/docs/date_picker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down