Skip to content

Commit 78a9a6f

Browse files
committed
fix: datepicker nullish values
1 parent f3d2626 commit 78a9a6f

File tree

8 files changed

+231
-12
lines changed

8 files changed

+231
-12
lines changed

e2e/date-picker.e2e.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,20 @@ test.describe("datepicker [range]", () => {
265265
// Should not crash and the value should still be set
266266
await I.seeInputHasValue("06/15/2024", 1)
267267
})
268+
269+
test("should not crash when changing end date after typing end date first", async () => {
270+
// Regression test for issue #2864
271+
// 1. Type a valid end date first (e.g. 06/15/2024)
272+
await I.focusInput(1)
273+
await I.type("06/15/2024", 1)
274+
await I.clickOutsideToBlur()
275+
276+
// 2. Change the end date by typing a new value (e.g., 06/15/2025)
277+
await I.focusInput(1)
278+
await I.type("06/15/2025", 1)
279+
await I.clickOutsideToBlur()
280+
281+
// Should not crash and the new value should be set
282+
await I.seeInputHasValue("06/15/2025", 1)
283+
})
268284
})

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export const machine = createMachine<DatePickerSchema>({
156156
})),
157157
hoveredValue: bindable<DateValue | null>(() => ({
158158
defaultValue: null,
159-
isEqual: (a, b) => b !== null && a !== null && isDateEqual(a, b),
159+
isEqual: isDateEqual,
160160
})),
161161
view: bindable(() => ({
162162
defaultValue: prop("defaultView"),
@@ -739,10 +739,21 @@ export const machine = createMachine<DatePickerSchema>({
739739
context.set("restoreFocus", true)
740740
},
741741
announceValueText({ context, prop, refs }) {
742-
const announceText = context
743-
.get("value")
744-
.map((date) => formatSelectedDate(date, null, prop("locale"), prop("timeZone")))
745-
refs.get("announcer")?.announce(announceText.join(","), 3000)
742+
const value = context.get("value")
743+
const locale = prop("locale")
744+
const timeZone = prop("timeZone")
745+
746+
let announceText: string
747+
if (prop("selectionMode") === "range") {
748+
announceText = formatSelectedDate(value[0], value[1], locale, timeZone)
749+
} else {
750+
announceText = value
751+
.map((date) => formatSelectedDate(date, null, locale, timeZone))
752+
.filter(Boolean)
753+
.join(",")
754+
}
755+
756+
refs.get("announcer")?.announce(announceText, 3000)
746757
},
747758
announceVisibleRange({ computed, refs }) {
748759
const { formatted } = computed("visibleRangeText")

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DateFormatter, type DateValue } from "@internationalized/date"
22
import { clampValue, match } from "@zag-js/utils"
33
import type { DateView, IntlTranslations } from "./date-picker.types"
44

5-
export function adjustStartAndEndDate(value: DateValue[]) {
5+
export function adjustStartAndEndDate(value: Array<DateValue | null | undefined>) {
66
const [startDate, endDate] = value
77
if (!startDate || !endDate) return value
88
return startDate.compare(endDate) <= 0 ? value : [endDate, startDate]
@@ -14,8 +14,11 @@ export function isDateWithinRange(date: DateValue, value: (DateValue | null)[])
1414
return startDate.compare(date) <= 0 && endDate.compare(date) >= 0
1515
}
1616

17-
export function sortDates(values: DateValue[]) {
18-
return values.slice().sort((a, b) => a.compare(b))
17+
export function sortDates(values: Array<DateValue | null | undefined>) {
18+
return values
19+
.slice()
20+
.filter((date): date is DateValue => date != null)
21+
.sort((a, b) => a.compare(b))
1922
}
2023

2124
export function getRoleDescription(view: DateView) {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { parseDate } from "@internationalized/date"
2+
import { describe, expect, test } from "vitest"
3+
import { adjustStartAndEndDate, isDateWithinRange, sortDates } from "../src/date-picker.utils"
4+
5+
describe("DatePicker Utils", () => {
6+
describe("sortDates", () => {
7+
test("should sort dates in ascending order", () => {
8+
const date1 = parseDate("2024-01-15")
9+
const date2 = parseDate("2024-01-20")
10+
const date3 = parseDate("2024-01-10")
11+
const values = [date1, date2, date3]
12+
13+
const sorted = sortDates(values)
14+
expect(sorted[0]).toEqual(date3)
15+
expect(sorted[1]).toEqual(date1)
16+
expect(sorted[2]).toEqual(date2)
17+
})
18+
19+
test("should filter out null values and sort remaining dates", () => {
20+
const date1 = parseDate("2024-01-15")
21+
const date2 = parseDate("2024-01-20")
22+
const values = [date1, null, date2, null]
23+
24+
const sorted = sortDates(values)
25+
expect(sorted).toHaveLength(2)
26+
expect(sorted[0]).toEqual(date1)
27+
expect(sorted[1]).toEqual(date2)
28+
})
29+
30+
test("should return empty array when all values are null", () => {
31+
const values = [null, null, null]
32+
const sorted = sortDates(values)
33+
expect(sorted).toEqual([])
34+
})
35+
36+
test("should filter out undefined values and sort remaining dates", () => {
37+
const date1 = parseDate("2024-01-15")
38+
const date2 = parseDate("2024-01-20")
39+
const values = [date1, undefined, date2, undefined]
40+
41+
const sorted = sortDates(values)
42+
expect(sorted).toHaveLength(2)
43+
expect(sorted[0]).toEqual(date1)
44+
expect(sorted[1]).toEqual(date2)
45+
})
46+
47+
test("should not mutate the original array", () => {
48+
const date1 = parseDate("2024-01-15")
49+
const date2 = parseDate("2024-01-20")
50+
const values = [date2, date1]
51+
const original = [...values]
52+
53+
sortDates(values)
54+
expect(values).toEqual(original)
55+
})
56+
})
57+
58+
describe("adjustStartAndEndDate", () => {
59+
test("should return value as-is when dates are already in order", () => {
60+
const startDate = parseDate("2024-01-10")
61+
const endDate = parseDate("2024-01-20")
62+
const value = [startDate, endDate]
63+
64+
const result = adjustStartAndEndDate(value)
65+
expect(result).toEqual([startDate, endDate])
66+
})
67+
68+
test("should swap dates when end date is before start date", () => {
69+
const startDate = parseDate("2024-01-20")
70+
const endDate = parseDate("2024-01-10")
71+
const value = [startDate, endDate]
72+
73+
const result = adjustStartAndEndDate(value)
74+
expect(result).toEqual([endDate, startDate])
75+
})
76+
77+
test("should return value as-is when start date is null", () => {
78+
const endDate = parseDate("2024-01-20")
79+
const value = [null, endDate]
80+
81+
const result = adjustStartAndEndDate(value)
82+
expect(result).toEqual([null, endDate])
83+
})
84+
85+
test("should return value as-is when end date is null", () => {
86+
const startDate = parseDate("2024-01-10")
87+
const value = [startDate, null]
88+
89+
const result = adjustStartAndEndDate(value)
90+
expect(result).toEqual([startDate, null])
91+
})
92+
93+
test("should return value as-is when both dates are null", () => {
94+
const value = [null, null]
95+
const result = adjustStartAndEndDate(value)
96+
expect(result).toEqual([null, null])
97+
})
98+
})
99+
100+
describe("isDateWithinRange", () => {
101+
test("should return true when date is within range", () => {
102+
const startDate = parseDate("2024-01-10")
103+
const endDate = parseDate("2024-01-20")
104+
const date = parseDate("2024-01-15")
105+
const value = [startDate, endDate]
106+
107+
expect(isDateWithinRange(date, value)).toBe(true)
108+
})
109+
110+
test("should return false when date is before start date", () => {
111+
const startDate = parseDate("2024-01-10")
112+
const endDate = parseDate("2024-01-20")
113+
const date = parseDate("2024-01-05")
114+
const value = [startDate, endDate]
115+
116+
expect(isDateWithinRange(date, value)).toBe(false)
117+
})
118+
119+
test("should return false when date is after end date", () => {
120+
const startDate = parseDate("2024-01-10")
121+
const endDate = parseDate("2024-01-20")
122+
const date = parseDate("2024-01-25")
123+
const value = [startDate, endDate]
124+
125+
expect(isDateWithinRange(date, value)).toBe(false)
126+
})
127+
128+
test("should return false when start date is null", () => {
129+
const endDate = parseDate("2024-01-20")
130+
const date = parseDate("2024-01-15")
131+
const value = [null, endDate]
132+
133+
expect(isDateWithinRange(date, value)).toBe(false)
134+
})
135+
136+
test("should return false when end date is null", () => {
137+
const startDate = parseDate("2024-01-10")
138+
const date = parseDate("2024-01-15")
139+
const value = [startDate, null]
140+
141+
expect(isDateWithinRange(date, value)).toBe(false)
142+
})
143+
144+
test("should return false when both dates are null", () => {
145+
const date = parseDate("2024-01-15")
146+
const value = [null, null]
147+
148+
expect(isDateWithinRange(date, value)).toBe(false)
149+
})
150+
})
151+
})

packages/utilities/date-utils/src/assertion.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { type DateValue, isSameDay } from "@internationalized/date"
22
import type { DateAvailableFn } from "./types"
33

4-
export function isDateEqual(dateA: DateValue, dateB?: DateValue | null) {
5-
return dateB != null && isSameDay(dateA, dateB)
4+
export function isDateEqual(dateA: DateValue | null | undefined, dateB?: DateValue | null) {
5+
if (dateA == null || dateB == null) return dateA === dateB
6+
return isSameDay(dateA, dateB)
67
}
78

89
export function isDateUnavailable(

packages/utilities/date-utils/src/format.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ export function formatRange(
3131
return toString(start, end)
3232
}
3333

34-
export function formatSelectedDate(startDate: DateValue, endDate: DateValue | null, locale: string, timeZone: string) {
34+
export function formatSelectedDate(
35+
startDate: DateValue | null | undefined,
36+
endDate: DateValue | null,
37+
locale: string,
38+
timeZone: string,
39+
) {
40+
if (!startDate) return ""
3541
let start = startDate
3642
let end = endDate ?? startDate
3743
let formatter = getDayFormatter(locale, timeZone)

packages/utilities/date-utils/tests/assertion.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,36 @@ describe("Date utilities / Assertion", () => {
1515
expect(isDateEqual(dateA, dateB)).toBe(false)
1616
})
1717

18-
test("isEqual / nullish", () => {
18+
test("isEqual / both null", () => {
19+
expect(isDateEqual(null, null)).toBe(true)
20+
})
21+
22+
test("isEqual / both undefined", () => {
23+
expect(isDateEqual(undefined, undefined)).toBe(true)
24+
})
25+
26+
test("isEqual / null and undefined", () => {
27+
expect(isDateEqual(null, undefined)).toBe(false)
28+
expect(isDateEqual(undefined, null)).toBe(false)
29+
})
30+
31+
test("isEqual / dateA null, dateB valid", () => {
32+
const dateB = parseDate("2024-04-15")
33+
expect(isDateEqual(null, dateB)).toBe(false)
34+
})
35+
36+
test("isEqual / dateA valid, dateB null", () => {
1937
const dateA = parseDate("2024-04-15")
2038
expect(isDateEqual(dateA, null)).toBe(false)
2139
})
40+
41+
test("isEqual / dateA undefined, dateB valid", () => {
42+
const dateB = parseDate("2024-04-15")
43+
expect(isDateEqual(undefined, dateB)).toBe(false)
44+
})
45+
46+
test("isEqual / dateA valid, dateB undefined", () => {
47+
const dateA = parseDate("2024-04-15")
48+
expect(isDateEqual(dateA, undefined)).toBe(false)
49+
})
2250
})

packages/utilities/date-utils/tests/format.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ test("formatSelectedDate / selected date", () => {
1414
expect(formatSelectedDate(startDate, endDate, locale, timeZone)).toMatchInlineSnapshot(
1515
`"Tuesday, January 10 – Thursday, January 12, 2023"`,
1616
)
17+
18+
expect(formatSelectedDate(null, null, locale, timeZone)).toBe("")
19+
expect(formatSelectedDate(undefined, null, locale, timeZone)).toBe("")
1720
})
1821

1922
test("formatVisibleRange / visible range", () => {

0 commit comments

Comments
 (0)