Skip to content

Commit e1f760a

Browse files
authored
Merge branch 'main' into feat/bottom-sheet
2 parents 467c3a9 + 6074d4c commit e1f760a

4 files changed

Lines changed: 104 additions & 18 deletions

File tree

.changeset/lemon-crabs-chew.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zag-js/date-picker": patch
3+
---
4+
5+
Fix issue where datepicker doesn't revert to a valid value when the input value exceeds the min/max and blurred

e2e/date-picker.e2e.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,55 @@ test.describe("datepicker [single]", () => {
145145
// test("renders previous month when the left arrow is clicked", async ({ page }) => {})
146146
// test("renders next month when the right arrow is clicked", async ({ page }) => {})
147147
})
148+
149+
test.describe("datepicker [min-max]", () => {
150+
test.beforeEach(async ({ page }) => {
151+
I = new DatePickerModel(page)
152+
await I.goto("/date-picker-min-max")
153+
})
154+
155+
test("constrains date to max value on blur when out-of-range date entered", async () => {
156+
// Min: 2025-07-01, Max: 2025-09-30
157+
// Enter a date after max (2025-10-15)
158+
await I.type("10/15/2025")
159+
await I.clickOutsideToBlur() // Use more reliable blur method
160+
await I.seeInputHasValue("09/30/2025") // Should be constrained to max
161+
await I.seeSelectedValue("09/30/2025")
162+
})
163+
164+
test("constrains date to min value on blur when out-of-range date entered", async () => {
165+
// Min: 2025-07-01, Max: 2025-09-30
166+
// Enter a date before min (2025-05-15)
167+
await I.type("05/15/2025")
168+
await I.clickOutsideToBlur() // Use more reliable blur method
169+
await I.seeInputHasValue("07/01/2025") // Should be constrained to min
170+
await I.seeSelectedValue("07/01/2025")
171+
})
172+
173+
test("constrains date to max value on Enter when out-of-range date entered", async () => {
174+
// Min: 2025-07-01, Max: 2025-09-30
175+
// Enter a date after max (2025-11-20)
176+
await I.type("11/20/2025")
177+
await I.pressKey("Enter")
178+
await I.seeInputHasValue("09/30/2025") // Should be constrained to max
179+
await I.seeSelectedValue("09/30/2025")
180+
})
181+
182+
test("constrains date to min value on Enter when out-of-range date entered", async () => {
183+
// Min: 2025-07-01, Max: 2025-09-30
184+
// Enter a date before min (2025-04-10)
185+
await I.type("04/10/2025")
186+
await I.pressKey("Enter")
187+
await I.seeInputHasValue("07/01/2025") // Should be constrained to min
188+
await I.seeSelectedValue("07/01/2025")
189+
})
190+
191+
test("allows valid dates within range", async () => {
192+
// Min: 2025-07-01, Max: 2025-09-30
193+
// Enter a valid date within range (2025-08-15)
194+
await I.type("08/15/2025")
195+
await I.pressKey("Enter")
196+
await I.seeInputHasValue("08/15/2025") // Should remain unchanged
197+
await I.seeSelectedValue("08/15/2025")
198+
})
199+
})

e2e/models/datepicker.model.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ export class DatePickerModel extends Model {
103103
return this.page.locator(`${part("table-cell-trigger")}[data-view=day][data-value="${end.toString()}"]`)
104104
}
105105

106+
private getViewCell(view: "day" | "month" | "year", value: string | number) {
107+
return this.page
108+
.locator(`[data-scope=date-picker][data-part=table-cell-trigger][data-view=${view}]`)
109+
.filter({ hasText: value.toString() })
110+
.first()
111+
}
112+
106113
getNextDayCell(opts: DayCellOptions = {}) {
107114
const { current, step = 1 } = opts
108115
const now = current ? parseDate(current) : this.today()
@@ -118,17 +125,15 @@ export class DatePickerModel extends Model {
118125
}
119126

120127
getDayCell(value: string | number) {
121-
const date = this.today().set({ day: +value }).toDate(getLocalTimeZone())
122-
const v = date.toISOString().split("T")[0]
123-
return this.page.locator(`${part("table-cell-trigger")}[data-view=day][data-value="${v}"]`)
128+
return this.getViewCell("day", value)
124129
}
125130

126131
getMonthCell(value: string | number) {
127-
return this.page.locator(`${part("table-cell-trigger")}[data-view=month][data-value="${value}"]`)
132+
return this.getViewCell("month", value)
128133
}
129134

130135
getYearCell(value: string | number) {
131-
return this.page.locator(`${part("table-cell-trigger")}[data-view=year][data-value="${value}"]`)
136+
return this.getViewCell("year", value)
132137
}
133138

134139
clickTrigger() {
@@ -165,6 +170,28 @@ export class DatePickerModel extends Model {
165170
return this.getDayCell(value).click()
166171
}
167172

173+
selectMonth(value: string) {
174+
return this.page.selectOption("[data-scope=date-picker][data-part=month-select]", value)
175+
}
176+
177+
async waitForCalendarRender() {
178+
// Wait for calendar table to be visible and stable
179+
await this.table.waitFor({ state: "visible" })
180+
await this.page.waitForTimeout(100) // Small delay for rendering
181+
}
182+
183+
getCurrentMonthDayCells() {
184+
// Only get cells that belong to the current month (not previous/next month spillover)
185+
return this.page.locator(
186+
"[data-scope=date-picker][data-part=table-cell-trigger][data-view=day]:not([data-outside-range])",
187+
)
188+
}
189+
190+
async clickOutsideToBlur() {
191+
// Click on a safe area to blur the input
192+
await this.page.locator("main").click({ position: { x: 10, y: 10 } })
193+
}
194+
168195
seeTodayCellIsFocused() {
169196
return expect(this.todayCell).toBeFocused()
170197
}

packages/machines/date-picker/src/date-picker.machine.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { DateFormatter } from "@internationalized/date"
2-
import { createGuards, createMachine, type Params } from "@zag-js/core"
2+
import { createGuards, createMachine, type Params, type PropFn } from "@zag-js/core"
33
import {
44
alignDate,
55
constrainValue,
@@ -48,6 +48,10 @@ function isDateArrayEqual(a: DateValue[], b: DateValue[] | undefined) {
4848
return true
4949
}
5050

51+
function getValueAsString(value: DateValue[], prop: PropFn<DatePickerSchema>) {
52+
return value.map((date) => prop("format")(date, { locale: prop("locale"), timeZone: prop("timeZone") }))
53+
}
54+
5155
export const machine = createMachine<DatePickerSchema>({
5256
props({ props }) {
5357
const locale = props.locale || "en-US"
@@ -125,9 +129,7 @@ export const machine = createMachine<DatePickerSchema>({
125129
const context = getContext()
126130
const view = context.get("view")
127131
const value = context.get("value")
128-
const valueAsString = value.map((date) =>
129-
prop("format")(date, { locale: prop("locale"), timeZone: prop("timeZone") }),
130-
)
132+
const valueAsString = getValueAsString(value, prop)
131133
prop("onFocusChange")?.({ value, valueAsString, view, focusedValue })
132134
},
133135
})),
@@ -138,9 +140,7 @@ export const machine = createMachine<DatePickerSchema>({
138140
hash: (v) => v.map((date) => date.toString()).join(","),
139141
onChange(value) {
140142
const context = getContext()
141-
const valueAsString = value.map((date) =>
142-
prop("format")(date, { locale: prop("locale"), timeZone: prop("timeZone") }),
143-
)
143+
const valueAsString = getValueAsString(value, prop)
144144
prop("onValueChange")?.({ value, valueAsString, view: context.get("view") })
145145
},
146146
})),
@@ -196,10 +196,7 @@ export const machine = createMachine<DatePickerSchema>({
196196
!isPreviousRangeInvalid(context.get("startValue"), prop("min"), prop("max")),
197197
isNextVisibleRangeValid: ({ prop, computed }) =>
198198
!isNextRangeInvalid(computed("endValue"), prop("min"), prop("max")),
199-
valueAsString({ context, prop }) {
200-
const value = context.get("value")
201-
return value.map((date) => prop("format")(date, { locale: prop("locale"), timeZone: prop("timeZone") }))
202-
},
199+
valueAsString: ({ context, prop }) => getValueAsString(context.get("value"), prop),
203200
},
204201

205202
effects: ["setupLiveRegion"],
@@ -1074,7 +1071,7 @@ export const machine = createMachine<DatePickerSchema>({
10741071
setFocusedValue(params, date)
10751072
},
10761073

1077-
selectParsedDate({ context, event, computed, prop }) {
1074+
selectParsedDate({ context, event, prop }) {
10781075
if (event.index == null) return
10791076

10801077
const parse = prop("parse")
@@ -1089,13 +1086,18 @@ export const machine = createMachine<DatePickerSchema>({
10891086

10901087
if (!date) return
10911088

1089+
// constrain date to min/max range
1090+
date = constrainValue(date, prop("min"), prop("max"))
1091+
10921092
const values = Array.from(context.get("value"))
10931093
values[event.index] = date
10941094

10951095
context.set("value", values)
1096+
10961097
// always sync the input value, even if the selecteddate is not changed
10971098
// e.g. selected value is 02/28/2024, and the input value changed to 02/28
1098-
context.set("inputValue", computed("valueAsString")[event.index])
1099+
const valueAsString = getValueAsString(values, prop)
1100+
context.set("inputValue", valueAsString[event.index])
10991101
},
11001102

11011103
resetView({ context }) {

0 commit comments

Comments
 (0)