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}`}

-
Selected: {api.valueAsString ?? "-"}
+
Selected: {api.valueAsString.join(", ") ?? "-"}
Focused: {api.focusedValueAsString}
- + +
@@ -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]) {