Skip to content

Commit 4ac700a

Browse files
committed
feat(date-picker): add non-Gregorian calendar support
1 parent d99e182 commit 4ac700a

15 files changed

Lines changed: 354 additions & 39 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@zag-js/date-picker": minor
3+
"@zag-js/date-utils": minor
4+
---
5+
6+
Add non-Gregorian calendar support via `createCalendar` prop
7+
8+
- Support Persian, Buddhist, Islamic, Hebrew, and other calendar systems
9+
- Month names, year ranges, formatters, and navigation now respect the active calendar
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as datePicker from "@zag-js/date-picker"
2+
import { normalizeProps, useMachine } from "@zag-js/react"
3+
import { useId } from "react"
4+
import { StateVisualizer } from "../components/state-visualizer"
5+
import { Toolbar } from "../components/toolbar"
6+
7+
import { PersianCalendar } from "@internationalized/date"
8+
9+
// Only bundle the Persian calendar — not all 13+ calendars.
10+
function createCalendar(identifier: string) {
11+
switch (identifier) {
12+
case "persian":
13+
return new PersianCalendar()
14+
default:
15+
throw new Error(`Unsupported calendar: ${identifier}`)
16+
}
17+
}
18+
19+
export default function Page() {
20+
const service = useMachine(datePicker.machine, {
21+
id: useId(),
22+
locale: "fa-IR",
23+
createCalendar,
24+
selectionMode: "single",
25+
})
26+
27+
const api = datePicker.connect(service, normalizeProps)
28+
29+
return (
30+
<>
31+
<main className="date-picker">
32+
<div>
33+
<button>Outside Element</button>
34+
</div>
35+
<p>{`Visible range: ${api.visibleRangeText.formatted}`}</p>
36+
37+
<output className="date-output">
38+
<div>Selected: {api.valueAsString ?? "-"}</div>
39+
<div>Focused: {api.focusedValueAsString}</div>
40+
</output>
41+
42+
<div {...api.getControlProps()}>
43+
<input {...api.getInputProps()} />
44+
<button {...api.getClearTriggerProps()}></button>
45+
<button {...api.getTriggerProps()}>🗓</button>
46+
</div>
47+
48+
<div {...api.getPositionerProps()}>
49+
<div {...api.getContentProps()}>
50+
<div style={{ marginBottom: "20px" }}>
51+
<select {...api.getMonthSelectProps()}>
52+
{api.getMonths().map((month, i) => (
53+
<option key={i} value={month.value} disabled={month.disabled}>
54+
{month.label}
55+
</option>
56+
))}
57+
</select>
58+
59+
<select {...api.getYearSelectProps()}>
60+
{api.getYears().map((year, i) => (
61+
<option key={i} value={year.value} disabled={year.disabled}>
62+
{year.label}
63+
</option>
64+
))}
65+
</select>
66+
</div>
67+
68+
<div hidden={api.view !== "day"}>
69+
<div {...api.getViewControlProps({ view: "year" })}>
70+
<button {...api.getPrevTriggerProps()}>Prev</button>
71+
<button {...api.getViewTriggerProps()}>{api.visibleRangeText.start}</button>
72+
<button {...api.getNextTriggerProps()}>Next</button>
73+
</div>
74+
75+
<table {...api.getTableProps({ view: "day" })}>
76+
<thead {...api.getTableHeaderProps({ view: "day" })}>
77+
<tr {...api.getTableRowProps({ view: "day" })}>
78+
{api.weekDays.map((day, i) => (
79+
<th scope="col" key={i} aria-label={day.long}>
80+
{day.narrow}
81+
</th>
82+
))}
83+
</tr>
84+
</thead>
85+
<tbody {...api.getTableBodyProps({ view: "day" })}>
86+
{api.weeks.map((week, i) => (
87+
<tr key={i} {...api.getTableRowProps({ view: "day" })}>
88+
{week.map((value, i) => (
89+
<td key={i} {...api.getDayTableCellProps({ value })}>
90+
<div {...api.getDayTableCellTriggerProps({ value })}>{value.day}</div>
91+
</td>
92+
))}
93+
</tr>
94+
))}
95+
</tbody>
96+
</table>
97+
</div>
98+
99+
<div style={{ display: "flex", gap: "40px" }}>
100+
<div hidden={api.view !== "month"} style={{ width: "100%" }}>
101+
<div {...api.getViewControlProps({ view: "month" })}>
102+
<button {...api.getPrevTriggerProps({ view: "month" })}>Prev</button>
103+
<button {...api.getViewTriggerProps({ view: "month" })}>{api.visibleRange.start.year}</button>
104+
<button {...api.getNextTriggerProps({ view: "month" })}>Next</button>
105+
</div>
106+
107+
<table {...api.getTableProps({ view: "month", columns: 4 })}>
108+
<tbody {...api.getTableBodyProps({ view: "month" })}>
109+
{api.getMonthsGrid({ columns: 4, format: "short" }).map((months, row) => (
110+
<tr key={row} {...api.getTableRowProps()}>
111+
{months.map((month, index) => (
112+
<td key={index} {...api.getMonthTableCellProps({ ...month, columns: 4 })}>
113+
<div {...api.getMonthTableCellTriggerProps({ ...month, columns: 4 })}>{month.label}</div>
114+
</td>
115+
))}
116+
</tr>
117+
))}
118+
</tbody>
119+
</table>
120+
</div>
121+
122+
<div hidden={api.view !== "year"} style={{ width: "100%" }}>
123+
<div {...api.getViewControlProps({ view: "year" })}>
124+
<button {...api.getPrevTriggerProps({ view: "year" })}>Prev</button>
125+
<span>
126+
{api.getDecade().start} - {api.getDecade().end}
127+
</span>
128+
<button {...api.getNextTriggerProps({ view: "year" })}>Next</button>
129+
</div>
130+
131+
<table {...api.getTableProps({ view: "year", columns: 4 })}>
132+
<tbody {...api.getTableBodyProps()}>
133+
{api.getYearsGrid({ columns: 4 }).map((years, row) => (
134+
<tr key={row} {...api.getTableRowProps({ view: "year" })}>
135+
{years.map((year, index) => (
136+
<td key={index} {...api.getYearTableCellProps({ ...year, columns: 4 })}>
137+
<div {...api.getYearTableCellTriggerProps({ ...year, columns: 4 })}>{year.label}</div>
138+
</td>
139+
))}
140+
</tr>
141+
))}
142+
</tbody>
143+
</table>
144+
</div>
145+
</div>
146+
</div>
147+
</div>
148+
</main>
149+
150+
<Toolbar viz>
151+
<StateVisualizer state={service} omit={["weeks"]} />
152+
</Toolbar>
153+
</>
154+
)
155+
}

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
getUnitDuration,
2222
getWeekDays,
2323
getWeekOfYear,
24+
getDefaultYearRange,
2425
getYearsRange,
2526
isDateEqual,
2627
isDateOutsideRange,
@@ -105,7 +106,7 @@ export function connect<T extends PropTypes>(
105106

106107
function getMonths(props: { format?: "short" | "long" | undefined } = {}) {
107108
const { format } = props
108-
return getMonthNames(locale, format).map((label, index) => {
109+
return getMonthNames(locale, format, focusedValue).map((label, index) => {
109110
const value = index + 1
110111
const dateValue = focusedValue.set({ month: value })
111112
const disabled = isDateOutsideRange(dateValue, min, max)
@@ -114,7 +115,8 @@ export function connect<T extends PropTypes>(
114115
}
115116

116117
function getYears() {
117-
const range = getYearsRange({ from: min?.year ?? 1900, to: max?.year ?? 2100 })
118+
const defaultRange = getDefaultYearRange(focusedValue, min, max)
119+
const range = getYearsRange(defaultRange)
118120
return range.map((year) => ({
119121
label: year.toString(),
120122
value: year,
@@ -127,12 +129,12 @@ export function connect<T extends PropTypes>(
127129
}
128130

129131
function focusMonth(month: number) {
130-
const date = startValue ?? getTodayDate(timeZone)
132+
const date = startValue ?? getTodayDate(timeZone, focusedValue.calendar)
131133
send({ type: "FOCUS.SET", value: date.set({ month }) })
132134
}
133135

134136
function focusYear(year: number) {
135-
const date = startValue ?? getTodayDate(timeZone)
137+
const date = startValue ?? getTodayDate(timeZone, focusedValue.calendar)
136138
send({ type: "FOCUS.SET", value: date.set({ year }) })
137139
}
138140

@@ -164,7 +166,7 @@ export function connect<T extends PropTypes>(
164166
function getMonthTableCellState(props: TableCellProps): TableCellState {
165167
const { value, disabled } = props
166168
const dateValue = focusedValue.set({ month: value })
167-
const formatter = getMonthFormatter(locale, timeZone)
169+
const formatter = getMonthFormatter(locale, timeZone, focusedValue)
168170
const cellState = {
169171
focused: focusedValue.month === props.value,
170172
selectable: !isDateOutsideRange(dateValue, min, max),
@@ -184,7 +186,7 @@ export function connect<T extends PropTypes>(
184186
function getDayTableCellState(props: DayTableCellProps): DayTableCellState {
185187
const { value, disabled, visibleRange = computed("visibleRange") } = props
186188

187-
const formatter = getDayFormatter(locale, timeZone)
189+
const formatter = getDayFormatter(locale, timeZone, focusedValue)
188190
const unitDuration = getUnitDuration(computed("visibleDuration"))
189191
const outsideDaySelectable = prop("outsideDaySelectable")
190192

@@ -274,7 +276,7 @@ export function connect<T extends PropTypes>(
274276
getOffset(duration) {
275277
const from = startValue.add(duration)
276278
const end = endValue.add(duration)
277-
const formatter = getMonthFormatter(locale, timeZone)
279+
const formatter = getMonthFormatter(locale, timeZone, focusedValue)
278280
return {
279281
visibleRange: { start: from, end },
280282
weeks: getMonthWeeks(from),
@@ -287,7 +289,7 @@ export function connect<T extends PropTypes>(
287289
getMonthWeeks,
288290
isUnavailable,
289291
weeks: getMonthWeeks(),
290-
weekDays: getWeekDays(getTodayDate(timeZone), startOfWeek, timeZone, locale),
292+
weekDays: getWeekDays(startValue, startOfWeek, timeZone, locale),
291293
visibleRangeText: computed("visibleRangeText"),
292294
value: selectedValue,
293295
valueAsDate: selectedValue.filter((date) => date != null).map((date) => date.toDate(timeZone)),
@@ -297,7 +299,7 @@ export function connect<T extends PropTypes>(
297299
focusedValueAsString: prop("format")(focusedValue, { locale, timeZone }),
298300
visibleRange: computed("visibleRange"),
299301
selectToday() {
300-
const value = constrainValue(getTodayDate(timeZone), min, max)
302+
const value = constrainValue(getTodayDate(timeZone, focusedValue.calendar), min, max)
301303
send({ type: "VALUE.SET", value: [value] })
302304
},
303305
setValue(values) {
@@ -362,7 +364,10 @@ export function connect<T extends PropTypes>(
362364
return chunk(getMonths({ format }), columns)
363365
},
364366
format(value, opts = { month: "long", year: "numeric" }) {
365-
return new DateFormatter(locale, opts).format(value.toDate(timeZone))
367+
return new DateFormatter(locale, {
368+
...opts,
369+
calendar: value.calendar.identifier,
370+
}).format(value.toDate(timeZone))
366371
},
367372
setView(view) {
368373
send({ type: "VIEW.SET", view })

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

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { DateFormatter, toCalendarDateTime, toZoned } from "@internationalized/date"
1+
import {
2+
DateFormatter,
3+
toCalendar,
4+
toCalendarDateTime,
5+
toZoned,
6+
type Calendar,
7+
type CalendarIdentifier,
8+
} from "@internationalized/date"
29
import { createGuards, createMachine, type Params, type PropFn } from "@zag-js/core"
310
import {
411
alignDate,
@@ -63,18 +70,39 @@ export const machine = createMachine<DatePickerSchema>({
6370
const selectionMode = props.selectionMode || "single"
6471
const numOfMonths = props.numOfMonths || 1
6572

73+
// Resolve calendar from locale when createCalendar is provided
74+
let calendar: Calendar | undefined
75+
if (props.createCalendar) {
76+
const resolved = new Intl.DateTimeFormat(locale).resolvedOptions()
77+
const calendarId = resolved.calendar as CalendarIdentifier
78+
if (calendarId !== "gregory" && calendarId !== "iso8601") {
79+
calendar = props.createCalendar(calendarId)
80+
}
81+
}
82+
83+
// Helper to convert dates to resolved calendar
84+
const toTargetCalendar = (date: DateValue) => {
85+
if (!calendar) return date
86+
if (date.calendar.identifier === calendar.identifier) return date
87+
return toCalendar(date, calendar)
88+
}
89+
6690
// sort and constrain dates
6791
const defaultValue = props.defaultValue
68-
? sortDates(props.defaultValue).map((date) => constrainValue(date, props.min, props.max))
92+
? sortDates(props.defaultValue).map((date) => constrainValue(toTargetCalendar(date), props.min, props.max))
6993
: undefined
7094
const value = props.value
71-
? sortDates(props.value).map((date) => constrainValue(date, props.min, props.max))
95+
? sortDates(props.value).map((date) => constrainValue(toTargetCalendar(date), props.min, props.max))
7296
: undefined
7397

7498
// get initial focused value
7599
let focusedValue =
76-
props.focusedValue || props.defaultFocusedValue || value?.[0] || defaultValue?.[0] || getTodayDate(timeZone)
77-
focusedValue = constrainValue(focusedValue, props.min, props.max)
100+
props.focusedValue ||
101+
props.defaultFocusedValue ||
102+
value?.[0] ||
103+
defaultValue?.[0] ||
104+
getTodayDate(timeZone, calendar)
105+
focusedValue = constrainValue(toTargetCalendar(focusedValue), props.min, props.max)
78106

79107
// get the initial view
80108
const minView: DateView = "day"
@@ -92,7 +120,13 @@ export const machine = createMachine<DatePickerSchema>({
92120
outsideDaySelectable: false,
93121
closeOnSelect: true,
94122
format(date, { locale, timeZone }) {
95-
const formatter = new DateFormatter(locale, { timeZone, day: "2-digit", month: "2-digit", year: "numeric" })
123+
const formatter = new DateFormatter(locale, {
124+
timeZone,
125+
day: "2-digit",
126+
month: "2-digit",
127+
year: "numeric",
128+
calendar: calendar?.identifier,
129+
})
96130
return formatter.format(date.toDate(timeZone))
97131
},
98132
parse(value, { locale, timeZone }) {
@@ -987,8 +1021,9 @@ export const machine = createMachine<DatePickerSchema>({
9871021
setFocusedValue(params, nextValue)
9881022
},
9891023
clearFocusedDate(params) {
990-
const { prop } = params
991-
setFocusedValue(params, getTodayDate(prop("timeZone")))
1024+
const { context, prop } = params
1025+
const calendar = context.get("focusedValue").calendar
1026+
setFocusedValue(params, getTodayDate(prop("timeZone"), calendar))
9921027
},
9931028
focusPreviousMonthColumn(params) {
9941029
const { context, event } = params
@@ -1012,13 +1047,15 @@ export const machine = createMachine<DatePickerSchema>({
10121047
},
10131048
focusFirstMonth(params) {
10141049
const { context } = params
1015-
const nextValue = context.get("focusedValue").set({ month: 1 })
1016-
setFocusedValue(params, nextValue)
1050+
const focused = context.get("focusedValue")
1051+
const minMonth = focused.calendar.getMinimumMonthInYear?.(focused) ?? 1
1052+
setFocusedValue(params, focused.set({ month: minMonth }))
10171053
},
10181054
focusLastMonth(params) {
10191055
const { context } = params
1020-
const nextValue = context.get("focusedValue").set({ month: 12 })
1021-
setFocusedValue(params, nextValue)
1056+
const focused = context.get("focusedValue")
1057+
const maxMonth = focused.calendar.getMonthsInYear(focused)
1058+
setFocusedValue(params, focused.set({ month: maxMonth }))
10221059
},
10231060
focusFirstYear(params) {
10241061
const { context } = params

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111

1212
export const props = createProps<DatePickerProps>()([
1313
"closeOnSelect",
14+
"createCalendar",
1415
"dir",
1516
"disabled",
1617
"fixedWeeks",

0 commit comments

Comments
 (0)