diff --git a/.changeset/green-lobsters-hide.md b/.changeset/green-lobsters-hide.md
new file mode 100644
index 0000000000..b453e4c6ab
--- /dev/null
+++ b/.changeset/green-lobsters-hide.md
@@ -0,0 +1,8 @@
+---
+"@zag-js/date-picker": minor
+---
+
+- [BREAKING] Change date picker from `inputProps` to `getInputProps` to support multiple inputs.
+
+- Added a new prop `getPresetTriggerProps` to support custom trigger for common date presets (e.g. Last 7 days, Last 30
+ days, etc.)
diff --git a/.xstate/date-picker.js b/.xstate/date-picker.js
index b425c3fdf7..41f46ffea1 100644
--- a/.xstate/date-picker.js
+++ b/.xstate/date-picker.js
@@ -13,6 +13,8 @@ const fetchMachine = createMachine({
id: "datepicker",
initial: ctx.open ? "open" : "idle",
context: {
+ "isOpenControlled": false,
+ "isOpenControlled": false,
"isYearView": false,
"isMonthView": false,
"isYearView": false,
@@ -68,7 +70,7 @@ const fetchMachine = createMachine({
activities: ["setupLiveRegion"],
on: {
"VALUE.SET": {
- actions: ["setSelectedDate", "setFocusedDate"]
+ actions: ["setDateValue", "setFocusedDate"]
},
"VIEW.SET": {
actions: ["setView"]
@@ -77,11 +79,31 @@ const fetchMachine = createMachine({
actions: ["setFocusedDate"]
},
"VALUE.CLEAR": {
- actions: ["clearSelectedDate", "clearFocusedDate", "focusInputElement"]
+ actions: ["clearDateValue", "clearFocusedDate", "focusFirstInputElement"]
},
"INPUT.CHANGE": {
actions: ["focusParsedDate"]
},
+ "INPUT.ENTER": {
+ actions: ["focusParsedDate", "selectFocusedDate"]
+ },
+ "INPUT.FOCUS": {
+ actions: ["setActiveIndex"]
+ },
+ "INPUT.BLUR": [{
+ cond: "isOpenControlled",
+ actions: ["setActiveIndexToStart", "selectParsedDate", "invokeOnClose"]
+ }, {
+ target: "idle",
+ actions: ["setActiveIndexToStart", "selectParsedDate"]
+ }],
+ "PRESET.CLICK": [{
+ cond: "isOpenControlled",
+ actions: ["setDateValue", "setFocusedDate", "invokeOnClose"]
+ }, {
+ target: "focused",
+ actions: ["setDateValue", "setFocusedDate", "focusInputElement"]
+ }],
"GOTO.NEXT": [{
cond: "isYearView",
actions: ["focusNextDecade", "announceVisibleRange"]
@@ -114,9 +136,6 @@ const fetchMachine = createMachine({
target: "open",
actions: ["focusFirstSelectedDate", "focusActiveCell"]
},
- "INPUT.FOCUS": {
- target: "focused"
- },
"TRIGGER.CLICK": [{
cond: "isOpenControlled",
actions: ["invokeOnOpen"]
@@ -147,12 +166,6 @@ const fetchMachine = createMachine({
target: "open",
actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"]
}],
- "INPUT.ENTER": {
- actions: ["focusParsedDate", "selectFocusedDate"]
- },
- "INPUT.BLUR": {
- target: "idle"
- },
OPEN: [{
cond: "isOpenControlled",
actions: ["invokeOnOpen"]
@@ -186,7 +199,7 @@ const fetchMachine = createMachine({
actions: ["setFocusedYear", "setViewToMonth"]
}, {
cond: "isRangePicker && hasSelectedRange",
- actions: ["setActiveIndexToStart", "clearSelectedDate", "setFocusedDate", "setSelectedDate", "setActiveIndexToEnd"]
+ actions: ["setActiveIndexToStart", "clearDateValue", "setFocusedDate", "setSelectedDate", "setActiveIndexToEnd"]
},
// === Grouped transitions (based on `closeOnSelect` and `isOpenControlled`) ===
{
@@ -250,7 +263,7 @@ const fetchMachine = createMachine({
actions: "setViewToMonth"
}, {
cond: "isRangePicker && hasSelectedRange",
- actions: ["setActiveIndexToStart", "clearSelectedDate", "setSelectedDate", "setActiveIndexToEnd"]
+ actions: ["setActiveIndexToStart", "clearDateValue", "setSelectedDate", "setActiveIndexToEnd"]
},
// === Grouped transitions (based on `closeOnSelect` and `isOpenControlled`) ===
{
@@ -389,9 +402,9 @@ const fetchMachine = createMachine({
})
},
guards: {
+ "isOpenControlled": ctx => ctx["isOpenControlled"],
"isYearView": ctx => ctx["isYearView"],
"isMonthView": ctx => ctx["isMonthView"],
- "isOpenControlled": ctx => ctx["isOpenControlled"],
"shouldRestoreFocus && isInteractOutsideEvent": ctx => ctx["shouldRestoreFocus && isInteractOutsideEvent"],
"shouldRestoreFocus": ctx => ctx["shouldRestoreFocus"],
"isRangePicker && hasSelectedRange": ctx => ctx["isRangePicker && hasSelectedRange"],
diff --git a/examples/next-app/components/date-range-picker.tsx b/examples/next-app/components/date-range-picker.tsx
index af4add54e9..dca5aef0fa 100644
--- a/examples/next-app/components/date-range-picker.tsx
+++ b/examples/next-app/components/date-range-picker.tsx
@@ -44,7 +44,8 @@ export function DateRangePicker(props: Props) {
-
+
+
diff --git a/examples/next-ts/pages/date-picker-multi.tsx b/examples/next-ts/pages/date-picker-multi.tsx
index 2d9f487dd9..b9902b6e4e 100644
--- a/examples/next-ts/pages/date-picker-multi.tsx
+++ b/examples/next-ts/pages/date-picker-multi.tsx
@@ -36,7 +36,7 @@ export default function Page() {
-
+
diff --git a/examples/next-ts/pages/date-picker-range.tsx b/examples/next-ts/pages/date-picker-range.tsx
index 748c9fa303..cc13b4ecec 100644
--- a/examples/next-ts/pages/date-picker-range.tsx
+++ b/examples/next-ts/pages/date-picker-range.tsx
@@ -12,6 +12,7 @@ export default function Page() {
const [state, send] = useMachine(
datePicker.machine({
id: useId(),
+ name: "date[]",
locale: "en",
numOfMonths: 2,
selectionMode: "range",
@@ -33,12 +34,13 @@ export default function Page() {
{`Visible range: ${api.visibleRangeText.formatted}`}
-
+
+
@@ -75,7 +77,7 @@ export default function Page() {
-
+
{api.weekDays.map((day, i) => (
@@ -98,7 +100,7 @@ export default function Page() {
-
+
{api.weekDays.map((day, i) => (
@@ -122,6 +124,15 @@ export default function Page() {
))}
+
+
+ Presets
+
+
+
+
+
+
diff --git a/examples/next-ts/pages/date-picker.tsx b/examples/next-ts/pages/date-picker.tsx
index 4c68479437..1d959353b6 100644
--- a/examples/next-ts/pages/date-picker.tsx
+++ b/examples/next-ts/pages/date-picker.tsx
@@ -36,7 +36,7 @@ export default function Page() {
-
+
diff --git a/packages/machines/date-picker/src/date-picker.anatomy.ts b/packages/machines/date-picker/src/date-picker.anatomy.ts
index 6bd588eb98..63831acfbf 100644
--- a/packages/machines/date-picker/src/date-picker.anatomy.ts
+++ b/packages/machines/date-picker/src/date-picker.anatomy.ts
@@ -23,6 +23,7 @@ export const anatomy = createAnatomy("date-picker").parts(
"viewTrigger",
"viewControl",
"yearSelect",
+ "presetTrigger",
)
export const parts = anatomy.build()
diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts
index 2bcec7e42b..dbe9fd4b2a 100644
--- a/packages/machines/date-picker/src/date-picker.connect.ts
+++ b/packages/machines/date-picker/src/date-picker.connect.ts
@@ -1,6 +1,7 @@
-import { DateFormatter, isWeekend, type DateValue, isEqualDay } from "@internationalized/date"
+import { DateFormatter, isEqualDay, isWeekend, type DateValue } from "@internationalized/date"
import {
constrainValue,
+ getDateRangePreset,
getDayFormatter,
getDaysInWeek,
getDecadeRange,
@@ -27,14 +28,14 @@ import { chunk } from "@zag-js/utils"
import { parts } from "./date-picker.anatomy"
import { dom } from "./date-picker.dom"
import type {
- TableCellProps,
- TableCellState,
DayTableCellProps,
DayTableCellState,
- TableProps,
MachineApi,
Send,
State,
+ TableCellProps,
+ TableCellState,
+ TableProps,
} from "./date-picker.types"
import {
adjustStartAndEndDate,
@@ -56,6 +57,7 @@ export function connect(state: State, send: Send, normalize
const endValue = state.context.endValue
const selectedValue = state.context.value
const focusedValue = state.context.focusedValue
+
const hoveredValue = state.context.hoveredValue
const hoveredRangeValue = hoveredValue ? adjustStartAndEndDate([selectedValue[0], hoveredValue]) : []
@@ -105,12 +107,12 @@ export function connect(state: State, send: Send, normalize
}
function focusMonth(month: number) {
- const value = setMonth(focusedValue ?? getTodayDate(timeZone), month)
+ const value = setMonth(startValue ?? getTodayDate(timeZone), month)
send({ type: "FOCUS.SET", value })
}
function focusYear(year: number) {
- const value = setYear(focusedValue ?? getTodayDate(timeZone), year)
+ const value = setYear(startValue ?? getTodayDate(timeZone), year)
send({ type: "FOCUS.SET", value })
}
@@ -150,6 +152,7 @@ export function connect(state: State, send: Send, normalize
const formatter = getDayFormatter(locale, timeZone)
const unitDuration = getUnitDuration(state.context.visibleDuration)
+
const end = visibleRange.start.add(unitDuration).subtract({ days: 1 })
const cellState = {
@@ -189,6 +192,9 @@ export function connect(state: State, send: Send, normalize
isFocused,
isOpen,
view: state.context.view,
+ getRangePresetValue(preset) {
+ return getDateRangePreset(preset, locale, timeZone)
+ },
getDaysInWeek(week, from = startValue) {
return getDaysInWeek(week, from, locale, startOfWeek)
},
@@ -272,7 +278,7 @@ export function connect(state: State, send: Send, normalize
labelProps: normalize.label({
...parts.label.attrs,
dir: state.context.dir,
- htmlFor: dom.getInputId(state.context),
+ htmlFor: dom.getInputId(state.context, 0),
"data-state": isOpen ? "open" : "closed",
"data-disabled": dataAttr(disabled),
"data-readonly": dataAttr(readOnly),
@@ -622,41 +628,46 @@ export function connect(state: State, send: Send, normalize
})
},
- inputProps: normalize.input({
- ...parts.input.attrs,
- id: dom.getInputId(state.context),
- autoComplete: "off",
- autoCorrect: "off",
- spellCheck: "false",
- dir: state.context.dir,
- name: state.context.name,
- "data-state": isOpen ? "open" : "closed",
- readOnly,
- disabled,
- placeholder: getInputPlaceholder(locale),
- defaultValue: state.context.inputValue,
- onBeforeInput(event) {
- const { data } = getNativeEvent(event)
- if (!isValidCharacter(data, separator)) {
+ getInputProps(props = {}) {
+ const { index = 0 } = props
+
+ return normalize.input({
+ ...parts.input.attrs,
+ id: dom.getInputId(state.context, index),
+ autoComplete: "off",
+ autoCorrect: "off",
+ spellCheck: "false",
+ dir: state.context.dir,
+ name: state.context.name,
+ "data-state": isOpen ? "open" : "closed",
+ readOnly,
+ disabled,
+ placeholder: getInputPlaceholder(locale),
+ defaultValue: state.context.formattedValue[index],
+ onBeforeInput(event) {
+ const { data } = getNativeEvent(event)
+ if (!isValidCharacter(data, separator)) {
+ event.preventDefault()
+ }
+ },
+ onFocus() {
+ send({ type: "INPUT.FOCUS", index })
+ },
+ onBlur(event) {
+ send({ type: "INPUT.BLUR", value: event.currentTarget.value, index })
+ },
+ onKeyDown(event) {
+ if (event.key !== "Enter" || !isInteractive) return
+ if (isUnavailable(state.context.focusedValue)) return
+ send({ type: "INPUT.ENTER", value: event.currentTarget.value, index })
event.preventDefault()
- }
- },
- onFocus() {
- send("INPUT.FOCUS")
- },
- onBlur(event) {
- send({ type: "INPUT.BLUR", value: event.currentTarget.value })
- },
- onKeyDown(event) {
- if (event.key !== "Enter" || !isInteractive) return
- if (isUnavailable(state.context.focusedValue)) return
- send({ type: "INPUT.ENTER", value: event.currentTarget.value })
- },
- onChange(event) {
- const { value } = event.target
- send({ type: "INPUT.CHANGE", value: ensureValidCharacters(value, separator) })
- },
- }),
+ },
+ onChange(event) {
+ const { value } = event.target
+ send({ type: "INPUT.CHANGE", value: ensureValidCharacters(value, separator), index })
+ },
+ })
+ },
monthSelectProps: normalize.select({
...parts.monthSelect.attrs,
@@ -664,7 +675,7 @@ export function connect(state: State, send: Send, normalize
"aria-label": "Select month",
disabled,
dir: state.context.dir,
- defaultValue: focusedValue.month,
+ defaultValue: startValue.month,
onChange(event) {
focusMonth(Number(event.currentTarget.value))
},
@@ -676,7 +687,7 @@ export function connect(state: State, send: Send, normalize
disabled,
"aria-label": "Select year",
dir: state.context.dir,
- defaultValue: focusedValue.year,
+ defaultValue: startValue.year,
onChange(event) {
focusYear(Number(event.currentTarget.value))
},
@@ -688,5 +699,19 @@ export function connect(state: State, send: Send, normalize
dir: state.context.dir,
style: popperStyles.floating,
}),
+
+ getPresetTriggerProps(props) {
+ const value = Array.isArray(props.value) ? props.value : getDateRangePreset(props.value, locale, timeZone)
+ return normalize.button({
+ ...parts.presetTrigger.attrs,
+ "aria-label": Array.isArray(props.value)
+ ? `select ${value[0].toString()} to ${value[1].toString()}`
+ : `select ${value}`,
+ type: "button",
+ onClick() {
+ send({ type: "PRESET.CLICK", value })
+ },
+ })
+ },
}
}
diff --git a/packages/machines/date-picker/src/date-picker.dom.ts b/packages/machines/date-picker/src/date-picker.dom.ts
index 803a715b6f..bf820762fc 100644
--- a/packages/machines/date-picker/src/date-picker.dom.ts
+++ b/packages/machines/date-picker/src/date-picker.dom.ts
@@ -1,4 +1,4 @@
-import { createScope, query } from "@zag-js/dom-query"
+import { createScope, query, queryAll } from "@zag-js/dom-query"
import type { DateView, MachineContext as Ctx } from "./date-picker.types"
export const dom = createScope({
@@ -14,7 +14,7 @@ export const dom = createScope({
getViewTriggerId: (ctx: Ctx, view: DateView) => ctx.ids?.viewTrigger?.(view) ?? `datepicker:${ctx.id}:view:${view}`,
getClearTriggerId: (ctx: Ctx) => ctx.ids?.clearTrigger ?? `datepicker:${ctx.id}:clear`,
getControlId: (ctx: Ctx) => ctx.ids?.control ?? `datepicker:${ctx.id}:control`,
- getInputId: (ctx: Ctx) => ctx.ids?.input ?? `datepicker:${ctx.id}:input`,
+ getInputId: (ctx: Ctx, index: number) => ctx.ids?.input?.(index) ?? `datepicker:${ctx.id}:input:${index}`,
getTriggerId: (ctx: Ctx) => ctx.ids?.trigger ?? `datepicker:${ctx.id}:trigger`,
getPositionerId: (ctx: Ctx) => ctx.ids?.positioner ?? `datepicker:${ctx.id}:positioner`,
getMonthSelectId: (ctx: Ctx) => ctx.ids?.monthSelect ?? `datepicker:${ctx.id}:month-select`,
@@ -27,7 +27,7 @@ export const dom = createScope({
),
getTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getTriggerId(ctx)),
getContentEl: (ctx: Ctx) => dom.getById(ctx, dom.getContentId(ctx)),
- getInputEl: (ctx: Ctx) => dom.getById(ctx, dom.getInputId(ctx)),
+ getInputEls: (ctx: Ctx) => queryAll(dom.getControlEl(ctx), `[data-part=input]`),
getYearSelectEl: (ctx: Ctx) => dom.getById(ctx, dom.getYearSelectId(ctx)),
getMonthSelectEl: (ctx: Ctx) => dom.getById(ctx, dom.getMonthSelectId(ctx)),
getClearTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getClearTriggerId(ctx)),
diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts
index 1f98c856b4..b914a50e78 100644
--- a/packages/machines/date-picker/src/date-picker.machine.ts
+++ b/packages/machines/date-picker/src/date-picker.machine.ts
@@ -8,14 +8,17 @@ import {
getDecadeRange,
getEndDate,
getNextDay,
+ getNextPage,
getNextSection,
getPreviousDay,
+ getPreviousPage,
getPreviousSection,
getTodayDate,
isDateEqual,
isNextVisibleRangeInvalid,
isPreviousVisibleRangeInvalid,
parseDateString,
+ type AdjustDateReturn,
} from "@zag-js/date-utils"
import { trackDismissableElement } from "@zag-js/dismissable"
import { raf } from "@zag-js/dom-query"
@@ -25,7 +28,7 @@ import { disableTextSelection, restoreTextSelection } from "@zag-js/text-selecti
import { compact, isEqual } from "@zag-js/utils"
import { dom } from "./date-picker.dom"
import type { DateValue, DateView, MachineContext, MachineState, UserDefinedContext } from "./date-picker.types"
-import { adjustStartAndEndDate, formatValue, sortDates } from "./date-picker.utils"
+import { adjustStartAndEndDate, sortDates } from "./date-picker.utils"
const { and } = guards
@@ -36,8 +39,7 @@ const transformContext = (ctx: Partial): MachineContext => {
const numOfMonths = ctx.numOfMonths || 1
// sort and constrain dates
- let value = sortDates(ctx.value || [])
- value = value.map((date) => constrainValue(date, ctx.min, ctx.max))
+ const value = sortDates(ctx.value || []).map((date) => constrainValue(date, ctx.min, ctx.max))
// get initial focused value
let focusedValue = value[0] || ctx.focusedValue || getTodayDate(timeZone)
@@ -46,9 +48,6 @@ const transformContext = (ctx: Partial): MachineContext => {
// get initial start value for visible range
const startValue = alignDate(focusedValue, "start", { months: numOfMonths }, locale)
- // format input value
- const inputValue = ctx.format?.(value) ?? formatValue({ locale, timeZone, selectionMode, value })
-
return {
locale,
numOfMonths,
@@ -56,7 +55,6 @@ const transformContext = (ctx: Partial): MachineContext => {
startValue,
timeZone,
value,
- inputValue,
selectionMode,
view: "day",
activeIndex: 0,
@@ -92,29 +90,32 @@ export function machine(userContext: UserDefinedContext) {
},
isPrevVisibleRangeValid: (ctx) => !isPreviousVisibleRangeInvalid(ctx.startValue, ctx.min, ctx.max),
isNextVisibleRangeValid: (ctx) => !isNextVisibleRangeInvalid(ctx.endValue, ctx.min, ctx.max),
+ formattedValue(ctx) {
+ const opts = { timeZone: ctx.timeZone, day: "2-digit", month: "2-digit", year: "numeric" } as const
+ const formatter = new DateFormatter(ctx.locale, opts)
+ return ctx.value.map((date) => ctx.format?.(date) ?? formatter.format(date.toDate(ctx.timeZone)))
+ },
},
activities: ["setupLiveRegion"],
watch: {
- locale: ["setStartValue", "setInputValue"],
+ locale: ["setStartValue"],
focusedValue: [
- "adjustStartDate",
"syncMonthSelectElement",
"syncYearSelectElement",
"focusActiveCellIfNeeded",
"setHoveredValueIfKeyboard",
],
- value: ["setInputValue"],
+ value: ["syncInputElement"],
valueAsString: ["announceValueText"],
- inputValue: ["syncInputElement"],
view: ["focusActiveCell"],
open: ["toggleVisibility"],
},
on: {
"VALUE.SET": {
- actions: ["setSelectedDate", "setFocusedDate"],
+ actions: ["setDateValue", "setFocusedDate"],
},
"VIEW.SET": {
actions: ["setView"],
@@ -123,11 +124,37 @@ export function machine(userContext: UserDefinedContext) {
actions: ["setFocusedDate"],
},
"VALUE.CLEAR": {
- actions: ["clearSelectedDate", "clearFocusedDate", "focusInputElement"],
+ actions: ["clearDateValue", "clearFocusedDate", "focusFirstInputElement"],
},
"INPUT.CHANGE": {
actions: ["focusParsedDate"],
},
+ "INPUT.ENTER": {
+ actions: ["focusParsedDate", "selectFocusedDate"],
+ },
+ "INPUT.FOCUS": {
+ actions: ["setActiveIndex"],
+ },
+ "INPUT.BLUR": [
+ {
+ guard: "isOpenControlled",
+ actions: ["setActiveIndexToStart", "selectParsedDate", "invokeOnClose"],
+ },
+ {
+ target: "idle",
+ actions: ["setActiveIndexToStart", "selectParsedDate"],
+ },
+ ],
+ "PRESET.CLICK": [
+ {
+ guard: "isOpenControlled",
+ actions: ["setDateValue", "setFocusedDate", "invokeOnClose"],
+ },
+ {
+ target: "focused",
+ actions: ["setDateValue", "setFocusedDate", "focusInputElement"],
+ },
+ ],
"GOTO.NEXT": [
{
guard: "isYearView",
@@ -164,9 +191,6 @@ export function machine(userContext: UserDefinedContext) {
target: "open",
actions: ["focusFirstSelectedDate", "focusActiveCell"],
},
- "INPUT.FOCUS": {
- target: "focused",
- },
"TRIGGER.CLICK": [
{
guard: "isOpenControlled",
@@ -207,12 +231,6 @@ export function machine(userContext: UserDefinedContext) {
actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"],
},
],
- "INPUT.ENTER": {
- actions: ["focusParsedDate", "selectFocusedDate"],
- },
- "INPUT.BLUR": {
- target: "idle",
- },
OPEN: [
{
guard: "isOpenControlled",
@@ -259,7 +277,7 @@ export function machine(userContext: UserDefinedContext) {
guard: and("isRangePicker", "hasSelectedRange"),
actions: [
"setActiveIndexToStart",
- "clearSelectedDate",
+ "clearDateValue",
"setFocusedDate",
"setSelectedDate",
"setActiveIndexToEnd",
@@ -344,7 +362,7 @@ export function machine(userContext: UserDefinedContext) {
},
{
guard: and("isRangePicker", "hasSelectedRange"),
- actions: ["setActiveIndexToStart", "clearSelectedDate", "setSelectedDate", "setActiveIndexToEnd"],
+ actions: ["setActiveIndexToStart", "clearDateValue", "setSelectedDate", "setActiveIndexToEnd"],
},
// === Grouped transitions (based on `closeOnSelect` and `isOpenControlled`) ===
{
@@ -551,7 +569,7 @@ export function machine(userContext: UserDefinedContext) {
},
trackDismissableElement(ctx, _evt, { send }) {
return trackDismissableElement(dom.getContentEl(ctx), {
- exclude: [dom.getInputEl(ctx), dom.getTriggerEl(ctx), dom.getClearTriggerEl(ctx)],
+ exclude: [...dom.getInputEls(ctx), dom.getTriggerEl(ctx), dom.getClearTriggerEl(ctx)],
onInteractOutside(event) {
ctx.restoreFocus = !event.detail.focusable
},
@@ -595,23 +613,18 @@ export function machine(userContext: UserDefinedContext) {
if (!ctx.value.length) return
set.focusedValue(ctx, ctx.value[0])
},
- setInputValue(ctx) {
- const input = dom.getInputEl(ctx)
- if (!input) return
- ctx.inputValue = ctx.format?.(ctx.value) ?? formatValue(ctx)
- },
syncInputElement(ctx) {
- const inputEl = dom.getInputEl(ctx)
- if (!inputEl || inputEl.value === ctx.inputValue) return
raf(() => {
- // move cursor to the end
- inputEl.value = ctx.inputValue
- inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
+ const inputEls = dom.getInputEls(ctx)
+
+ inputEls.forEach((inputEl, index) => {
+ dom.setValue(inputEl, ctx.formattedValue[index] || "")
+ })
})
},
setFocusedDate(ctx, evt) {
const value = Array.isArray(evt.value) ? evt.value[0] : evt.value
- set.focusedValue(ctx, constrainValue(value, ctx.min, ctx.max))
+ set.focusedValue(ctx, value)
},
setFocusedMonth(ctx, evt) {
set.focusedValue(ctx, ctx.focusedValue.set({ month: evt.value }))
@@ -625,8 +638,16 @@ export function machine(userContext: UserDefinedContext) {
setFocusedYear(ctx, evt) {
set.focusedValue(ctx, ctx.focusedValue.set({ year: evt.value }))
},
+ setDateValue(ctx, evt) {
+ if (!Array.isArray(evt.value)) return
+ const value = evt.value.map((date: DateValue) => constrainValue(date, ctx.min, ctx.max))
+ set.value(ctx, value)
+ },
+ clearDateValue(ctx) {
+ set.value(ctx, [])
+ },
setSelectedDate(ctx, evt) {
- const values = [...ctx.value]
+ const values = Array.from(ctx.value)
values[ctx.activeIndex] = evt.value ?? ctx.focusedValue
set.value(ctx, adjustStartAndEndDate(values))
},
@@ -638,7 +659,7 @@ export function machine(userContext: UserDefinedContext) {
const values = [...ctx.value, currentValue]
set.value(ctx, sortDates(values))
} else {
- const values = [...ctx.value]
+ const values = Array.from(ctx.value)
values.splice(index, 1)
set.value(ctx, sortDates(values))
}
@@ -650,16 +671,10 @@ export function machine(userContext: UserDefinedContext) {
ctx.hoveredValue = null
},
selectFocusedDate(ctx) {
- const values = [...ctx.value]
+ const values = Array.from(ctx.value)
values[ctx.activeIndex] = ctx.focusedValue.copy()
set.value(ctx, adjustStartAndEndDate(values))
},
- adjustStartDate(ctx) {
- const adjust = getAdjustedDateFn(ctx.visibleDuration, ctx.locale, ctx.min, ctx.max)
- const { startDate, focusedDate } = adjust({ focusedDate: ctx.focusedValue, startDate: ctx.startValue })
- ctx.startValue = startDate
- set.focusedValue(ctx, focusedDate)
- },
setPreviousDate(ctx) {
set.focusedValue(ctx, getPreviousDay(ctx.focusedValue))
},
@@ -679,10 +694,28 @@ export function machine(userContext: UserDefinedContext) {
set.focusedValue(ctx, ctx.focusedValue.add({ weeks: 1 }))
},
focusNextPage(ctx) {
- set.focusedValue(ctx, ctx.focusedValue.add(ctx.visibleDuration))
+ const nextPage = getNextPage(
+ ctx.focusedValue,
+ ctx.startValue,
+ ctx.visibleDuration,
+ ctx.locale,
+ ctx.min,
+ ctx.max,
+ )
+
+ set.adjustedValue(ctx, nextPage)
},
focusPreviousPage(ctx) {
- set.focusedValue(ctx, ctx.focusedValue.subtract(ctx.visibleDuration))
+ const previousPage = getPreviousPage(
+ ctx.focusedValue,
+ ctx.startValue,
+ ctx.visibleDuration,
+ ctx.locale,
+ ctx.min,
+ ctx.max,
+ )
+
+ set.adjustedValue(ctx, previousPage)
},
focusSectionStart(ctx) {
set.focusedValue(ctx, ctx.startValue.copy())
@@ -691,7 +724,7 @@ export function machine(userContext: UserDefinedContext) {
set.focusedValue(ctx, ctx.endValue.copy())
},
focusNextSection(ctx, evt) {
- const section = getNextSection(
+ const nextSection = getNextSection(
ctx.focusedValue,
ctx.startValue,
evt.larger,
@@ -701,11 +734,11 @@ export function machine(userContext: UserDefinedContext) {
ctx.max,
)
- if (!section) return
- set.focusedValue(ctx, section.focusedDate)
+ if (!nextSection) return
+ set.adjustedValue(ctx, nextSection)
},
focusPreviousSection(ctx, evt) {
- const section = getPreviousSection(
+ const previousSection = getPreviousSection(
ctx.focusedValue,
ctx.startValue,
evt.larger,
@@ -715,8 +748,8 @@ export function machine(userContext: UserDefinedContext) {
ctx.max,
)
- if (!section) return
- set.focusedValue(ctx, section.focusedDate)
+ if (!previousSection) return
+ set.adjustedValue(ctx, previousSection)
},
focusNextYear(ctx) {
set.focusedValue(ctx, ctx.focusedValue.add({ years: 1 }))
@@ -730,9 +763,6 @@ export function machine(userContext: UserDefinedContext) {
focusPreviousDecade(ctx) {
set.focusedValue(ctx, ctx.focusedValue.subtract({ years: 10 }))
},
- clearSelectedDate(ctx) {
- set.value(ctx, [])
- },
clearFocusedDate(ctx) {
set.focusedValue(ctx, getTodayDate(ctx.timeZone))
},
@@ -762,6 +792,9 @@ export function machine(userContext: UserDefinedContext) {
const range = getDecadeRange(ctx.focusedValue.year)
set.focusedValue(ctx, ctx.focusedValue.set({ year: range.at(-1) }))
},
+ setActiveIndex(ctx, evt) {
+ ctx.activeIndex = evt.index
+ },
setActiveIndexToEnd(ctx) {
ctx.activeIndex = 1
},
@@ -788,28 +821,54 @@ export function machine(userContext: UserDefinedContext) {
dom.getTriggerEl(ctx)?.focus({ preventScroll: true })
})
},
+ focusFirstInputElement(ctx) {
+ raf(() => {
+ const inputEl = dom.getInputEls(ctx)[0]
+ inputEl?.focus({ preventScroll: true })
+ })
+ },
focusInputElement(ctx) {
raf(() => {
- const inputEl = dom.getInputEl(ctx)
+ const inputEls = dom.getInputEls(ctx)
+
+ const lastIndexWithValue = inputEls.findLastIndex((inputEl) => inputEl.value !== "")
+ const indexToFocus = Math.max(lastIndexWithValue, 0)
+
+ const inputEl = inputEls[indexToFocus]
inputEl?.focus({ preventScroll: true })
+ // move cursor to the end
inputEl?.setSelectionRange(inputEl.value.length, inputEl.value.length)
})
},
syncMonthSelectElement(ctx) {
const monthSelectEl = dom.getMonthSelectEl(ctx)
if (!monthSelectEl) return
- monthSelectEl.value = ctx.focusedValue.month.toString()
+ monthSelectEl.value = ctx.startValue.month.toString()
},
syncYearSelectElement(ctx) {
const yearSelectEl = dom.getYearSelectEl(ctx)
if (!yearSelectEl) return
- yearSelectEl.value = ctx.focusedValue.year.toString()
+ yearSelectEl.value = ctx.startValue.year.toString()
},
focusParsedDate(ctx, evt) {
- ctx.inputValue = evt.value
- const date = parseDateString(ctx.inputValue, ctx.locale, ctx.timeZone)
+ if (evt.index == null) return
+
+ const date = parseDateString(evt.value, ctx.locale, ctx.timeZone)
+ if (!date) return
+
set.focusedValue(ctx, date)
},
+ selectParsedDate(ctx, evt) {
+ if (evt.index == null) return
+
+ const date = parseDateString(evt.value, ctx.locale, ctx.timeZone)
+ if (!date) return
+
+ const values = Array.from(ctx.value)
+ values[evt.index] = date
+
+ set.value(ctx, values)
+ },
resetView(ctx, _evt, { initialContext }) {
set.view(ctx, initialContext.view)
},
@@ -829,6 +888,7 @@ export function machine(userContext: UserDefinedContext) {
},
compareFns: {
startValue: isDateEqual,
+ endValue: isDateEqual,
focusedValue: isDateEqual,
value: isDateEqualFn,
},
@@ -872,11 +932,30 @@ const set = {
ctx.value = value
invoke.change(ctx)
},
+
focusedValue(ctx: MachineContext, value: DateValue | undefined) {
if (!value || isDateEqual(ctx.focusedValue, value)) return
- ctx.focusedValue = value
+
+ const adjustFn = getAdjustedDateFn(ctx.visibleDuration, ctx.locale, ctx.min, ctx.max)
+ const adjustedValue = adjustFn({
+ focusedDate: value,
+ startDate: ctx.startValue,
+ })
+
+ ctx.startValue = adjustedValue.startDate
+ ctx.focusedValue = adjustedValue.focusedDate
+
+ invoke.focusChange(ctx)
+ },
+
+ adjustedValue(ctx: MachineContext, value: AdjustDateReturn) {
+ ctx.startValue = value.startDate
+ if (isDateEqual(ctx.focusedValue, value.focusedDate)) return
+
+ ctx.focusedValue = value.focusedDate
invoke.focusChange(ctx)
},
+
view(ctx: MachineContext, value: DateView) {
if (isEqual(ctx.view, value)) return
ctx.view = value
diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts
index 0d4f89d28a..f0a7b0e027 100644
--- a/packages/machines/date-picker/src/date-picker.types.ts
+++ b/packages/machines/date-picker/src/date-picker.types.ts
@@ -8,6 +8,7 @@ import type {
ZonedDateTime,
} from "@internationalized/date"
import type { StateMachine as S } from "@zag-js/core"
+import type { DateRangePreset } from "@zag-js/date-utils"
import type { LiveRegion } from "@zag-js/live-region"
import type { Placement, PositioningOptions } from "@zag-js/popper"
import type { CommonProperties, Context, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types"
@@ -60,7 +61,7 @@ export type ElementIds = Partial<{
viewTrigger(view: DateView): string
clearTrigger: string
control: string
- input: string
+ input(index: number): string
trigger: string
monthSelect: string
yearSelect: string
@@ -168,11 +169,11 @@ interface PublicContext extends DirectionProperty, CommonProperties {
/**
* The format of the date to display in the input.
*/
- format?: (date: DateValue[]) => string
+ format?: (date: DateValue) => string
/**
* The format of the date to display in the input.
*/
- parse?: (value: string) => DateValue[]
+ parse?: (value: string) => DateValue
/**
* The view of the calendar
* @default "day"
@@ -213,11 +214,6 @@ type PrivateContext = Context<{
* The live region to announce changes
*/
announcer?: LiveRegion
- /**
- * @internal
- * The input element's value
- */
- inputValue: string
/**
* @internal
* The current hovered date. Useful for range selection mode.
@@ -282,6 +278,11 @@ type ComputedContext = Readonly<{
* The value text to display in the input.
*/
valueAsString: string[]
+ /**
+ * @internal
+ * The input element's value
+ */
+ formattedValue: string[]
}>
export type UserDefinedContext = RequiredBy
@@ -356,10 +357,18 @@ export interface TableProps {
id?: string
}
+export interface PresetTriggerProps {
+ value: DateValue[] | DateRangePreset
+}
+
export interface ViewProps {
view?: DateView
}
+export interface InputProps {
+ index?: number
+}
+
export interface MonthGridProps {
columns?: number
format?: "short" | "long"
@@ -414,6 +423,10 @@ export interface MachineApi {
* Returns the offset of the month based on the provided number of months.
*/
getOffset(duration: DateDuration): DateValueOffset
+ /**
+ * Returns the range of dates based on the provided date range preset.
+ */
+ getRangePresetValue(value: DateRangePreset): DateValue[]
/**
* Returns the weeks of the month from the provided date. Represented as an array of arrays of dates.
*/
@@ -572,9 +585,10 @@ export interface MachineApi {
clearTriggerProps: T["button"]
triggerProps: T["button"]
+ getPresetTriggerProps(props: PresetTriggerProps): T["button"]
getViewTriggerProps(props?: ViewProps): T["button"]
getViewControlProps(props?: ViewProps): T["element"]
- inputProps: T["input"]
+ getInputProps(props?: InputProps): T["input"]
monthSelectProps: T["select"]
yearSelectProps: T["select"]
}
diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts
index ae1d14ce16..cb7d3d5d68 100644
--- a/packages/machines/date-picker/src/date-picker.utils.ts
+++ b/packages/machines/date-picker/src/date-picker.utils.ts
@@ -1,6 +1,6 @@
import { DateFormatter, type DateValue } from "@internationalized/date"
import { match } from "@zag-js/utils"
-import type { DateView, MachineContext } from "./date-picker.types"
+import type { DateView } from "./date-picker.types"
export function adjustStartAndEndDate(value: DateValue[]) {
const [startDate, endDate] = value
@@ -18,29 +18,6 @@ export function sortDates(values: DateValue[]) {
return values.sort((a, b) => a.compare(b))
}
-export function formatValue(ctx: Pick) {
- const formatter = new DateFormatter(ctx.locale, {
- timeZone: ctx.timeZone,
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- })
-
- if (ctx.selectionMode === "range") {
- const [startDate, endDate] = ctx.value
- if (!startDate || !endDate) return ""
- return `${formatter.format(startDate.toDate(ctx.timeZone))} - ${formatter.format(endDate.toDate(ctx.timeZone))}`
- }
-
- if (ctx.selectionMode === "single") {
- const [startValue] = ctx.value
- if (!startValue) return ""
- return formatter.format(startValue.toDate(ctx.timeZone))
- }
-
- return ctx.value.map((date) => formatter.format(date.toDate(ctx.timeZone))).join(", ")
-}
-
export function getNextTriggerLabel(view: DateView) {
return match(view, {
year: "Switch to next decade",
diff --git a/packages/utilities/date-utils/src/index.ts b/packages/utilities/date-utils/src/index.ts
index 263f2aeb35..f8ae63c2b2 100644
--- a/packages/utilities/date-utils/src/index.ts
+++ b/packages/utilities/date-utils/src/index.ts
@@ -17,4 +17,5 @@ export * from "./get-year-range"
export * from "./mutation"
export * from "./pagination"
export * from "./parse-date"
+export * from "./preset"
export type { DateAdjustFn, DateGranularity } from "./types"
diff --git a/packages/utilities/date-utils/src/pagination.ts b/packages/utilities/date-utils/src/pagination.ts
index b91f36b738..c600d332fc 100644
--- a/packages/utilities/date-utils/src/pagination.ts
+++ b/packages/utilities/date-utils/src/pagination.ts
@@ -10,13 +10,22 @@ import { isDateInvalid } from "./assertion"
import { alignEnd, alignStart, constrainStart, constrainValue } from "./constrain"
import { getEndDate, getUnitDuration } from "./duration"
+export interface AdjustDateParams {
+ startDate: DateValue
+ focusedDate: DateValue
+}
+
+export interface AdjustDateReturn extends AdjustDateParams {
+ endDate: DateValue
+}
+
export function getAdjustedDateFn(
visibleDuration: DateDuration,
locale: string,
minValue?: DateValue,
maxValue?: DateValue,
) {
- return function getDate(options: { startDate: DateValue; focusedDate: DateValue }) {
+ return function getDate(options: AdjustDateParams): AdjustDateReturn {
const { startDate, focusedDate } = options
const endDate = getEndDate(startDate, visibleDuration)
@@ -32,8 +41,8 @@ export function getAdjustedDateFn(
if (focusedDate.compare(startDate) < 0) {
return {
startDate: alignEnd(focusedDate, visibleDuration, locale, minValue, maxValue),
- endDate,
focusedDate: constrainValue(focusedDate, minValue, maxValue),
+ endDate,
}
}
diff --git a/packages/utilities/date-utils/src/parse-date.ts b/packages/utilities/date-utils/src/parse-date.ts
index e873c02fdd..4fed39f798 100644
--- a/packages/utilities/date-utils/src/parse-date.ts
+++ b/packages/utilities/date-utils/src/parse-date.ts
@@ -1,24 +1,45 @@
-import { CalendarDateTime, DateFormatter } from "@internationalized/date"
+import { CalendarDate, DateFormatter, type DateValue } from "@internationalized/date"
-export function parseDateString(date: string, locale: string, timeZone: string) {
+const isValidYear = (year: string | undefined): year is string => year != null && year.length === 4
+const isValidMonth = (month: string | undefined): month is string => month != null && parseFloat(month) <= 12
+const isValidDay = (day: string | undefined): day is string => day != null && parseFloat(day) <= 31
+
+export function parseDateString(date: string, locale: string, timeZone: string): DateValue | undefined {
const regex = createRegex(locale, timeZone)
- const { year, month, day } = extract(regex, date) ?? {}
- if (year != null && year.length === 4 && month != null && +month <= 12 && day != null && +day <= 31) {
- return new CalendarDateTime(+year, +month, +day)
+ let { year, month, day } = extract(regex, date) ?? {}
+
+ const hasMatch = year != null || month != null || day != null
+
+ if (hasMatch) {
+ const curr = new Date()
+ year ||= curr.getFullYear().toString()
+ month ||= (curr.getMonth() + 1).toString()
+ day ||= curr.getDate().toString()
+ }
+
+ if (isValidYear(year) && isValidMonth(month) && isValidDay(day)) {
+ return new CalendarDate(+year, +month, +day)
}
+ // We should never get here, but just in case
const time = Date.parse(date)
if (!isNaN(time)) {
const date = new Date(time)
- return new CalendarDateTime(date.getFullYear(), date.getMonth() + 1, date.getDate())
+ return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}
}
function createRegex(locale: string, timeZone: string) {
const formatter = new DateFormatter(locale, { day: "numeric", month: "numeric", year: "numeric", timeZone })
const parts = formatter.formatToParts(new Date(2000, 11, 25))
- return parts.map(({ type, value }) => (type === "literal" ? value : `((?!=<${type}>)\\d+)`)).join("")
+ return parts.map(({ type, value }) => (type === "literal" ? `${value}?` : `((?!=<${type}>)\\d+)?`)).join("")
+}
+
+interface DateParts {
+ year: string
+ month: string
+ day: string
}
function extract(pattern: string | RegExp, str: string) {
@@ -33,16 +54,13 @@ function extract(pattern: string | RegExp, str: string) {
}
return group.match(/<(.+)>/)?.[1]
})
- .reduce(
- (acc, curr, index) => {
- if (!curr) return acc
- if (matches && matches.length > index) {
- acc[curr] = matches[index + 1]
- } else {
- acc[curr] = null
- }
- return acc
- },
- {} as { year: string; month: string; day: string },
- )
+ .reduce((acc, curr, index) => {
+ if (!curr) return acc
+ if (matches && matches.length > index) {
+ acc[curr] = matches[index + 1]
+ } else {
+ acc[curr] = null
+ }
+ return acc
+ }, {} as DateParts)
}
diff --git a/packages/utilities/date-utils/src/preset.ts b/packages/utilities/date-utils/src/preset.ts
new file mode 100644
index 0000000000..fceeba328f
--- /dev/null
+++ b/packages/utilities/date-utils/src/preset.ts
@@ -0,0 +1,63 @@
+import {
+ endOfMonth,
+ endOfWeek,
+ endOfYear,
+ now,
+ startOfMonth,
+ startOfWeek,
+ startOfYear,
+ type DateValue,
+} from "@internationalized/date"
+
+export type DateRangePreset =
+ | "thisWeek"
+ | "lastWeek"
+ | "thisMonth"
+ | "lastMonth"
+ | "thisQuarter"
+ | "lastQuarter"
+ | "thisYear"
+ | "lastYear"
+ | "last3Days"
+ | "last7Days"
+ | "last14Days"
+ | "last30Days"
+ | "last90Days"
+
+export function getDateRangePreset(preset: DateRangePreset, locale: string, timeZone: string): [DateValue, DateValue] {
+ const today = now(timeZone)
+
+ switch (preset) {
+ case "thisWeek":
+ return [startOfWeek(today, locale), endOfWeek(today, locale)]
+ case "thisMonth":
+ return [startOfMonth(today), today]
+ case "thisQuarter":
+ return [startOfMonth(today).add({ months: -today.month % 3 }), today]
+ case "thisYear":
+ return [startOfYear(today), today]
+ case "last3Days":
+ return [today.add({ days: -2 }), today]
+ case "last7Days":
+ return [today.add({ days: -6 }), today]
+ case "last14Days":
+ return [today.add({ days: -13 }), today]
+ case "last30Days":
+ return [today.add({ days: -29 }), today]
+ case "last90Days":
+ return [today.add({ days: -89 }), today]
+ case "lastMonth":
+ return [startOfMonth(today.add({ months: -1 })), endOfMonth(today.add({ months: -1 }))]
+ case "lastQuarter":
+ return [
+ startOfMonth(today.add({ months: (-today.month % 3) - 3 })),
+ endOfMonth(today.add({ months: (-today.month % 3) - 1 })),
+ ]
+ case "lastWeek":
+ return [startOfWeek(today, locale).add({ weeks: -1 }), endOfWeek(today, locale).add({ weeks: -1 })]
+ case "lastYear":
+ return [startOfYear(today.add({ years: -1 })), endOfYear(today.add({ years: -1 }))]
+ default:
+ throw new Error(`Invalid date range preset: ${preset}`)
+ }
+}
diff --git a/packages/utilities/date-utils/tests/parse-date.test.ts b/packages/utilities/date-utils/tests/parse-date.test.ts
new file mode 100644
index 0000000000..66b2bd9a6b
--- /dev/null
+++ b/packages/utilities/date-utils/tests/parse-date.test.ts
@@ -0,0 +1,41 @@
+import { parseDateString } from "../src"
+
+describe("parse date", () => {
+ test("with month", () => {
+ const today = new Date()
+ const date = parseDateString("03", "en-US", "UTC")
+ expect(date).contain({
+ month: 3,
+ day: today.getDate(),
+ year: today.getFullYear(),
+ })
+ })
+
+ test("with just month/day", () => {
+ const today = new Date()
+ const date = parseDateString("03/28", "en-US", "UTC")
+ expect(date).contain({
+ month: 3,
+ day: 28,
+ year: today.getFullYear(),
+ })
+ })
+
+ test("with just month/day/year", () => {
+ const date = parseDateString("03/28/2023", "en-US", "UTC")
+ expect(date).contain({
+ month: 3,
+ day: 28,
+ year: 2023,
+ })
+ })
+
+ test("with just month/day/year [shortform]", () => {
+ const date = parseDateString("03/28/23", "en-US", "UTC")
+ expect(date).contain({
+ month: 3,
+ day: 28,
+ year: 2023,
+ })
+ })
+})
diff --git a/shared/src/style.css b/shared/src/style.css
index aa92e336f0..7ed710fdbb 100644
--- a/shared/src/style.css
+++ b/shared/src/style.css
@@ -1482,7 +1482,7 @@ main [data-testid="scrubber"] {
}
[data-scope="date-picker"][data-part="table-cell-trigger"][data-focused] {
- background: rgba(128, 0, 128, 0.171);
+ background: rgba(165, 151, 165, 0.085);
color: rgba(128, 0, 128, 0.959);
}
@@ -1491,8 +1491,8 @@ main [data-testid="scrubber"] {
}
[data-scope="date-picker"][data-part="table-cell-trigger"][data-selected] {
- background: purple;
- color: white;
+ background: purple !important;
+ color: white !important ;
}
[data-scope="date-picker"][data-part="table-cell-trigger"][data-in-range]:not([data-selected]) {