Skip to content
This repository was archived by the owner on May 11, 2026. It is now read-only.

Commit 722dc3b

Browse files
add variant date picker
1 parent 4a044d5 commit 722dc3b

3 files changed

Lines changed: 236 additions & 2 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CalendarInput < Input
5+
def initialize(calendar_id: nil, format: "MM-dd-yyyy", placeholder: nil, label_animation: true, label_classes: nil, label_separator: " - ex: ", **attrs)
6+
@calendar_id = calendar_id
7+
@format = format
8+
@placeholder = placeholder || default_placeholder_for_format(format)
9+
@label_animation = label_animation
10+
@label_classes = label_classes || "text-xs text-gray-500 -mt-2 transition-all duration-200"
11+
@label_separator = label_separator
12+
super(type: :string, **attrs)
13+
end
14+
15+
def view_template
16+
input(**attrs)
17+
end
18+
19+
private
20+
21+
def default_placeholder_for_format(format)
22+
case format
23+
when "MM-dd-yyyy"
24+
"(01-16-1998)"
25+
when "dd-MM-yyyy"
26+
"(16-01-1998)"
27+
else
28+
"(01-16-1998)"
29+
end
30+
end
31+
32+
def default_attrs
33+
parent_attrs = super
34+
parent_attrs.merge(
35+
placeholder: @placeholder,
36+
data: parent_attrs[:data].merge(
37+
controller: "ruby-ui--calendar-input",
38+
ruby_ui__calendar_input_format_value: @format,
39+
ruby_ui__calendar_input_placeholder_value: @placeholder,
40+
ruby_ui__calendar_input_label_animation_value: @label_animation,
41+
ruby_ui__calendar_input_label_classes_value: @label_classes,
42+
ruby_ui__calendar_input_label_separator_value: @label_separator,
43+
ruby_ui__calendar_input_ruby_ui__calendar_outlet: @calendar_id
44+
)
45+
)
46+
end
47+
end
48+
end
Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,170 @@
11
import { Controller } from "@hotwired/stimulus"
22

3-
// Connects to data-controller="input"
3+
// Connects to data-controller="ruby-ui--calendar-input"
44
export default class extends Controller {
5+
static outlets = ["ruby-ui--calendar"]
6+
static values = {
7+
format: { type: String, default: "MM-dd-yyyy" },
8+
placeholder: { type: String, default: "" },
9+
labelAnimation: { type: Boolean, default: false },
10+
labelClasses: { type: String, default: "" },
11+
labelSeparator: { type: String, default: "" }
12+
}
13+
14+
connect() {
15+
this.isProgrammaticUpdate = false
16+
this.cacheLabel()
17+
if (!this.element.placeholder && this.placeholderValue) this.element.placeholder = this.placeholderValue
18+
this.addEventListeners()
19+
}
20+
21+
disconnect() {
22+
this.removeEventListeners()
23+
}
24+
25+
addEventListeners() {
26+
this.boundHandleInput = this.handleInput.bind(this)
27+
this.element.addEventListener("input", this.boundHandleInput)
28+
}
29+
30+
removeEventListeners() {
31+
if (this.boundHandleInput) {
32+
this.element.removeEventListener("input", this.boundHandleInput)
33+
}
34+
}
35+
36+
handleInput(event) {
37+
if (this.isProgrammaticUpdate) return
38+
const value = event.target.value
39+
if (this.matchesFormat(value)) {
40+
const oldValue = this.element.value
41+
this.syncCalendar(value)
42+
this.dispatchDateChange(oldValue, value)
43+
}
44+
if (this.labelEl) this.updateFloatingLabel(value)
45+
}
46+
47+
syncCalendar(inputValue) {
48+
if (this.rubyUiCalendarOutlets.length === 0) return
49+
if (!this.matchesFormat(inputValue)) return
50+
const date = this.parseDate(inputValue)
51+
if (!this.isValidDate(date)) return
52+
53+
const iso = this.toISOString(inputValue)
54+
this.rubyUiCalendarOutlets.forEach((outlet) => {
55+
if (outlet.selectedDateValue === iso) return
56+
outlet.selectedDateValue = iso
57+
})
58+
}
59+
60+
matchesFormat(value) {
61+
return /^\d{2}-\d{2}-\d{4}$/.test(value)
62+
}
63+
64+
parseDate(value) {
65+
const m = value.match(/^(\d{2})-(\d{2})-(\d{4})$/)
66+
if (!m) return null
67+
68+
const [part1, part2, yearStr] = m.slice(1)
69+
const year = parseInt(yearStr, 10)
70+
const isAmericanFormat = this.formatValue === "MM-dd-yyyy"
71+
const month = parseInt(isAmericanFormat ? part1 : part2, 10)
72+
const day = parseInt(isAmericanFormat ? part2 : part1, 10)
73+
74+
if (!this.#validateDateComponents(month, day, year)) return null
75+
76+
return new Date(year, month - 1, day)
77+
}
78+
79+
#validateDateComponents(month, day, year) {
80+
if (month < 1 || month > 12 || day < 1 || day > 31) return false
81+
if (day > new Date(year, month, 0).getDate()) return false
82+
return true
83+
}
84+
85+
toISOString(value) {
86+
const m = value.match(/^(\d{2})-(\d{2})-(\d{4})$/)
87+
if (!m) return null
88+
89+
const part1 = m[1]
90+
const part2 = m[2]
91+
const year = m[3]
92+
93+
if (this.formatValue === "MM-dd-yyyy") {
94+
return `${year}-${part1}-${part2}`
95+
} else {
96+
return `${year}-${part2}-${part1}`
97+
}
98+
}
99+
100+
isValidDate(date) {
101+
return date instanceof Date && !isNaN(date.getTime())
102+
}
103+
104+
cacheLabel() {
105+
this.labelEl = this.element.previousElementSibling
106+
if (this.labelEl?.tagName === "LABEL") {
107+
const separator = this.labelSeparatorValue || " "
108+
this.labelBaseText = this.labelEl.textContent.split(separator)[0].trim()
109+
} else {
110+
this.labelBaseText = null
111+
}
112+
}
113+
114+
updateFloatingLabel(inputValue) {
115+
if (!this.labelEl || !this.labelBaseText) return
116+
if (!this.labelAnimationValue) return
117+
118+
if (inputValue && inputValue.length > 0) {
119+
const separator = this.labelSeparatorValue || " "
120+
this.labelEl.textContent = `${this.labelBaseText}${separator}${this.placeholderValue}`
121+
this.addLabelClasses()
122+
} else {
123+
this.labelEl.textContent = this.labelBaseText
124+
this.removeLabelClasses()
125+
}
126+
}
127+
128+
addLabelClasses() {
129+
if (!this.labelClassesValue) return
130+
this.labelEl.classList.add(...this.labelClassesValue.split(' '))
131+
}
132+
133+
removeLabelClasses() {
134+
if (!this.labelClassesValue) return
135+
this.labelEl.classList.remove(...this.labelClassesValue.split(' '))
136+
}
137+
5138
setValue(value) {
6-
this.element.value = value
139+
this.isProgrammaticUpdate = true
140+
const formattedValue = (this.formatValue && this.formatValue !== "MM-dd-yyyy") ? this.formatDateForInput(value) : value
141+
this.element.value = formattedValue
142+
if (this.labelEl) this.updateFloatingLabel(formattedValue)
143+
requestAnimationFrame(() => { this.isProgrammaticUpdate = false })
144+
}
145+
146+
formatDateForInput(dateString) {
147+
const match = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/)
148+
if (!match) return dateString
149+
150+
const [, year, month, day] = match
151+
152+
if (this.formatValue === "MM-dd-yyyy") {
153+
return `${month}-${day}-${year}`
154+
} else {
155+
return `${day}-${month}-${year}`
156+
}
157+
}
158+
159+
dispatchDateChange(oldValue, newValue) {
160+
if (oldValue === newValue) return
161+
162+
const event = new CustomEvent("date:changed", {
163+
detail: { oldValue, newValue, format: this.formatValue },
164+
bubbles: true,
165+
cancelable: true,
166+
})
167+
168+
this.element.dispatchEvent(event)
7169
}
8170
}

app/views/docs/date_picker.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,30 @@ def view_template
2727
RUBY
2828
end
2929

30+
render Docs::VisualCodeExample.new(title: "Single date with format", context: self) do
31+
<<~RUBY
32+
div(class: 'space-y-4 w-[260px]') do
33+
div(class: 'grid w-full max-w-sm items-center gap-1.5') do
34+
label(for: "date-format-american") { "Select a date" }
35+
CalendarInput(calendar_id: '#calendar-format-american',format: 'MM-dd-yyyy',id: 'date-format-american',class: 'rounded-md border shadow')
36+
end
37+
Calendar(id: 'calendar-format-american', input_id: '#date-format-american', date_format: 'MM-dd-yyyy', class: 'rounded-md border shadow')
38+
end
39+
RUBY
40+
end
41+
42+
render Docs::VisualCodeExample.new(title: "Single date with format (European)", context: self) do
43+
<<~RUBY
44+
div(class: 'space-y-4 w-[260px]') do
45+
div(class: 'grid w-full max-w-sm items-center gap-1.5') do
46+
label(for: "date-format-european") { "Select a date" }
47+
CalendarInput(calendar_id: '#calendar-format-european',format: 'dd-MM-yyyy', id: 'date-format-european', class: 'rounded-md border shadow')
48+
end
49+
Calendar(id: 'calendar-format-european', input_id: '#date-format-european', date_format: 'dd-MM-yyyy', class: 'rounded-md border shadow')
50+
end
51+
RUBY
52+
end
53+
3054
render Components::ComponentSetup::Tabs.new(component_name: component)
3155

3256
render Docs::ComponentsTable.new(component_files(component))

0 commit comments

Comments
 (0)