From 88c58d49a95a4bae9457f73b99af9a65314def82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sun, 24 Aug 2025 00:13:03 +0200 Subject: [PATCH 01/23] feat: implement segmets getter --- .../next-ts/pages/date-picker-segment.tsx | 152 ++++++++++++ .../date-picker/src/date-picker.connect.ts | 25 +- .../date-picker/src/date-picker.machine.ts | 34 ++- .../date-picker/src/date-picker.props.ts | 1 + .../date-picker/src/date-picker.types.ts | 78 +++++- .../date-picker/src/date-picker.utils.ts | 228 +++++++++++++++++- shared/src/routes.ts | 1 + 7 files changed, 511 insertions(+), 8 deletions(-) create mode 100644 examples/next-ts/pages/date-picker-segment.tsx diff --git a/examples/next-ts/pages/date-picker-segment.tsx b/examples/next-ts/pages/date-picker-segment.tsx new file mode 100644 index 0000000000..59df67ad95 --- /dev/null +++ b/examples/next-ts/pages/date-picker-segment.tsx @@ -0,0 +1,152 @@ +import * as datePicker from "@zag-js/date-picker" +import { normalizeProps, useMachine } from "@zag-js/react" +import { datePickerControls } from "@zag-js/shared" +import { useId } from "react" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" + +export default function Page() { + const controls = useControls(datePickerControls) + const service = useMachine(datePicker.machine, { + id: useId(), + locale: "en", + selectionMode: "single", + ...controls.context, + }) + + const api = datePicker.connect(service, normalizeProps) + + return ( + <> +
+
+ +
+

{`Visible range: ${api.visibleRangeText.formatted}`}

+ + +
Selected: {api.valueAsString ?? "-"}
+
Focused: {api.focusedValueAsString}
+
+ +
+
+ {api.getSegments().map((segment, i) => ( + + {segment.text} + + ))} +
+ + +
+ +
+
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + ) +} diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 56a0a663af..8ef5043bc2 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -30,10 +30,11 @@ import type { TableCellProps, TableCellState, TableProps, + SegmentProps, + SegmentState, } from "./date-picker.types" import { adjustStartAndEndDate, - defaultTranslations, ensureValidCharacters, getInputPlaceholder, getLocaleSeparator, @@ -79,7 +80,7 @@ export function connect( }) const separator = getLocaleSeparator(locale) - const translations = { ...defaultTranslations, ...prop("translations") } + const translations = prop("translations") function getMonthWeeks(from = startValue) { const numOfWeeks = prop("fixedWeeks") ? 6 : undefined @@ -223,6 +224,11 @@ export function connect( return [view, id].filter(Boolean).join(" ") } + function getSegmentState(props: SegmentProps): SegmentState { + const {} = props + return {} + } + return { focused, open, @@ -803,6 +809,21 @@ export function connect( }) }, + getSegments(props = {}) { + const { index = 0 } = props + console.log(computed("segments")) + + return computed("segments")[index] ?? [] + }, + + getSegmentState, + + getSegmentProps(props = {}) { + const {} = props + + return {} + }, + getMonthSelectProps() { return normalize.select({ ...parts.monthSelect.attrs, diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index bca70783b6..6aaa68eae1 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -24,16 +24,18 @@ import { disableTextSelection, raf, restoreTextSelection, setElementValue } from import { createLiveRegion } from "@zag-js/live-region" import { getPlacement, type Placement } from "@zag-js/popper" import * as dom from "./date-picker.dom" -import type { DatePickerSchema, DateValue, DateView } from "./date-picker.types" +import type { DatePickerSchema, DateValue, DateView, ValidSegments } from "./date-picker.types" import { adjustStartAndEndDate, clampView, + defaultTranslations, eachView, getNextView, getPreviousView, isAboveMinView, isBelowMinView, isValidDate, + processSegments, sortDates, } from "./date-picker.utils" @@ -72,6 +74,8 @@ export const machine = createMachine({ const minView: DateView = "day" const maxView: DateView = "year" const defaultView = clampView(props.view || minView, minView, maxView) + const granularity = props.granularity || "day" + const translations = { ...defaultTranslations, ...props.translations } return { locale, @@ -91,6 +95,7 @@ export const machine = createMachine({ return parseDateString(value, locale, timeZone) }, ...props, + translations, focusedValue: typeof props.focusedValue === "undefined" ? undefined : focusedValue, defaultFocusedValue: focusedValue, value, @@ -99,6 +104,7 @@ export const machine = createMachine({ placement: "bottom", ...props.positioning, }, + granularity, } }, @@ -176,6 +182,9 @@ export const machine = createMachine({ restoreFocus: bindable(() => ({ defaultValue: false, })), + validSegments: bindable(() => ({ + defaultValue: [{}, {}], + })), } }, @@ -200,6 +209,29 @@ export const machine = createMachine({ const value = context.get("value") return value.map((date) => prop("format")(date, { locale: prop("locale"), timeZone: prop("timeZone") })) }, + segments: ({ context, prop }) => { + const value = context.get("value") + const timeZone = prop("timeZone") + const translations = prop("translations") + const formatter = new DateFormatter(prop("locale"), { + timeZone, + day: "2-digit", + month: "2-digit", + year: "numeric", + }) // TODO: move globally + + return value.map((date, i) => { + return processSegments({ + dateValue: date.toDate(timeZone), + displayValue: date, + validSegments: context.get("validSegments")[i], + formatter, + locale: prop("locale"), + translations, + granularity: prop("granularity"), + }) + }) + }, }, effects: ["setupLiveRegion"], diff --git a/packages/machines/date-picker/src/date-picker.props.ts b/packages/machines/date-picker/src/date-picker.props.ts index 35eead8593..b6d108315b 100644 --- a/packages/machines/date-picker/src/date-picker.props.ts +++ b/packages/machines/date-picker/src/date-picker.props.ts @@ -48,6 +48,7 @@ export const props = createProps()([ "outsideDaySelectable", "minView", "maxView", + "granularity", ]) export const splitProps = createSplitProps>(props) diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index b1142246bf..f6188eb572 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -8,10 +8,11 @@ import type { ZonedDateTime, } from "@internationalized/date" import type { Machine, Service } from "@zag-js/core" -import type { DateRangePreset } from "@zag-js/date-utils" +import type { DateRangePreset, DateGranularity } from "@zag-js/date-utils" import type { LiveRegion } from "@zag-js/live-region" import type { Placement, PositioningOptions } from "@zag-js/popper" import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" +import type { EDITABLE_SEGMENTS } from "./date-picker.utils" /* ----------------------------------------------------------------------------- * Callback details @@ -249,6 +250,10 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties { * Whether to render the date picker inline */ inline?: boolean | undefined + /** + * Determines the smallest unit that is displayed in the date picker. By default, this is `"day"`. + */ + granularity?: DateGranularity | undefined } type PropsWithDefault = @@ -343,6 +348,10 @@ type ComputedContext = Readonly<{ * The value text to display in the input. */ valueAsString: string[] + /** + * A list of segments for the selected date(s). + */ + segments: DateSegment[][] }> type Refs = { @@ -401,6 +410,64 @@ export interface TableCellState { readonly disabled: boolean } +export interface SegmentsProps { + index?: number | undefined +} + +export type SegmentType = + | "era" + | "year" + | "month" + | "day" + | "hour" + | "minute" + | "second" + | "dayPeriod" + | "literal" + | "timeZoneName" + +export type ValidSegments = Partial + +export interface DateSegment { + /** + * The type of segment. + */ + type: SegmentType + /** + * The formatted text for the segment. + */ + text: string + /** + * The numeric value for the segment, if applicable. + */ + value?: number + /** + * The minimum numeric value for the segment, if applicable. + */ + minValue?: number + /** + * The maximum numeric value for the segment, if applicable. + */ + maxValue?: number + /** + * Whether the value is a placeholder. + */ + isPlaceholder: boolean + /** + * A placeholder string for the segment. + */ + placeholder: string + /** + * Whether the segment is editable. + */ + isEditable: boolean +} + +export interface SegmentProps { + segment?: DateSegment +} + +export interface SegmentState {} export interface DayTableCellProps { value: DateValue disabled?: boolean | undefined @@ -648,6 +715,14 @@ export interface DatePickerApi { * Returns the state details for a given year cell. */ getYearTableCellState: (props: TableCellProps) => TableCellState + /** + * + */ + getSegments: (props?: SegmentsProps) => DateSegment[] + /** + * Returns the state details for a given segment. + */ + getSegmentState: (props: SegmentProps) => SegmentState getRootProps: () => T["element"] getLabelProps: (props?: LabelProps) => T["label"] @@ -682,6 +757,7 @@ export interface DatePickerApi { getViewTriggerProps: (props?: ViewProps) => T["button"] getViewControlProps: (props?: ViewProps) => T["element"] getInputProps: (props?: InputProps) => T["input"] + getSegmentProps: (props?: SegmentProps) => T["element"] getMonthSelectProps: () => T["select"] getYearSelectProps: () => 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 14901a7422..837a40e020 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -1,6 +1,14 @@ -import { DateFormatter, type DateValue } from "@internationalized/date" +import { + createCalendar, + DateFormatter, + getMinimumDayInMonth, + getMinimumMonthInYear, + type CalendarIdentifier, + type DateValue, +} from "@internationalized/date" import { clampValue, match } from "@zag-js/utils" -import type { DateView, IntlTranslations } from "./date-picker.types" +import type { Calendar, DateSegment, DateView, IntlTranslations, ValidSegments } from "./date-picker.types" +import type { DateGranularity } from "@zag-js/date-utils" export function adjustStartAndEndDate(value: DateValue[]) { const [startDate, endDate] = value @@ -95,9 +103,24 @@ export const defaultTranslations: IntlTranslations = { day: "Switch to next month", }) }, - // TODO: Revisit this placeholder() { - return { day: "dd", month: "mm", year: "yyyy" } + const placeholders: Record = { + day: "dd", + month: "mm", + year: "yyyy", + hour: "hh", + minute: "mm", + second: "ss", + dayPeriod: "AM/PM", + era: "era", + literal: "", // TODO: investigate what this should be + timeZoneName: "timeZone", + weekday: "weekday", + unknown: "unknown", + fractionalSecond: "ff", + } + + return placeholders }, content: "calendar", monthSelect: "Select month", @@ -149,3 +172,200 @@ const views: DateView[] = ["day", "month", "year"] export function eachView(cb: (view: DateView) => void) { views.forEach((view) => cb(view)) } + +// --------------------------------------------------- +// SEGMENT +// --------------------------------------------------- + +export const EDITABLE_SEGMENTS = { + year: true, + month: true, + day: true, + hour: true, + minute: true, + second: true, + dayPeriod: true, + era: true, + literal: false, + timeZoneName: false, + weekday: false, + unknown: false, + fractionalSecond: true, +} as const satisfies Record + +const TYPE_MAPPING = { + // Node seems to convert everything to lowercase... + dayperiod: "dayPeriod", + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts#named_years + relatedYear: "year", + yearName: "literal", // not editable + unknown: "literal", +} as const + +function getSafeType(type: TType): TType { + console.log(type) + return (TYPE_MAPPING as any)[type] ?? type +} + +function getPlaceholder(type: keyof ValidSegments, translations: IntlTranslations, locale: string): string { + return translations.placeholder(locale)[type] +} + +interface ProcessSegmentsProps { + dateValue: Date + displayValue: DateValue + validSegments: ValidSegments + formatter: DateFormatter + locale: string + translations: IntlTranslations + granularity: DateGranularity +} + +export function processSegments({ + dateValue, + displayValue, + validSegments, + formatter, + locale, + translations, + granularity, +}: ProcessSegmentsProps): DateSegment[] { + const timeValue = ["hour", "minute", "second"] + const segments = formatter.formatToParts(dateValue) + const resolvedOptions = formatter.resolvedOptions() + const processedSegments: DateSegment[] = [] + + for (const segment of segments) { + const type = getSafeType(segment.type) + let isEditable = EDITABLE_SEGMENTS[type] + if (type === "era" && displayValue.calendar.getEras().length === 1) { + isEditable = false + } + + const isPlaceholder = EDITABLE_SEGMENTS[type] && !validSegments[type] + const placeholder = EDITABLE_SEGMENTS[type] ? getPlaceholder(type, translations, locale) : null + + const dateSegment = { + type, + text: isPlaceholder ? placeholder : segment.value, + ...getSegmentLimits(displayValue, type, resolvedOptions), + isPlaceholder, + placeholder, + isEditable, + } as DateSegment + + // There is an issue in RTL languages where time fields render (minute:hour) instead of (hour:minute). + // To force an LTR direction on the time field since, we wrap the time segments in LRI (left-to-right) isolate unicode. See https://www.w3.org/International/questions/qa-bidi-unicode-controls. + // These unicode characters will be added to the array of processed segments as literals and will mark the start and end of the embedded direction change. + if (type === "hour") { + // This marks the start of the embedded direction change. + processedSegments.push({ + type: "literal", + text: "\u2066", + ...getSegmentLimits(displayValue, "literal", resolvedOptions), + isPlaceholder: false, + placeholder: "", + isEditable: false, + }) + processedSegments.push(dateSegment) + // This marks the end of the embedded direction change in the case that the granularity it set to "hour". + if (type === granularity) { + processedSegments.push({ + type: "literal", + text: "\u2069", + ...getSegmentLimits(displayValue, "literal", resolvedOptions), + isPlaceholder: false, + placeholder: "", + isEditable: false, + }) + } + } else if (timeValue.includes(type) && type === granularity) { + processedSegments.push(dateSegment) + // This marks the end of the embedded direction change. + processedSegments.push({ + type: "literal", + text: "\u2069", + ...getSegmentLimits(displayValue, "literal", resolvedOptions), + isPlaceholder: false, + placeholder: "", + isEditable: false, + }) + } else { + // We only want to "wrap" the unicode around segments that are hour, minute, or second. If they aren't, just process as normal. + processedSegments.push(dateSegment) + } + } + + return processedSegments +} + +function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedDateTimeFormatOptions) { + switch (type) { + case "era": { + const eras = date.calendar.getEras() + return { + value: eras.indexOf(date.era), + minValue: 0, + maxValue: eras.length - 1, + } + } + case "year": + return { + value: date.year, + minValue: 1, + maxValue: date.calendar.getYearsInEra(date), + } + case "month": + return { + value: date.month, + minValue: getMinimumMonthInYear(date), + maxValue: date.calendar.getMonthsInYear(date), + } + case "day": + return { + value: date.day, + minValue: getMinimumDayInMonth(date), + maxValue: date.calendar.getDaysInMonth(date), + } + } + + if ("hour" in date) { + switch (type) { + case "dayPeriod": + return { + value: date.hour >= 12 ? 12 : 0, + minValue: 0, + maxValue: 12, + } + case "hour": + if (options.hour12) { + const isPM = date.hour >= 12 + return { + value: date.hour, + minValue: isPM ? 12 : 0, + maxValue: isPM ? 23 : 11, + } + } + + return { + value: date.hour, + minValue: 0, + maxValue: 23, + } + case "minute": + return { + value: date.minute, + minValue: 0, + maxValue: 59, + } + case "second": + return { + value: date.second, + minValue: 0, + maxValue: 59, + } + } + } + + return {} +} diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 913105e042..a375a34a53 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -37,6 +37,7 @@ export const routesData: RouteData[] = [ { label: "Date Picker (Inline)", path: "/date-picker-inline" }, { label: "Date Picker (Month + Range)", path: "/date-picker-month-range" }, { label: "Date Picker (Year + Range)", path: "/date-picker-year-range" }, + { label: "Date Picker (Segment)", path: "/date-picker-segment" }, { label: "Select", path: "/select" }, { label: "Accordion", path: "/accordion" }, { label: "Checkbox", path: "/checkbox" }, From c00b0504741cfcc0c39bad0a51041864cf228c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Tue, 26 Aug 2025 09:40:00 +0200 Subject: [PATCH 02/23] feat: wip on segments --- .../date-picker/src/date-picker.machine.ts | 25 ++++++++++++++++--- .../date-picker/src/date-picker.types.ts | 2 +- .../date-picker/src/date-picker.utils.ts | 11 ++++---- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 6aaa68eae1..4d979146f0 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -24,12 +24,13 @@ import { disableTextSelection, raf, restoreTextSelection, setElementValue } from import { createLiveRegion } from "@zag-js/live-region" import { getPlacement, type Placement } from "@zag-js/popper" import * as dom from "./date-picker.dom" -import type { DatePickerSchema, DateValue, DateView, ValidSegments } from "./date-picker.types" +import type { DatePickerSchema, DateValue, DateView, Segments } from "./date-picker.types" import { adjustStartAndEndDate, clampView, defaultTranslations, eachView, + EDITABLE_SEGMENTS, getNextView, getPreviousView, isAboveMinView, @@ -37,6 +38,7 @@ import { isValidDate, processSegments, sortDates, + TYPE_MAPPING, } from "./date-picker.utils" const { and } = createGuards() @@ -182,9 +184,24 @@ export const machine = createMachine({ restoreFocus: bindable(() => ({ defaultValue: false, })), - validSegments: bindable(() => ({ - defaultValue: [{}, {}], - })), + validSegments: bindable(() => { + const formatter = new DateFormatter(prop("locale"), { + timeZone: prop("timeZone"), + day: "2-digit", + month: "2-digit", + year: "numeric", + }) // TODO: move globally + const allSegments = formatter + .formatToParts(new Date()) + .filter((seg) => EDITABLE_SEGMENTS[seg.type]) + .reduce((p, seg) => ((p[TYPE_MAPPING[seg.type] || seg.type] = true), p), {}) + + const dateValue = prop("value") || prop("defaultValue") + + return { + defaultValue: dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}], + } + }), } }, diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index f6188eb572..3b59e6ac39 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -426,7 +426,7 @@ export type SegmentType = | "literal" | "timeZoneName" -export type ValidSegments = Partial +export type Segments = Partial export interface DateSegment { /** diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index 837a40e020..b1271ca417 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -7,7 +7,7 @@ import { type DateValue, } from "@internationalized/date" import { clampValue, match } from "@zag-js/utils" -import type { Calendar, DateSegment, DateView, IntlTranslations, ValidSegments } from "./date-picker.types" +import type { Calendar, DateSegment, DateView, IntlTranslations, Segments } from "./date-picker.types" import type { DateGranularity } from "@zag-js/date-utils" export function adjustStartAndEndDate(value: DateValue[]) { @@ -104,7 +104,7 @@ export const defaultTranslations: IntlTranslations = { }) }, placeholder() { - const placeholders: Record = { + const placeholders: Record = { day: "dd", month: "mm", year: "yyyy", @@ -193,7 +193,7 @@ export const EDITABLE_SEGMENTS = { fractionalSecond: true, } as const satisfies Record -const TYPE_MAPPING = { +export const TYPE_MAPPING = { // Node seems to convert everything to lowercase... dayperiod: "dayPeriod", // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts#named_years @@ -203,18 +203,17 @@ const TYPE_MAPPING = { } as const function getSafeType(type: TType): TType { - console.log(type) return (TYPE_MAPPING as any)[type] ?? type } -function getPlaceholder(type: keyof ValidSegments, translations: IntlTranslations, locale: string): string { +function getPlaceholder(type: keyof Segments, translations: IntlTranslations, locale: string): string { return translations.placeholder(locale)[type] } interface ProcessSegmentsProps { dateValue: Date displayValue: DateValue - validSegments: ValidSegments + validSegments: Segments formatter: DateFormatter locale: string translations: IntlTranslations From 53f04c76d4e5f54ab4a645f15dda479c376ce710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Thu, 28 Aug 2025 00:02:23 +0200 Subject: [PATCH 03/23] feat: implement placeholder --- .../next-ts/pages/date-picker-segment.tsx | 2 +- .../date-picker/src/date-picker.anatomy.ts | 1 + .../date-picker/src/date-picker.connect.ts | 62 +++++++++++++++++-- .../date-picker/src/date-picker.machine.ts | 40 +++++++++--- .../date-picker/src/date-picker.types.ts | 18 +++++- .../date-picker/src/date-picker.utils.ts | 11 +--- shared/src/css/date-picker.css | 6 ++ 7 files changed, 116 insertions(+), 24 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment.tsx b/examples/next-ts/pages/date-picker-segment.tsx index 59df67ad95..0fde70c895 100644 --- a/examples/next-ts/pages/date-picker-segment.tsx +++ b/examples/next-ts/pages/date-picker-segment.tsx @@ -31,7 +31,7 @@ export default function Page() {
-
+
{api.getSegments().map((segment, i) => ( {segment.text} diff --git a/packages/machines/date-picker/src/date-picker.anatomy.ts b/packages/machines/date-picker/src/date-picker.anatomy.ts index a4cc4d3e99..d2458aa63e 100644 --- a/packages/machines/date-picker/src/date-picker.anatomy.ts +++ b/packages/machines/date-picker/src/date-picker.anatomy.ts @@ -5,6 +5,7 @@ export const anatomy = createAnatomy("date-picker").parts( "content", "control", "input", + "segment", "label", "monthSelect", "nextTrigger", diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 8ef5043bc2..bcacc080cb 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -809,19 +809,71 @@ export function connect( }) }, + getSegmentInputProps() { + return normalize.element({ + ...parts.input.attrs, + id: dom.getInputId(scope, 0), // FIXIT: figure out the index + dir: prop("dir"), + "data-state": open ? "open" : "closed", + role: "presentation", + readOnly, + disabled, + style: { + unicodeBidi: "isolate", + }, + }) + }, + getSegments(props = {}) { const { index = 0 } = props - console.log(computed("segments")) - return computed("segments")[index] ?? [] }, getSegmentState, - getSegmentProps(props = {}) { - const {} = props + getSegmentProps(props) { + const { segment } = props + + if (segment.type === "literal") { + return normalize.element({ + ...parts.segment.attrs, + dir: prop("dir"), + "aria-hidden": true, + "data-type": segment.type, + "data-readonly": dataAttr(true), + "data-disabled": dataAttr(true), + }) + } - return {} + return { + ...parts.segment.attrs, + dir: prop("dir"), + role: "spinbutton", + tabIndex: segment.isEditable && !readOnly && !disabled ? 0 : -1, + autoComplete: "off", + autoCorrect: "off", + spellCheck: "false", + contentEditable: "true", + inputMode: segment.type !== "dayPeriod" ? "numeric" : undefined, + enterKeyHint: "next", + "data-placeholder": segment.isPlaceholder, + "aria-labelledby": dom.getInputId(scope, 0), // FIXIT: figure out the index + // "aria-label": translations.segmentLabel(segment), + "aria-valuenow": segment.value, + "aria-valuetext": segment.text, + "aria-valuemin": segment.minValue, + "aria-valuemax": segment.maxValue, + "aria-readonly": ariaAttr(!segment.isEditable || readOnly), + "aria-disabled": ariaAttr(disabled), + "data-value": segment.value, + "data-type": segment.type, + "data-readonly": dataAttr(!segment.isEditable || readOnly), + "data-disabled": dataAttr(disabled), + "data-editable": dataAttr(segment.isEditable && !readOnly && !disabled), + style: { + "caret-color": "transparent", + }, + } }, getMonthSelectProps() { diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 1aefad765e..a3754757bf 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -202,6 +202,19 @@ export const machine = createMachine({ defaultValue: dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}], } }), + placeholderDate: bindable(() => { + const timeZone = prop("timeZone") + + // Create a placeholder date similar to React Spectrum's createPlaceholderDate + // We use today's date as the base, but this can be customized via placeholderValue prop if needed + const placeholderValue = getTodayDate(timeZone) + + return { + defaultValue: placeholderValue, + isEqual: isDateEqual, + hash: (v) => v.toString(), + } + }), } }, @@ -224,9 +237,13 @@ export const machine = createMachine({ !isNextRangeInvalid(computed("endValue"), prop("min"), prop("max")), valueAsString: ({ context, prop }) => getValueAsString(context.get("value"), prop), segments: ({ context, prop }) => { - const value = context.get("value") + const value = prop("value") + const selectionMode = prop("selectionMode") + const placeholderDate = context.get("placeholderDate") + const validSegments = context.get("validSegments") const timeZone = prop("timeZone") - const translations = prop("translations") + const translations = prop("translations") || defaultTranslations + const granularity = prop("granularity") const formatter = new DateFormatter(prop("locale"), { timeZone, day: "2-digit", @@ -234,15 +251,24 @@ export const machine = createMachine({ year: "numeric", }) // TODO: move globally - return value.map((date, i) => { + let dates: DateValue[] = value?.length ? value : [placeholderDate] + + if (selectionMode === "range") { + dates = value?.length ? value : [placeholderDate, placeholderDate] + } + + return dates.map((date, i) => { + const displayValue = date || placeholderDate + const currentValidSegments = validSegments?.[i] || {} + return processSegments({ - dateValue: date.toDate(timeZone), - displayValue: date, - validSegments: context.get("validSegments")[i], + dateValue: displayValue.toDate(timeZone), + displayValue, + validSegments: currentValidSegments, formatter, locale: prop("locale"), translations, - granularity: prop("granularity"), + granularity, }) }) }, diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index 3b59e6ac39..b157b2e673 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -270,6 +270,8 @@ type PropsWithDefault = | "parse" | "defaultFocusedValue" | "outsideDaySelectable" + | "granularity" + | "translations" interface PrivateContext { /** @@ -313,6 +315,14 @@ interface PrivateContext { * The focused date. */ focusedValue: DateValue + /** + * The valid segments for each date value (tracks which segments have been filled). + */ + validSegments: Segments[] + /** + * The placeholder date to use when segments are not filled. + */ + placeholderDate: DateValue } type ComputedContext = Readonly<{ @@ -464,7 +474,7 @@ export interface DateSegment { } export interface SegmentProps { - segment?: DateSegment + segment: DateSegment } export interface SegmentState {} @@ -715,6 +725,10 @@ export interface DatePickerApi { * Returns the state details for a given year cell. */ getYearTableCellState: (props: TableCellProps) => TableCellState + /** + * + */ + getSegmentInputProps: () => T["element"] /** * */ @@ -757,7 +771,7 @@ export interface DatePickerApi { getViewTriggerProps: (props?: ViewProps) => T["button"] getViewControlProps: (props?: ViewProps) => T["element"] getInputProps: (props?: InputProps) => T["input"] - getSegmentProps: (props?: SegmentProps) => T["element"] + getSegmentProps: (props: SegmentProps) => T["element"] getMonthSelectProps: () => T["select"] getYearSelectProps: () => 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 b1271ca417..1ba11cd0dd 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -1,13 +1,6 @@ -import { - createCalendar, - DateFormatter, - getMinimumDayInMonth, - getMinimumMonthInYear, - type CalendarIdentifier, - type DateValue, -} from "@internationalized/date" +import { DateFormatter, getMinimumDayInMonth, getMinimumMonthInYear, type DateValue } from "@internationalized/date" import { clampValue, match } from "@zag-js/utils" -import type { Calendar, DateSegment, DateView, IntlTranslations, Segments } from "./date-picker.types" +import type { DateSegment, DateView, IntlTranslations, Segments } from "./date-picker.types" import type { DateGranularity } from "@zag-js/date-utils" export function adjustStartAndEndDate(value: DateValue[]) { diff --git a/shared/src/css/date-picker.css b/shared/src/css/date-picker.css index c6e37a1d0d..3ae453b481 100644 --- a/shared/src/css/date-picker.css +++ b/shared/src/css/date-picker.css @@ -37,6 +37,12 @@ margin-bottom: 16px; } +[data-scope="date-picker"][data-part="segment"] { + font-variant-numeric: tabular-nums; + text-align: end; + padding: 0 2px; +} + [data-scope="date-picker"][data-part="table-cell-trigger"][data-today] { color: purple; } From f75ca3a68ef9b4867c71d23c9214d816f35e9d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Thu, 28 Aug 2025 23:25:50 +0200 Subject: [PATCH 04/23] feat: fill in segments on value select --- .../next-ts/pages/date-picker-segment.tsx | 2 +- .../date-picker/src/date-picker.anatomy.ts | 1 + .../date-picker/src/date-picker.connect.ts | 3 +- .../date-picker/src/date-picker.machine.ts | 77 ++++++++++--------- .../date-picker/src/date-picker.types.ts | 16 ++-- shared/src/css/date-picker.css | 26 ++++++- 6 files changed, 78 insertions(+), 47 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment.tsx b/examples/next-ts/pages/date-picker-segment.tsx index 0fde70c895..55dc45fdf6 100644 --- a/examples/next-ts/pages/date-picker-segment.tsx +++ b/examples/next-ts/pages/date-picker-segment.tsx @@ -33,7 +33,7 @@ export default function Page() {
{api.getSegments().map((segment, i) => ( - + {segment.text} ))} diff --git a/packages/machines/date-picker/src/date-picker.anatomy.ts b/packages/machines/date-picker/src/date-picker.anatomy.ts index d2458aa63e..362036671a 100644 --- a/packages/machines/date-picker/src/date-picker.anatomy.ts +++ b/packages/machines/date-picker/src/date-picker.anatomy.ts @@ -5,6 +5,7 @@ export const anatomy = createAnatomy("date-picker").parts( "content", "control", "input", + "segmentInput", "segment", "label", "monthSelect", diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index bcacc080cb..4ae5b3207b 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -709,6 +709,7 @@ export function connect( "data-state": open ? "open" : "closed", "aria-haspopup": "grid", disabled, + "data-readonly": dataAttr(readOnly), onClick(event) { if (event.defaultPrevented) return if (!interactive) return @@ -811,7 +812,7 @@ export function connect( getSegmentInputProps() { return normalize.element({ - ...parts.input.attrs, + ...parts.segmentInput.attrs, id: dom.getInputId(scope, 0), // FIXIT: figure out the index dir: prop("dir"), "data-state": open ? "open" : "closed", diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index a3754757bf..5f071ad5e1 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -119,9 +119,10 @@ export const machine = createMachine({ return open ? "open" : "idle" }, - refs() { + refs({ prop }) { return { announcer: undefined, + placeholderDate: getTodayDate(prop("timeZone")), } }, @@ -184,37 +185,6 @@ export const machine = createMachine({ restoreFocus: bindable(() => ({ defaultValue: false, })), - validSegments: bindable(() => { - const formatter = new DateFormatter(prop("locale"), { - timeZone: prop("timeZone"), - day: "2-digit", - month: "2-digit", - year: "numeric", - }) // TODO: move globally - const allSegments = formatter - .formatToParts(new Date()) - .filter((seg) => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => ((p[TYPE_MAPPING[seg.type] || seg.type] = true), p), {}) - - const dateValue = prop("value") || prop("defaultValue") - - return { - defaultValue: dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}], - } - }), - placeholderDate: bindable(() => { - const timeZone = prop("timeZone") - - // Create a placeholder date similar to React Spectrum's createPlaceholderDate - // We use today's date as the base, but this can be customized via placeholderValue prop if needed - const placeholderValue = getTodayDate(timeZone) - - return { - defaultValue: placeholderValue, - isEqual: isDateEqual, - hash: (v) => v.toString(), - } - }), } }, @@ -236,11 +206,29 @@ export const machine = createMachine({ isNextVisibleRangeValid: ({ prop, computed }) => !isNextRangeInvalid(computed("endValue"), prop("min"), prop("max")), valueAsString: ({ context, prop }) => getValueAsString(context.get("value"), prop), - segments: ({ context, prop }) => { - const value = prop("value") + validSegments: ({ context, prop }) => { + const formatter = new DateFormatter(prop("locale"), { + timeZone: prop("timeZone"), + day: "2-digit", + month: "2-digit", + year: "numeric", + }) // TODO: move globally + const allSegments = formatter + .formatToParts(new Date()) + .filter((seg) => EDITABLE_SEGMENTS[seg.type]) + .reduce((p, seg) => ((p[TYPE_MAPPING[seg.type] || seg.type] = true), p), {}) + + const dateValue = context.get("value") + + console.log("validSegments: ", dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}]) + + return dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}] + }, + segments: ({ context, prop, refs, computed }) => { + const value = context.get("value") const selectionMode = prop("selectionMode") - const placeholderDate = context.get("placeholderDate") - const validSegments = context.get("validSegments") + const placeholderDate = refs.get("placeholderDate") + const validSegments = computed("validSegments") const timeZone = prop("timeZone") const translations = prop("translations") || defaultTranslations const granularity = prop("granularity") @@ -257,6 +245,23 @@ export const machine = createMachine({ dates = value?.length ? value : [placeholderDate, placeholderDate] } + console.log( + dates.map((date, i) => { + const displayValue = date || placeholderDate + const currentValidSegments = validSegments?.[i] || {} + + return processSegments({ + dateValue: displayValue.toDate(timeZone), + displayValue, + validSegments: currentValidSegments, + formatter, + locale: prop("locale"), + translations, + granularity, + }) + }), + ) + return dates.map((date, i) => { const displayValue = date || placeholderDate const currentValidSegments = validSegments?.[i] || {} diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index b157b2e673..9dd7f87319 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -315,14 +315,6 @@ interface PrivateContext { * The focused date. */ focusedValue: DateValue - /** - * The valid segments for each date value (tracks which segments have been filled). - */ - validSegments: Segments[] - /** - * The placeholder date to use when segments are not filled. - */ - placeholderDate: DateValue } type ComputedContext = Readonly<{ @@ -358,6 +350,10 @@ type ComputedContext = Readonly<{ * The value text to display in the input. */ valueAsString: string[] + /** + * The valid segments for each date value (tracks which segments have been filled). + */ + validSegments: Segments[] /** * A list of segments for the selected date(s). */ @@ -369,6 +365,10 @@ type Refs = { * The live region to announce changes */ announcer?: LiveRegion | undefined + /** + * The placeholder date to use when segments are not filled. + */ + placeholderDate?: CalendarDate | undefined } export interface DatePickerSchema { diff --git a/shared/src/css/date-picker.css b/shared/src/css/date-picker.css index 3ae453b481..81d43793eb 100644 --- a/shared/src/css/date-picker.css +++ b/shared/src/css/date-picker.css @@ -37,10 +37,34 @@ margin-bottom: 16px; } +[data-scope="date-picker"][data-part="segment-input"] { + display: inline-flex; + align-items: center; + padding-block: 2px; + padding-inline: 3px; + border: 1px solid rgb(118, 118, 118); + border-radius: 2px; + background-color: #fff; + color: #333; + min-width: 154px; + + &[data-focus] { + border-color: #66afe9; + box-shadow: 0 0 3px rgba(102, 175, 233, 0.6); + outline: none; + } +} + [data-scope="date-picker"][data-part="segment"] { font-variant-numeric: tabular-nums; text-align: end; - padding: 0 2px; + font-size: 13.3333px; + border-radius: 2px; + + &:focus { + background-color: rgba(102, 175, 233, 0.6); + outline: none; + } } [data-scope="date-picker"][data-part="table-cell-trigger"][data-today] { From e7af2820e968f294c5f312ed884f8ec856f2b5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Fri, 29 Aug 2025 01:08:56 +0200 Subject: [PATCH 05/23] feat: working on key events --- .../next-ts/pages/date-picker-segment.tsx | 2 +- .../date-picker/src/date-picker.connect.ts | 81 ++++++++++++++++-- .../date-picker/src/date-picker.machine.ts | 85 ++++++++++++++++--- .../date-picker/src/date-picker.types.ts | 14 ++- .../date-picker/src/date-picker.utils.ts | 34 ++++++++ 5 files changed, 191 insertions(+), 25 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment.tsx b/examples/next-ts/pages/date-picker-segment.tsx index 55dc45fdf6..0fde70c895 100644 --- a/examples/next-ts/pages/date-picker-segment.tsx +++ b/examples/next-ts/pages/date-picker-segment.tsx @@ -33,7 +33,7 @@ export default function Page() {
{api.getSegments().map((segment, i) => ( - + {segment.text} ))} diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 4ae5b3207b..e7979b49b0 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -834,6 +834,7 @@ export function connect( getSegmentProps(props) { const { segment } = props + const isEditable = !disabled && !readOnly && segment.isEditable if (segment.type === "literal") { return normalize.element({ @@ -846,18 +847,19 @@ export function connect( }) } - return { + return normalize.element({ ...parts.segment.attrs, dir: prop("dir"), role: "spinbutton", - tabIndex: segment.isEditable && !readOnly && !disabled ? 0 : -1, + tabIndex: disabled ? undefined : 0, autoComplete: "off", - autoCorrect: "off", - spellCheck: "false", - contentEditable: "true", - inputMode: segment.type !== "dayPeriod" ? "numeric" : undefined, + spellCheck: isEditable ? "false" : undefined, + autoCorrect: isEditable ? "off" : undefined, + contentEditable: isEditable, + suppressContentEditableWarning: isEditable, + inputMode: + disabled || segment.type === "dayPeriod" || segment.type === "era" || !isEditable ? undefined : "numeric", enterKeyHint: "next", - "data-placeholder": segment.isPlaceholder, "aria-labelledby": dom.getInputId(scope, 0), // FIXIT: figure out the index // "aria-label": translations.segmentLabel(segment), "aria-valuenow": segment.value, @@ -871,10 +873,73 @@ export function connect( "data-readonly": dataAttr(!segment.isEditable || readOnly), "data-disabled": dataAttr(disabled), "data-editable": dataAttr(segment.isEditable && !readOnly && !disabled), + "data-placeholder": dataAttr(segment.isPlaceholder), style: { "caret-color": "transparent", }, - } + onKeyDown(event) { + if ( + event.defaultPrevented || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey || + readOnly || + event.nativeEvent.isComposing + ) { + return + } + + const keyMap: EventKeyMap = { + Enter() { + send({ type: "SEGMENT.ENTER", focus: true }) + }, + ArrowLeft() { + send({ type: "SEGMENT.ARROW_LEFT", focus: true }) + }, + ArrowRight() { + send({ type: "SEGMENT.ARROW_RIGHT", focus: true }) + }, + ArrowUp() { + send({ type: "SEGMENT.ARROW_UP", segment, focus: true }) + }, + ArrowDown() { + send({ type: "SEGMENT.ARROW_DOWN", segment, focus: true }) + }, + PageUp(event) { + send({ type: "SEGMENT.PAGE_UP", larger: event.shiftKey, focus: true }) + }, + PageDown(event) { + send({ type: "SEGMENT.PAGE_DOWN", larger: event.shiftKey, focus: true }) + }, + Home() { + send({ type: "SEGMENT.HOME", focus: true }) + }, + End() { + send({ type: "SEGMENT.END", focus: true }) + }, + } + + const exec = + keyMap[ + getEventKey(event, { + dir: prop("dir"), + }) + ] + + if (exec) { + exec(event) + event.preventDefault() + event.stopPropagation() + } + }, + onPointerDown(event) { + event.stopPropagation() + }, + onMouseDown(event) { + event.stopPropagation() + }, + }) }, getMonthSelectProps() { diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 5f071ad5e1..c40fd1a47a 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -26,6 +26,7 @@ import { getPlacement, type Placement } from "@zag-js/popper" import * as dom from "./date-picker.dom" import type { DatePickerSchema, DateValue, DateView, Segments } from "./date-picker.types" import { + addSegment, adjustStartAndEndDate, clampView, defaultTranslations, @@ -120,8 +121,26 @@ export const machine = createMachine({ }, refs({ prop }) { + const formatter = new DateFormatter(prop("locale"), { + timeZone: prop("timeZone"), + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + + const allSegments = formatter + .formatToParts(new Date()) + .filter((seg) => EDITABLE_SEGMENTS[seg.type]) + .reduce((p, seg) => { + const key = TYPE_MAPPING[seg.type as keyof typeof TYPE_MAPPING] || seg.type + p[key] = true + return p + }, {}) + return { announcer: undefined, + formatter, + allSegments, placeholderDate: getTodayDate(prop("timeZone")), } }, @@ -206,22 +225,10 @@ export const machine = createMachine({ isNextVisibleRangeValid: ({ prop, computed }) => !isNextRangeInvalid(computed("endValue"), prop("min"), prop("max")), valueAsString: ({ context, prop }) => getValueAsString(context.get("value"), prop), - validSegments: ({ context, prop }) => { - const formatter = new DateFormatter(prop("locale"), { - timeZone: prop("timeZone"), - day: "2-digit", - month: "2-digit", - year: "numeric", - }) // TODO: move globally - const allSegments = formatter - .formatToParts(new Date()) - .filter((seg) => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => ((p[TYPE_MAPPING[seg.type] || seg.type] = true), p), {}) - + validSegments: ({ context, refs }) => { + const allSegments = refs.get("allSegments") const dateValue = context.get("value") - console.log("validSegments: ", dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}]) - return dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}] }, segments: ({ context, prop, refs, computed }) => { @@ -419,6 +426,16 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], + "SEGMENT.ARROW_UP": [ + { + actions: ["invokeOnSegmentIncrement"], + }, + ], + "SEGMENT.ARROW_DOWN": [ + { + actions: ["invokeOnSegmentDecrement"], + }, + ], }, }, @@ -449,6 +466,16 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], + "SEGMENT.ARROW_UP": [ + { + actions: ["invokeOnSegmentIncrement"], + }, + ], + "SEGMENT.ARROW_DOWN": [ + { + actions: ["invokeOnSegmentDecrement"], + }, + ], }, }, @@ -1207,6 +1234,36 @@ export const machine = createMachine({ toggleVisibility({ event, send, prop }) { send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: event }) }, + + invokeOnSegmentIncrement({ event, computed, refs, context }) { + const { segment } = event + const type = segment.type + const value = context.get("value") + const validSegments = computed("validSegments") + const allSegments = refs.get("allSegments") + const formatter = refs.get("formatter") + const index = 0 // FIXIT: figure out the index + + if (!validSegments[type]) { + // markValid(type) + let validKeys = Object.keys(validSegments) + let allKeys = Object.keys(allSegments) + if ( + validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments[index].dayPeriod) + ) { + const values = Array.from(value) // FIXIT: implement setValue (original code setValue(displayValue);) + context.set("value", values) + } + } else { + const values = Array.from(value) + values[index] = addSegment(value[index], type, 1, formatter.resolvedOptions()) + context.set("value", values) + // setValue(addSegment(value, type, 1, resolvedOptions)) + } + }, + + invokeOnSegmentDecrement({ context, prop }) {}, }, }, }) diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index 9dd7f87319..6857ec0da1 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -365,10 +365,18 @@ type Refs = { * The live region to announce changes */ announcer?: LiveRegion | undefined + /** + * The date formatter used to format date values. + */ + formatter: DateFormatter + /** + * + */ + allSegments: Segments /** * The placeholder date to use when segments are not filled. */ - placeholderDate?: CalendarDate | undefined + placeholderDate: CalendarDate } export interface DatePickerSchema { @@ -436,7 +444,9 @@ export type SegmentType = | "literal" | "timeZoneName" -export type Segments = Partial +export type Segments = Partial<{ + -readonly [K in keyof typeof EDITABLE_SEGMENTS]: boolean +}> export interface DateSegment { /** diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index 1ba11cd0dd..cd994f7296 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -361,3 +361,37 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD return {} } + +export function addSegment( + value: DateValue, + part: string, + amount: number, + options: Intl.ResolvedDateTimeFormatOptions, +) { + switch (part) { + case "era": + case "year": + case "month": + case "day": + return value.cycle(part, amount, { round: part === "year" }) + } + + if ("hour" in value) { + switch (part) { + case "dayPeriod": { + let hours = value.hour + let isPM = hours >= 12 + return value.set({ hour: isPM ? hours - 12 : hours + 12 }) + } + case "hour": + case "minute": + case "second": + return value.cycle(part, amount, { + round: part !== "hour", + hourCycle: options.hour12 ? 12 : 24, + }) + } + } + + throw new Error("Unknown segment: " + part) +} From aa7df7af3b0ee1c2cf4e81525f4ea7e8d456e591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Fri, 29 Aug 2025 09:54:14 +0200 Subject: [PATCH 06/23] feat: replace local date formatter with global reference --- packages/machines/date-picker/src/date-picker.machine.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index c40fd1a47a..759b5cec16 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -239,12 +239,7 @@ export const machine = createMachine({ const timeZone = prop("timeZone") const translations = prop("translations") || defaultTranslations const granularity = prop("granularity") - const formatter = new DateFormatter(prop("locale"), { - timeZone, - day: "2-digit", - month: "2-digit", - year: "numeric", - }) // TODO: move globally + const formatter = refs.get("formatter") let dates: DateValue[] = value?.length ? value : [placeholderDate] From 1cd6fe2df66cd1a0623ec8df553e817b75dfec3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Fri, 29 Aug 2025 19:16:46 +0200 Subject: [PATCH 07/23] chore: fix comments --- .../next-ts/pages/date-picker-segment.tsx | 2 +- .../date-picker/src/date-picker.connect.ts | 14 ++++++----- .../date-picker/src/date-picker.dom.ts | 2 ++ .../date-picker/src/date-picker.types.ts | 25 +++++++++++-------- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment.tsx b/examples/next-ts/pages/date-picker-segment.tsx index 0fde70c895..13f24ca7cb 100644 --- a/examples/next-ts/pages/date-picker-segment.tsx +++ b/examples/next-ts/pages/date-picker-segment.tsx @@ -31,7 +31,7 @@ export default function Page() {
-
+
{api.getSegments().map((segment, i) => ( {segment.text} diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index e7979b49b0..133bd7ab5d 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -810,10 +810,12 @@ export function connect( }) }, - getSegmentInputProps() { + getSegmentGroupProps(props = {}) { + const { index = 0 } = props + return normalize.element({ ...parts.segmentInput.attrs, - id: dom.getInputId(scope, 0), // FIXIT: figure out the index + id: dom.getSegmentGroupId(scope, index), dir: prop("dir"), "data-state": open ? "open" : "closed", role: "presentation", @@ -833,14 +835,14 @@ export function connect( getSegmentState, getSegmentProps(props) { - const { segment } = props + const { segment, index = 0 } = props const isEditable = !disabled && !readOnly && segment.isEditable if (segment.type === "literal") { return normalize.element({ ...parts.segment.attrs, dir: prop("dir"), - "aria-hidden": true, + "aria-hidden": true, // Literal segments should not be visible to screen readers. "data-type": segment.type, "data-readonly": dataAttr(true), "data-disabled": dataAttr(true), @@ -860,7 +862,7 @@ export function connect( inputMode: disabled || segment.type === "dayPeriod" || segment.type === "era" || !isEditable ? undefined : "numeric", enterKeyHint: "next", - "aria-labelledby": dom.getInputId(scope, 0), // FIXIT: figure out the index + "aria-labelledby": dom.getSegmentGroupId(scope, index), // "aria-label": translations.segmentLabel(segment), "aria-valuenow": segment.value, "aria-valuetext": segment.text, @@ -875,7 +877,7 @@ export function connect( "data-editable": dataAttr(segment.isEditable && !readOnly && !disabled), "data-placeholder": dataAttr(segment.isPlaceholder), style: { - "caret-color": "transparent", + caretColor: "transparent", }, onKeyDown(event) { if ( diff --git a/packages/machines/date-picker/src/date-picker.dom.ts b/packages/machines/date-picker/src/date-picker.dom.ts index 0d0d8fc98f..4940b16db8 100644 --- a/packages/machines/date-picker/src/date-picker.dom.ts +++ b/packages/machines/date-picker/src/date-picker.dom.ts @@ -22,6 +22,8 @@ export const getClearTriggerId = (ctx: Scope) => ctx.ids?.clearTrigger ?? `datep export const getControlId = (ctx: Scope) => ctx.ids?.control ?? `datepicker:${ctx.id}:control` export const getInputId = (ctx: Scope, index: number) => ctx.ids?.input?.(index) ?? `datepicker:${ctx.id}:input:${index}` +export const getSegmentGroupId = (ctx: Scope, index: number) => + ctx.ids?.segmentGroup?.(index) ?? `datepicker:${ctx.id}:segment-group:${index}` export const getTriggerId = (ctx: Scope) => ctx.ids?.trigger ?? `datepicker:${ctx.id}:trigger` export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `datepicker:${ctx.id}:positioner` export const getMonthSelectId = (ctx: Scope) => ctx.ids?.monthSelect ?? `datepicker:${ctx.id}:month-select` diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index 6857ec0da1..d335b14f76 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -428,6 +428,10 @@ export interface TableCellState { readonly disabled: boolean } +export interface SegmentGroupProps { + index?: number | undefined +} + export interface SegmentsProps { index?: number | undefined } @@ -485,6 +489,7 @@ export interface DateSegment { export interface SegmentProps { segment: DateSegment + index?: number | undefined } export interface SegmentState {} @@ -600,7 +605,7 @@ export interface DatePickerApi { /** * Returns an array of days in the week index counted from the provided start date, or the first visible date if not given. */ - getDaysInWeek: (week: number, from?: DateValue) => DateValue[] + getDaysInWeek: (week: number, from?: DateValue | undefined) => DateValue[] /** * Returns the offset of the month based on the provided number of months. */ @@ -612,7 +617,7 @@ export interface DatePickerApi { /** * Returns the weeks of the month from the provided date. Represented as an array of arrays of dates. */ - getMonthWeeks: (from?: DateValue) => DateValue[][] + getMonthWeeks: (from?: DateValue | undefined) => DateValue[][] /** * Returns whether the provided date is available (or can be selected) */ @@ -693,7 +698,7 @@ export interface DatePickerApi { * Returns the years of the decade based on the columns. * Represented as an array of arrays of years. */ - getYearsGrid: (props?: YearGridProps) => YearGridValue + getYearsGrid: (props?: YearGridProps | undefined) => YearGridValue /** * Returns the start and end years of the decade. */ @@ -701,16 +706,16 @@ export interface DatePickerApi { /** * Returns the months of the year */ - getMonths: (props?: MonthFormatOptions) => Cell[] + getMonths: (props?: MonthFormatOptions | undefined) => Cell[] /** * Returns the months of the year based on the columns. * Represented as an array of arrays of months. */ - getMonthsGrid: (props?: MonthGridProps) => MonthGridValue + getMonthsGrid: (props?: MonthGridProps | undefined) => MonthGridValue /** * Formats the given date value based on the provided options. */ - format: (value: DateValue, opts?: Intl.DateTimeFormatOptions) => string + format: (value: DateValue, opts?: Intl.DateTimeFormatOptions | undefined) => string /** * Sets the view of the date picker. */ @@ -736,13 +741,13 @@ export interface DatePickerApi { */ getYearTableCellState: (props: TableCellProps) => TableCellState /** - * + * Returns the props for the segment group container. */ - getSegmentInputProps: () => T["element"] + getSegmentGroupProps: (props?: SegmentGroupProps | undefined) => T["element"] /** - * + * Returns the props for a given segment. */ - getSegments: (props?: SegmentsProps) => DateSegment[] + getSegments: (props?: SegmentsProps | undefined) => DateSegment[] /** * Returns the state details for a given segment. */ From bd184701a1e309a63997776fd0fcdbab69498adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 30 Aug 2025 21:40:36 +0200 Subject: [PATCH 08/23] feat: complete segment value increment/decrement functionality --- .../pages/date-picker-segment-multi.tsx | 152 ++++++++++++ ...ment.tsx => date-picker-segment-range.tsx} | 4 +- .../pages/date-picker-segment-single.tsx | 155 ++++++++++++ packages/machines/date-picker/LOGIC.md | 68 ++++++ .../date-picker/src/date-picker.anatomy.ts | 2 +- .../date-picker/src/date-picker.connect.ts | 76 ++++-- .../date-picker/src/date-picker.dom.ts | 1 + .../date-picker/src/date-picker.machine.ts | 229 +++++++++++------- .../date-picker/src/date-picker.types.ts | 39 +-- .../date-picker/src/date-picker.utils.ts | 32 ++- shared/src/css/date-picker.css | 8 +- shared/src/routes.ts | 4 +- 12 files changed, 621 insertions(+), 149 deletions(-) create mode 100644 examples/next-ts/pages/date-picker-segment-multi.tsx rename examples/next-ts/pages/{date-picker-segment.tsx => date-picker-segment-range.tsx} (98%) create mode 100644 examples/next-ts/pages/date-picker-segment-single.tsx create mode 100644 packages/machines/date-picker/LOGIC.md diff --git a/examples/next-ts/pages/date-picker-segment-multi.tsx b/examples/next-ts/pages/date-picker-segment-multi.tsx new file mode 100644 index 0000000000..38851f946c --- /dev/null +++ b/examples/next-ts/pages/date-picker-segment-multi.tsx @@ -0,0 +1,152 @@ +import * as datePicker from "@zag-js/date-picker" +import { normalizeProps, useMachine } from "@zag-js/react" +import { datePickerControls } from "@zag-js/shared" +import { useId } from "react" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" + +export default function Page() { + const controls = useControls(datePickerControls) + const service = useMachine(datePicker.machine, { + id: useId(), + locale: "en", + selectionMode: "multiple", + ...controls.context, + }) + + const api = datePicker.connect(service, normalizeProps) + + return ( + <> +
+
+ +
+

{`Visible range: ${api.visibleRangeText.formatted}`}

+ + +
Selected: {api.valueAsString ?? "-"}
+
Focused: {api.focusedValueAsString}
+
+ +
+
+ {api.getSegments().map((segment, i) => ( + + {segment.text} + + ))} +
+ + +
+ +
+
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + ) +} diff --git a/examples/next-ts/pages/date-picker-segment.tsx b/examples/next-ts/pages/date-picker-segment-range.tsx similarity index 98% rename from examples/next-ts/pages/date-picker-segment.tsx rename to examples/next-ts/pages/date-picker-segment-range.tsx index 13f24ca7cb..61c339b380 100644 --- a/examples/next-ts/pages/date-picker-segment.tsx +++ b/examples/next-ts/pages/date-picker-segment-range.tsx @@ -11,7 +11,7 @@ export default function Page() { const service = useMachine(datePicker.machine, { id: useId(), locale: "en", - selectionMode: "single", + selectionMode: "range", ...controls.context, }) @@ -33,7 +33,7 @@ export default function Page() {
{api.getSegments().map((segment, i) => ( - + {segment.text} ))} diff --git a/examples/next-ts/pages/date-picker-segment-single.tsx b/examples/next-ts/pages/date-picker-segment-single.tsx new file mode 100644 index 0000000000..7c4716e5d9 --- /dev/null +++ b/examples/next-ts/pages/date-picker-segment-single.tsx @@ -0,0 +1,155 @@ +import * as datePicker from "@zag-js/date-picker" +import { normalizeProps, useMachine } from "@zag-js/react" +import { datePickerControls } from "@zag-js/shared" +import { useId } from "react" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" + +export default function Page() { + const controls = useControls(datePickerControls) + const service = useMachine(datePicker.machine, { + id: useId(), + locale: "en", + selectionMode: "single", + + ...controls.context, + }) + + const api = datePicker.connect(service, normalizeProps) + + return ( + <> +
+
+ +
+

{`Visible range: ${api.visibleRangeText.formatted}`}

+ + +
Selected: {api.valueAsString ?? "-"}
+
Focused: {api.focusedValueAsString}
+
+ +
+
+ {api.getSegments().map((segment, i) => ( + + {segment.text} + + ))} +
+ + +
+ + + +
+
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + ) +} diff --git a/packages/machines/date-picker/LOGIC.md b/packages/machines/date-picker/LOGIC.md new file mode 100644 index 0000000000..1d7b622266 --- /dev/null +++ b/packages/machines/date-picker/LOGIC.md @@ -0,0 +1,68 @@ +## DatePicker Segment Focus Logic + +- The date-picker component has three states: `IDLE`, `FOCUSED`, and `OPEN` + +- In the `IDLE` state: + + - When a segment group is focused: + - Transition to the `FOCUSED` state + - Set the active index and active segment index + - Focus the first editable segment + +- In the `FOCUSED` state: + + - When a segment group is blurred: + - Transition to the `IDLE` state + - Reset the active segment index to -1 + - Reset the active index to start + + - When arrow right key is pressed: + - Move to the next editable segment + - If at the last segment, move to the first segment of next input (range mode) + + - When arrow left key is pressed: + - Move to the previous editable segment + - If at the first segment, move to the last segment of previous input (range mode) + + - When arrow up key is pressed: + - Increment the current segment value (day, month, year) + - Constrain the value within valid bounds + - Update the date value + + - When arrow down key is pressed: + - Decrement the current segment value (day, month, year) + - Constrain the value within valid bounds + - Update the date value + + - When a digit is typed: + - Update the current segment value + - If segment is complete, move to next segment + - Parse and validate the date + + - When backspace is pressed: + - Clear the current segment value + - Move to previous segment if current is empty + + - When delete is pressed: + - Clear the current segment value + - Stay on the current segment + + - When enter is pressed: + - Parse the complete input value + - Set the focused date if valid + - Select the focused date + + - When escape is pressed: + - Revert to the last valid value + - Blur the segment group + + - When text is pasted: + - Parse the pasted value as a complete date + - Update all segments if valid + - Focus the last segment + +- In the `OPEN` state: + + - Segment interactions are disabled + - Arrow keys control calendar navigation instead + - Typing characters may trigger date search/filter \ No newline at end of file diff --git a/packages/machines/date-picker/src/date-picker.anatomy.ts b/packages/machines/date-picker/src/date-picker.anatomy.ts index 362036671a..4e68fd3450 100644 --- a/packages/machines/date-picker/src/date-picker.anatomy.ts +++ b/packages/machines/date-picker/src/date-picker.anatomy.ts @@ -5,7 +5,7 @@ export const anatomy = createAnatomy("date-picker").parts( "content", "control", "input", - "segmentInput", + "segmentGroup", "segment", "label", "monthSelect", diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 133bd7ab5d..7cb7993726 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -41,6 +41,7 @@ import { getRoleDescription, isDateWithinRange, isValidCharacter, + PAGE_STEP, } from "./date-picker.utils" export function connect( @@ -225,8 +226,13 @@ export function connect( } function getSegmentState(props: SegmentProps): SegmentState { - const {} = props - return {} + const { segment } = props + + const isEditable = !disabled && !readOnly && segment.isEditable + + return { + editable: isEditable, + } } return { @@ -810,11 +816,16 @@ export function connect( }) }, + getSegments(props = {}) { + const { index = 0 } = props + return computed("segments")[index] ?? [] + }, + getSegmentGroupProps(props = {}) { const { index = 0 } = props return normalize.element({ - ...parts.segmentInput.attrs, + ...parts.segmentGroup.attrs, id: dom.getSegmentGroupId(scope, index), dir: prop("dir"), "data-state": open ? "open" : "closed", @@ -824,19 +835,20 @@ export function connect( style: { unicodeBidi: "isolate", }, + onFocus() { + send({ type: "SEGMENT_GROUP.FOCUS", index: 1 }) + }, + onBlur() { + send({ type: "SEGMENT_GROUP.BLUR", index: -1 }) + }, }) }, - getSegments(props = {}) { - const { index = 0 } = props - return computed("segments")[index] ?? [] - }, - getSegmentState, getSegmentProps(props) { const { segment, index = 0 } = props - const isEditable = !disabled && !readOnly && segment.isEditable + const segmentState = getSegmentState(props) if (segment.type === "literal") { return normalize.element({ @@ -855,12 +867,14 @@ export function connect( role: "spinbutton", tabIndex: disabled ? undefined : 0, autoComplete: "off", - spellCheck: isEditable ? "false" : undefined, - autoCorrect: isEditable ? "off" : undefined, - contentEditable: isEditable, - suppressContentEditableWarning: isEditable, + spellCheck: segmentState.editable ? "false" : undefined, + autoCorrect: segmentState.editable ? "off" : undefined, + contentEditable: segmentState.editable, + suppressContentEditableWarning: segmentState.editable, inputMode: - disabled || segment.type === "dayPeriod" || segment.type === "era" || !isEditable ? undefined : "numeric", + disabled || segment.type === "dayPeriod" || segment.type === "era" || !segmentState.editable + ? undefined + : "numeric", enterKeyHint: "next", "aria-labelledby": dom.getSegmentGroupId(scope, index), // "aria-label": translations.segmentLabel(segment), @@ -879,6 +893,12 @@ export function connect( style: { caretColor: "transparent", }, + onFocus() { + send({ type: "SEGMENT.FOCUS", index }) + }, + onBlur() { + send({ type: "SEGMENT.BLUR", index }) + }, onKeyDown(event) { if ( event.defaultPrevented || @@ -902,17 +922,29 @@ export function connect( ArrowRight() { send({ type: "SEGMENT.ARROW_RIGHT", focus: true }) }, - ArrowUp() { - send({ type: "SEGMENT.ARROW_UP", segment, focus: true }) + ArrowUp(event) { + event.preventDefault() + send({ type: "SEGMENT.ADJUST", segment, amount: 1, focus: true }) }, - ArrowDown() { - send({ type: "SEGMENT.ARROW_DOWN", segment, focus: true }) + ArrowDown(event) { + event.preventDefault() + send({ type: "SEGMENT.ADJUST", segment, amount: -1, focus: true }) }, - PageUp(event) { - send({ type: "SEGMENT.PAGE_UP", larger: event.shiftKey, focus: true }) + PageUp() { + send({ + type: "SEGMENT.ADJUST", + segment, + amount: PAGE_STEP[segment.type] || 1, + focus: true, + }) }, - PageDown(event) { - send({ type: "SEGMENT.PAGE_DOWN", larger: event.shiftKey, focus: true }) + PageDown() { + send({ + type: "SEGMENT.ADJUST", + segment, + amount: -(PAGE_STEP[segment.type] ?? 1), + focus: true, + }) }, Home() { send({ type: "SEGMENT.HOME", focus: true }) diff --git a/packages/machines/date-picker/src/date-picker.dom.ts b/packages/machines/date-picker/src/date-picker.dom.ts index 4940b16db8..03ce9f7cdd 100644 --- a/packages/machines/date-picker/src/date-picker.dom.ts +++ b/packages/machines/date-picker/src/date-picker.dom.ts @@ -34,6 +34,7 @@ export const getFocusedCell = (ctx: Scope, view: DateView) => export const getTriggerEl = (ctx: Scope) => ctx.getById(getTriggerId(ctx)) export const getContentEl = (ctx: Scope) => ctx.getById(getContentId(ctx)) export const getInputEls = (ctx: Scope) => queryAll(getControlEl(ctx), `[data-part=input]`) +export const getSegmentEls = (ctx: Scope) => queryAll(getControlEl(ctx), `[data-part=segment]`) export const getYearSelectEl = (ctx: Scope) => ctx.getById(getYearSelectId(ctx)) export const getMonthSelectEl = (ctx: Scope) => ctx.getById(getMonthSelectId(ctx)) export const getClearTriggerEl = (ctx: Scope) => ctx.getById(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 156478a5bc..ca069e4269 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -24,7 +24,7 @@ import { disableTextSelection, raf, restoreTextSelection, setElementValue } from import { createLiveRegion } from "@zag-js/live-region" import { getPlacement, type Placement } from "@zag-js/popper" import * as dom from "./date-picker.dom" -import type { DatePickerSchema, DateValue, DateView, Segments } from "./date-picker.types" +import type { DatePickerSchema, DateSegment, DateValue, DateView, Segments, SegmentType } from "./date-picker.types" import { addSegment, adjustStartAndEndDate, @@ -84,6 +84,22 @@ export const machine = createMachine({ const granularity = props.granularity || "day" const translations = { ...defaultTranslations, ...props.translations } + const formatter = new DateFormatter(locale, { + timeZone: timeZone, + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + + const allSegments = formatter + .formatToParts(new Date()) + .filter((seg) => EDITABLE_SEGMENTS[seg.type]) + .reduce((p, seg) => { + const key = TYPE_MAPPING[seg.type as keyof typeof TYPE_MAPPING] || seg.type + p[key] = true + return p + }, {}) + return { locale, numOfMonths, @@ -94,8 +110,7 @@ export const machine = createMachine({ maxView, outsideDaySelectable: false, closeOnSelect: true, - format(date, { locale, timeZone }) { - const formatter = new DateFormatter(locale, { timeZone, day: "2-digit", month: "2-digit", year: "numeric" }) + format(date, { timeZone }) { return formatter.format(date.toDate(timeZone)) }, parse(value, { locale, timeZone }) { @@ -112,6 +127,8 @@ export const machine = createMachine({ ...props.positioning, }, granularity, + formatter, + allSegments, } }, @@ -120,28 +137,9 @@ export const machine = createMachine({ return open ? "open" : "idle" }, - refs({ prop }) { - const formatter = new DateFormatter(prop("locale"), { - timeZone: prop("timeZone"), - day: "2-digit", - month: "2-digit", - year: "numeric", - }) - - const allSegments = formatter - .formatToParts(new Date()) - .filter((seg) => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => { - const key = TYPE_MAPPING[seg.type as keyof typeof TYPE_MAPPING] || seg.type - p[key] = true - return p - }, {}) - + refs() { return { announcer: undefined, - formatter, - allSegments, - placeholderDate: getTodayDate(prop("timeZone")), } }, @@ -179,6 +177,10 @@ export const machine = createMachine({ defaultValue: 0, sync: true, })), + activeSegmentIndex: bindable(() => ({ + defaultValue: -1, + sync: true, + })), hoveredValue: bindable(() => ({ defaultValue: null, isEqual: (a, b) => b !== null && a !== null && isDateEqual(a, b), @@ -204,6 +206,25 @@ export const machine = createMachine({ restoreFocus: bindable(() => ({ defaultValue: false, })), + validSegments: bindable(() => { + const allSegments = prop("allSegments") + const value = prop("value") || prop("defaultValue") + const defaultValidSegments = value?.length ? value.map((date) => (date ? { ...allSegments } : {})) : [{}] + + return { + defaultValue: defaultValidSegments, + } + }), + placeholderDate: bindable(() => ({ + defaultValue: [getTodayDate(prop("timeZone"))], + isEqual: isDateArrayEqual, + hash: (v) => v.map((date) => date.toString()).join(","), + // onChange(value) { + // const context = getContext() + // const valueAsString = getValueAsString(value, prop) + // prop("onValueChange")?.({ value, valueAsString, view: context.get("view") }) + // }, + })), } }, @@ -225,21 +246,15 @@ export const machine = createMachine({ isNextVisibleRangeValid: ({ prop, computed }) => !isNextRangeInvalid(computed("endValue"), prop("min"), prop("max")), valueAsString: ({ context, prop }) => getValueAsString(context.get("value"), prop), - validSegments: ({ context, refs }) => { - const allSegments = refs.get("allSegments") - const dateValue = context.get("value") - - return dateValue?.map((date) => (date ? { ...allSegments } : {})) ?? [{}, {}] - }, - segments: ({ context, prop, refs, computed }) => { + segments: ({ context, prop }) => { const value = context.get("value") const selectionMode = prop("selectionMode") - const placeholderDate = refs.get("placeholderDate") - const validSegments = computed("validSegments") + const placeholderDate = context.get("placeholderDate")[0] + const validSegments = context.get("validSegments") const timeZone = prop("timeZone") const translations = prop("translations") || defaultTranslations const granularity = prop("granularity") - const formatter = refs.get("formatter") + const formatter = prop("formatter") let dates: DateValue[] = value?.length ? value : [placeholderDate] @@ -247,23 +262,6 @@ export const machine = createMachine({ dates = value?.length ? value : [placeholderDate, placeholderDate] } - console.log( - dates.map((date, i) => { - const displayValue = date || placeholderDate - const currentValidSegments = validSegments?.[i] || {} - - return processSegments({ - dateValue: displayValue.toDate(timeZone), - displayValue, - validSegments: currentValidSegments, - formatter, - locale: prop("locale"), - translations, - granularity, - }) - }), - ) - return dates.map((date, i) => { const displayValue = date || placeholderDate const currentValidSegments = validSegments?.[i] || {} @@ -391,6 +389,10 @@ export const machine = createMachine({ actions: ["focusPreviousPage"], }, ], + + "SEGMENT_GROUP.BLUR": { + actions: ["setActiveSegmentIndex"], + }, }, states: { @@ -421,16 +423,10 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], - "SEGMENT.ARROW_UP": [ - { - actions: ["invokeOnSegmentIncrement"], - }, - ], - "SEGMENT.ARROW_DOWN": [ - { - actions: ["invokeOnSegmentDecrement"], - }, - ], + "SEGMENT_GROUP.FOCUS": { + target: "focused", + actions: ["setActiveSegmentIndex", "focusFirstSegmentElement"], + }, }, }, @@ -461,16 +457,9 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], - "SEGMENT.ARROW_UP": [ - { - actions: ["invokeOnSegmentIncrement"], - }, - ], - "SEGMENT.ARROW_DOWN": [ - { - actions: ["invokeOnSegmentDecrement"], - }, - ], + "SEGMENT.ADJUST": { + actions: ["invokeOnSegmentAdjust"], + }, }, }, @@ -1231,35 +1220,93 @@ export const machine = createMachine({ send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: event }) }, - invokeOnSegmentIncrement({ event, computed, refs, context }) { - const { segment } = event - const type = segment.type + // SEGMENT + + setActiveSegmentIndex({ context, event }) { + context.set("activeSegmentIndex", event.index) + }, + + focusFirstSegmentElement({ scope }) { + raf(() => { + const [inputEl] = dom.getSegmentEls(scope) + inputEl?.focus({ preventScroll: true }) + }) + }, + + invokeOnSegmentAdjust({ event, context, prop }) { + const { segment, amount } = event + const type = segment.type as DateSegment["type"] + const validSegments = Array.from(context.get("validSegments")) const value = context.get("value") - const validSegments = computed("validSegments") - const allSegments = refs.get("allSegments") - const formatter = refs.get("formatter") - const index = 0 // FIXIT: figure out the index - - if (!validSegments[type]) { - // markValid(type) - let validKeys = Object.keys(validSegments) - let allKeys = Object.keys(allSegments) - if ( + const allSegments = prop("allSegments") + const formatter = prop("formatter") + const index = context.get("activeIndex") + const placeholderDate = context.get("placeholderDate")[index] + const activeValidSegments = validSegments[index] + const activeValue = value[index] + const validKeys = Object.keys(activeValidSegments) + const allKeys = Object.keys(allSegments) + + // If all segments are valid, use the date from state, otherwise use the placeholder date. + const displayValue = + activeValue && Object.keys(activeValidSegments).length >= Object.keys(allSegments).length + ? activeValue + : placeholderDate + + const setValue = (newValue: DateValue) => { + if (prop("disabled") || prop("readOnly")) return + const date = constrainValue(newValue, prop("min"), prop("max")) + + // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared + if (newValue == null) { + // setDate(null) + // setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)) + // setValidSegments({}) + } else if ( + // (validKeys.length === 0 && clearedSegment.current == null) || // FIXIT: figure out what is clearedSegment.current used for validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments[index].dayPeriod) + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) + // && clearedSegment.current !== "dayPeriod" // FIXIT: figure out what is clearedSegment.current used for ) { - const values = Array.from(value) // FIXIT: implement setValue (original code setValue(displayValue);) + // If the field was empty (no valid segments) or all segments are completed, commit the new value. + // When committing from an empty state, mark every segment as valid so value is committed. + if (validKeys.length === 0) { + validSegments[index] = { ...allSegments } + context.set("validSegments", validSegments) + } + + const values = Array.from(context.get("value")) + values[index] = date context.set("value", values) + } else { + const placeholderDates = Array.from(context.get("placeholderDate")) + placeholderDates[index] = date + context.set("placeholderDate", placeholderDates) + } + // clearedSegment.current = null // FIXIT: figure out what is clearedSegment.current used for + } + + const markValid = (segmentType: SegmentType) => { + activeValidSegments[segmentType] = true + if (segmentType === "year" && allSegments.era) { + activeValidSegments.era = true + } + context.set("validSegments", validSegments) + } + + if (!activeValidSegments?.[type]) { + markValid(type) + + if ( + validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) + ) { + setValue(displayValue) } } else { - const values = Array.from(value) - values[index] = addSegment(value[index], type, 1, formatter.resolvedOptions()) - context.set("value", values) - // setValue(addSegment(value, type, 1, resolvedOptions)) + setValue(addSegment(displayValue, type, amount, formatter.resolvedOptions())) } }, - - invokeOnSegmentDecrement({ context, prop }) {}, }, }, }) diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index d335b14f76..0ca4f8acd0 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -254,6 +254,11 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties { * Determines the smallest unit that is displayed in the date picker. By default, this is `"day"`. */ granularity?: DateGranularity | undefined + formatter?: DateFormatter | undefined + /** + * + */ + allSegments?: Segments | undefined } type PropsWithDefault = @@ -272,6 +277,8 @@ type PropsWithDefault = | "outsideDaySelectable" | "granularity" | "translations" + | "formatter" + | "allSegments" interface PrivateContext { /** @@ -295,6 +302,10 @@ interface PrivateContext { * Used in range selection mode. */ activeIndex: number + /** + * The index of the currently active segment. + */ + activeSegmentIndex: number /** * The computed placement (maybe different from initial placement) */ @@ -315,6 +326,14 @@ interface PrivateContext { * The focused date. */ focusedValue: DateValue + /** + * The valid segments for each date value (tracks which segments have been filled). + */ + validSegments: Segments[] + /** + * The placeholder date to use when segments are not filled. + */ + placeholderDate: DateValue[] } type ComputedContext = Readonly<{ @@ -350,10 +369,6 @@ type ComputedContext = Readonly<{ * The value text to display in the input. */ valueAsString: string[] - /** - * The valid segments for each date value (tracks which segments have been filled). - */ - validSegments: Segments[] /** * A list of segments for the selected date(s). */ @@ -365,18 +380,6 @@ type Refs = { * The live region to announce changes */ announcer?: LiveRegion | undefined - /** - * The date formatter used to format date values. - */ - formatter: DateFormatter - /** - * - */ - allSegments: Segments - /** - * The placeholder date to use when segments are not filled. - */ - placeholderDate: CalendarDate } export interface DatePickerSchema { @@ -492,7 +495,9 @@ export interface SegmentProps { index?: number | undefined } -export interface SegmentState {} +export interface SegmentState { + editable: boolean +} export interface DayTableCellProps { value: DateValue disabled?: boolean | undefined diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index cd994f7296..c4af250899 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, getMinimumDayInMonth, getMinimumMonthInYear, type DateValue } from "@internationalized/date" import { clampValue, match } from "@zag-js/utils" -import type { DateSegment, DateView, IntlTranslations, Segments } from "./date-picker.types" +import type { DateSegment, DateView, IntlTranslations, Segments, SegmentType } from "./date-picker.types" import type { DateGranularity } from "@zag-js/date-utils" export function adjustStartAndEndDate(value: DateValue[]) { @@ -186,6 +186,22 @@ export const EDITABLE_SEGMENTS = { fractionalSecond: true, } as const satisfies Record +export const PAGE_STEP = { + year: 5, + month: 2, + day: 7, + hour: 2, + minute: 15, + second: 15, + dayPeriod: undefined, + era: undefined, + literal: undefined, + timeZoneName: undefined, + weekday: undefined, + unknown: undefined, + fractionalSecond: undefined, +} as const satisfies Record + export const TYPE_MAPPING = { // Node seems to convert everything to lowercase... dayperiod: "dayPeriod", @@ -364,20 +380,20 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD export function addSegment( value: DateValue, - part: string, + type: SegmentType, amount: number, options: Intl.ResolvedDateTimeFormatOptions, ) { - switch (part) { + switch (type) { case "era": case "year": case "month": case "day": - return value.cycle(part, amount, { round: part === "year" }) + return value.cycle(type, amount, { round: type === "year" }) } if ("hour" in value) { - switch (part) { + switch (type) { case "dayPeriod": { let hours = value.hour let isPM = hours >= 12 @@ -386,12 +402,12 @@ export function addSegment( case "hour": case "minute": case "second": - return value.cycle(part, amount, { - round: part !== "hour", + return value.cycle(type, amount, { + round: type !== "hour", hourCycle: options.hour12 ? 12 : 24, }) } } - throw new Error("Unknown segment: " + part) + throw new Error("Unknown segment: " + type) } diff --git a/shared/src/css/date-picker.css b/shared/src/css/date-picker.css index 81d43793eb..03f9b7e0dd 100644 --- a/shared/src/css/date-picker.css +++ b/shared/src/css/date-picker.css @@ -37,7 +37,7 @@ margin-bottom: 16px; } -[data-scope="date-picker"][data-part="segment-input"] { +[data-scope="date-picker"][data-part="segment-group"] { display: inline-flex; align-items: center; padding-block: 2px; @@ -47,12 +47,6 @@ background-color: #fff; color: #333; min-width: 154px; - - &[data-focus] { - border-color: #66afe9; - box-shadow: 0 0 3px rgba(102, 175, 233, 0.6); - outline: none; - } } [data-scope="date-picker"][data-part="segment"] { diff --git a/shared/src/routes.ts b/shared/src/routes.ts index a8ca06bf9a..81bc9e0382 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -37,7 +37,9 @@ export const routesData: RouteData[] = [ { label: "Date Picker (Inline)", path: "/date-picker-inline" }, { label: "Date Picker (Month + Range)", path: "/date-picker-month-range" }, { label: "Date Picker (Year + Range)", path: "/date-picker-year-range" }, - { label: "Date Picker (Segment)", path: "/date-picker-segment" }, + { label: "Date Picker (Segment Single)", path: "/date-picker-segment-single" }, + { label: "Date Picker (Segment Range)", path: "/date-picker-segment-range" }, + { label: "Date Picker (Segment Multi)", path: "/date-picker-segment-multi" }, { label: "Select", path: "/select" }, { label: "Accordion", path: "/accordion" }, { label: "Checkbox", path: "/checkbox" }, From 53215e9f8703e2c490a9d0c719d599f57b23dd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sun, 31 Aug 2025 19:28:18 +0200 Subject: [PATCH 09/23] chore: some fixes --- .../pages/date-picker-segment-single.tsx | 2 +- .../date-picker/src/date-picker.connect.ts | 13 ++-- .../date-picker/src/date-picker.machine.ts | 59 ++++++++++++++++--- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment-single.tsx b/examples/next-ts/pages/date-picker-segment-single.tsx index 7c4716e5d9..a842f41b28 100644 --- a/examples/next-ts/pages/date-picker-segment-single.tsx +++ b/examples/next-ts/pages/date-picker-segment-single.tsx @@ -10,7 +10,7 @@ export default function Page() { const controls = useControls(datePickerControls) const service = useMachine(datePicker.machine, { id: useId(), - locale: "en", + locale: "en-US", selectionMode: "single", ...controls.context, diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 7cb7993726..fd69007942 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -836,10 +836,7 @@ export function connect( unicodeBidi: "isolate", }, onFocus() { - send({ type: "SEGMENT_GROUP.FOCUS", index: 1 }) - }, - onBlur() { - send({ type: "SEGMENT_GROUP.BLUR", index: -1 }) + send({ type: "SEGMENT_GROUP.FOCUS" }) }, }) }, @@ -897,7 +894,7 @@ export function connect( send({ type: "SEGMENT.FOCUS", index }) }, onBlur() { - send({ type: "SEGMENT.BLUR", index }) + send({ type: "SEGMENT.BLUR", index: -1 }) }, onKeyDown(event) { if ( @@ -922,12 +919,10 @@ export function connect( ArrowRight() { send({ type: "SEGMENT.ARROW_RIGHT", focus: true }) }, - ArrowUp(event) { - event.preventDefault() + ArrowUp() { send({ type: "SEGMENT.ADJUST", segment, amount: 1, focus: true }) }, - ArrowDown(event) { - event.preventDefault() + ArrowDown() { send({ type: "SEGMENT.ADJUST", segment, amount: -1, focus: true }) }, PageUp() { diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index ca069e4269..b4f4f2c784 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -314,6 +314,10 @@ export const machine = createMachine({ track([() => prop("open")], () => { action(["toggleVisibility"]) }) + + track([() => context.get("activeSegmentIndex")], () => { + action(["focusActiveSegment"]) + }) }, on: { @@ -389,10 +393,6 @@ export const machine = createMachine({ actions: ["focusPreviousPage"], }, ], - - "SEGMENT_GROUP.BLUR": { - actions: ["setActiveSegmentIndex"], - }, }, states: { @@ -425,7 +425,11 @@ export const machine = createMachine({ ], "SEGMENT_GROUP.FOCUS": { target: "focused", - actions: ["setActiveSegmentIndex", "focusFirstSegmentElement"], + actions: ["focusFirstSegment"], + }, + "SEGMENT.FOCUS": { + target: "focused", + actions: ["setActiveSegmentIndex"], }, }, }, @@ -457,9 +461,18 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], + "SEGMENT.FOCUS": { + actions: ["setActiveSegmentIndex"], + }, "SEGMENT.ADJUST": { actions: ["invokeOnSegmentAdjust"], }, + "SEGMENT.ARROW_LEFT": { + actions: ["focusPreviousSegment"], + }, + "SEGMENT.ARROW_RIGHT": { + actions: ["focusNextSegment"], + }, }, }, @@ -1226,10 +1239,40 @@ export const machine = createMachine({ context.set("activeSegmentIndex", event.index) }, - focusFirstSegmentElement({ scope }) { + focusFirstSegment({ scope }) { raf(() => { - const [inputEl] = dom.getSegmentEls(scope) - inputEl?.focus({ preventScroll: true }) + const segmentEls = dom.getSegmentEls(scope) + const firstSegmentEl = segmentEls.find((el) => el.hasAttribute("data-editable")) + firstSegmentEl?.focus({ preventScroll: true }) + }) + }, + + focusNextSegment({ scope, context }) { + raf(() => { + const segmentEls = dom.getSegmentEls(scope) + const nextSegmentEl = segmentEls + .slice(context.get("activeSegmentIndex") + 1) + .find((el) => el.hasAttribute("data-editable")) + nextSegmentEl?.focus({ preventScroll: true }) + }) + }, + + focusPreviousSegment({ scope, context }) { + raf(() => { + const segmentEls = dom.getSegmentEls(scope) + const prevSegmentEl = segmentEls + .slice(0, context.get("activeSegmentIndex")) + .reverse() + .find((el) => el.hasAttribute("data-editable")) + prevSegmentEl?.focus({ preventScroll: true }) + }) + }, + + focusActiveSegment({ scope, context }) { + raf(() => { + const segmentEls = dom.getSegmentEls(scope) + const activeSegmentEl = segmentEls[context.get("activeSegmentIndex")] + activeSegmentEl?.focus({ preventScroll: true }) }) }, From ed687a3bae4f1ca68ea4d49e8c28d36a75057768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Tue, 2 Sep 2025 22:50:00 +0200 Subject: [PATCH 10/23] feat: implement placeholderValue and reset segments on value clear --- .../pages/date-picker-segment-single.tsx | 5 +- .../date-picker/src/date-picker.connect.ts | 4 ++ .../date-picker/src/date-picker.machine.ts | 63 +++++++++++-------- .../date-picker/src/date-picker.props.ts | 5 ++ .../date-picker/src/date-picker.types.ts | 43 +++++++++++-- .../date-picker/src/date-picker.utils.ts | 31 ++++++--- 6 files changed, 110 insertions(+), 41 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment-single.tsx b/examples/next-ts/pages/date-picker-segment-single.tsx index a842f41b28..b1fea95d40 100644 --- a/examples/next-ts/pages/date-picker-segment-single.tsx +++ b/examples/next-ts/pages/date-picker-segment-single.tsx @@ -10,10 +10,10 @@ export default function Page() { const controls = useControls(datePickerControls) const service = useMachine(datePicker.machine, { id: useId(), - locale: "en-US", selectionMode: "single", - ...controls.context, + locale: "de", + defaultPlaceholderValue: datePicker.parse("2022-12-25"), }) const api = datePicker.connect(service, normalizeProps) @@ -29,6 +29,7 @@ export default function Page() {
Selected: {api.valueAsString ?? "-"}
Focused: {api.focusedValueAsString}
+
Placeholder: {api.placeholderValueAsString}
diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index fd69007942..d7fe28de77 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -54,6 +54,7 @@ export function connect( const endValue = computed("endValue") const selectedValue = context.get("value") const focusedValue = context.get("focusedValue") + const placeholderValue = context.get("placeholderValue") const hoveredValue = context.get("hoveredValue") const hoveredRangeValue = hoveredValue ? adjustStartAndEndDate([selectedValue[0], hoveredValue]) : [] @@ -270,6 +271,9 @@ export function connect( focusedValue, focusedValueAsDate: focusedValue?.toDate(timeZone), focusedValueAsString: prop("format")(focusedValue, { locale, timeZone }), + placeholderValue: placeholderValue, + placeholderValueAsDate: placeholderValue?.toDate(timeZone), + placeholderValueAsString: prop("format")(placeholderValue, { locale, timeZone }), visibleRange: computed("visibleRange"), selectToday() { const value = constrainValue(getTodayDate(timeZone), min, max) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index b4f4f2c784..324aa82021 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -32,6 +32,7 @@ import { defaultTranslations, eachView, EDITABLE_SEGMENTS, + getDefaultValidSegments, getNextView, getPreviousView, isAboveMinView, @@ -77,6 +78,15 @@ export const machine = createMachine({ props.focusedValue || props.defaultFocusedValue || value?.[0] || defaultValue?.[0] || getTodayDate(timeZone) focusedValue = constrainValue(focusedValue, props.min, props.max) + // get initial placeholder value + let placeholderValue = + props.placeholderValue || + props.defaultPlaceholderValue || + value?.[0] || + defaultValue?.[0] || + getTodayDate(timeZone) + placeholderValue = constrainValue(placeholderValue, props.min, props.max) + // get the initial view const minView: DateView = "day" const maxView: DateView = "year" @@ -128,6 +138,8 @@ export const machine = createMachine({ }, granularity, formatter, + placeholderValue: typeof props.placeholderValue === "undefined" ? undefined : placeholderValue, + defaultPlaceholderValue: placeholderValue, allSegments, } }, @@ -206,25 +218,25 @@ export const machine = createMachine({ restoreFocus: bindable(() => ({ defaultValue: false, })), + placeholderValue: bindable(() => ({ + defaultValue: prop("defaultPlaceholderValue"), + value: prop("placeholderValue"), + isEqual: isDateEqual, + hash: (v) => v.toString(), + sync: true, + onChange(placeholderValue) { + const context = getContext() + const view = context.get("view") + const value = context.get("value") + const valueAsString = getValueAsString(value, prop) + prop("onPlaceholderChange")?.({ value, valueAsString, view, placeholderValue }) + }, + })), validSegments: bindable(() => { - const allSegments = prop("allSegments") - const value = prop("value") || prop("defaultValue") - const defaultValidSegments = value?.length ? value.map((date) => (date ? { ...allSegments } : {})) : [{}] - return { - defaultValue: defaultValidSegments, + defaultValue: getDefaultValidSegments(prop("value") || prop("defaultValue"), prop("allSegments")), } }), - placeholderDate: bindable(() => ({ - defaultValue: [getTodayDate(prop("timeZone"))], - isEqual: isDateArrayEqual, - hash: (v) => v.map((date) => date.toString()).join(","), - // onChange(value) { - // const context = getContext() - // const valueAsString = getValueAsString(value, prop) - // prop("onValueChange")?.({ value, valueAsString, view: context.get("view") }) - // }, - })), } }, @@ -249,21 +261,21 @@ export const machine = createMachine({ segments: ({ context, prop }) => { const value = context.get("value") const selectionMode = prop("selectionMode") - const placeholderDate = context.get("placeholderDate")[0] + const placeholderValue = context.get("placeholderValue") const validSegments = context.get("validSegments") const timeZone = prop("timeZone") const translations = prop("translations") || defaultTranslations const granularity = prop("granularity") const formatter = prop("formatter") - let dates: DateValue[] = value?.length ? value : [placeholderDate] + let dates: DateValue[] = value?.length ? value : [placeholderValue] if (selectionMode === "range") { - dates = value?.length ? value : [placeholderDate, placeholderDate] + dates = value?.length ? value : [placeholderValue, placeholderValue] } return dates.map((date, i) => { - const displayValue = date || placeholderDate + const displayValue = date || placeholderValue const currentValidSegments = validSegments?.[i] || {} return processSegments({ @@ -300,7 +312,7 @@ export const machine = createMachine({ }) track([() => context.hash("value")], () => { - action(["syncInputElement"]) + action(["syncValidSegments", "syncInputElement"]) }) track([() => computed("valueAsString").toString()], () => { @@ -868,6 +880,9 @@ export const machine = createMachine({ }) }) }, + syncValidSegments({ context, prop }) { + context.set("validSegments", getDefaultValidSegments(context.get("value"), prop("allSegments"))) + }, setFocusedDate(params) { const { event } = params const value = Array.isArray(event.value) ? event.value[0] : event.value @@ -1284,7 +1299,7 @@ export const machine = createMachine({ const allSegments = prop("allSegments") const formatter = prop("formatter") const index = context.get("activeIndex") - const placeholderDate = context.get("placeholderDate")[index] + const placeholderValue = context.get("placeholderValue") const activeValidSegments = validSegments[index] const activeValue = value[index] const validKeys = Object.keys(activeValidSegments) @@ -1294,7 +1309,7 @@ export const machine = createMachine({ const displayValue = activeValue && Object.keys(activeValidSegments).length >= Object.keys(allSegments).length ? activeValue - : placeholderDate + : placeholderValue const setValue = (newValue: DateValue) => { if (prop("disabled") || prop("readOnly")) return @@ -1322,9 +1337,7 @@ export const machine = createMachine({ values[index] = date context.set("value", values) } else { - const placeholderDates = Array.from(context.get("placeholderDate")) - placeholderDates[index] = date - context.set("placeholderDate", placeholderDates) + context.set("placeholderValue", date) } // clearedSegment.current = null // FIXIT: figure out what is clearedSegment.current used for } diff --git a/packages/machines/date-picker/src/date-picker.props.ts b/packages/machines/date-picker/src/date-picker.props.ts index b6d108315b..a0744d3012 100644 --- a/packages/machines/date-picker/src/date-picker.props.ts +++ b/packages/machines/date-picker/src/date-picker.props.ts @@ -29,6 +29,7 @@ export const props = createProps()([ "name", "numOfMonths", "onFocusChange", + "onPlaceholderChange", "onOpenChange", "onValueChange", "onViewChange", @@ -49,6 +50,10 @@ export const props = createProps()([ "minView", "maxView", "granularity", + "allSegments", + "formatter", + "placeholderValue", + "defaultPlaceholderValue", ]) export const splitProps = createSplitProps>(props) diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index 0ca4f8acd0..b2a0a4b074 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -31,6 +31,10 @@ export interface FocusChangeDetails extends ValueChangeDetails { view: DateView } +export interface PlaceholderChangeDetails extends ValueChangeDetails { + placeholderValue: DateValue +} + export interface ViewChangeDetails { view: DateView } @@ -61,7 +65,7 @@ export interface IntlTranslations { clearTrigger: string trigger: (open: boolean) => string content: string - placeholder: (locale: string) => { year: string; month: string; day: string } + placeholder: (locale: string) => Record } export type ElementIds = Partial<{ @@ -153,6 +157,15 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties { * Use when you don't need to control the focused date of the date picker. */ defaultFocusedValue?: DateValue | undefined + /** + * The controlled placeholder date. + */ + placeholderValue?: DateValue | undefined + /** + * The initial placeholder date when rendered. + * The date that is used when the date picker is empty to determine what point in time the calendar should start at. + */ + defaultPlaceholderValue?: DateValue | undefined /** * The number of months to display. */ @@ -181,6 +194,10 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties { * Function called when the focused date changes. */ onFocusChange?: ((details: FocusChangeDetails) => void) | undefined + /** + * A function called when the placeholder value changes. + */ + onPlaceholderChange?: ((details: PlaceholderChangeDetails) => void) | undefined /** * Function called when the view changes. */ @@ -327,13 +344,13 @@ interface PrivateContext { */ focusedValue: DateValue /** - * The valid segments for each date value (tracks which segments have been filled). + * The placeholder date. */ - validSegments: Segments[] + placeholderValue: DateValue /** - * The placeholder date to use when segments are not filled. + * The valid segments for each date value (tracks which segments have been filled). */ - placeholderDate: DateValue[] + validSegments: Segments[] } type ComputedContext = Readonly<{ @@ -455,6 +472,10 @@ export type Segments = Partial<{ -readonly [K in keyof typeof EDITABLE_SEGMENTS]: boolean }> +export type EditableSegmentType = { + [K in keyof typeof EDITABLE_SEGMENTS]: (typeof EDITABLE_SEGMENTS)[K] extends true ? K : never +}[keyof typeof EDITABLE_SEGMENTS] + export interface DateSegment { /** * The type of segment. @@ -667,6 +688,18 @@ export interface DatePickerApi { * The focused date as a string. */ focusedValueAsString: string + /** + * The placeholder date. + */ + placeholderValue: DateValue + /** + * The placeholder date as a Date object. + */ + placeholderValueAsDate: Date + /** + * The placeholder date as a string. + */ + placeholderValueAsString: string /** * Sets the selected date to today. */ diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index c4af250899..53cbaf65ab 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -1,6 +1,13 @@ import { DateFormatter, getMinimumDayInMonth, getMinimumMonthInYear, type DateValue } from "@internationalized/date" import { clampValue, match } from "@zag-js/utils" -import type { DateSegment, DateView, IntlTranslations, Segments, SegmentType } from "./date-picker.types" +import type { + DateSegment, + DateView, + EditableSegmentType, + IntlTranslations, + Segments, + SegmentType, +} from "./date-picker.types" import type { DateGranularity } from "@zag-js/date-utils" export function adjustStartAndEndDate(value: DateValue[]) { @@ -97,7 +104,7 @@ export const defaultTranslations: IntlTranslations = { }) }, placeholder() { - const placeholders: Record = { + return { day: "dd", month: "mm", year: "yyyy", @@ -112,8 +119,6 @@ export const defaultTranslations: IntlTranslations = { unknown: "unknown", fractionalSecond: "ff", } - - return placeholders }, content: "calendar", monthSelect: "Select month", @@ -183,7 +188,7 @@ export const EDITABLE_SEGMENTS = { timeZoneName: false, weekday: false, unknown: false, - fractionalSecond: true, + fractionalSecond: false, } as const satisfies Record export const PAGE_STEP = { @@ -215,10 +220,14 @@ function getSafeType(t return (TYPE_MAPPING as any)[type] ?? type } -function getPlaceholder(type: keyof Segments, translations: IntlTranslations, locale: string): string { +function getPlaceholder(type: EditableSegmentType, translations: IntlTranslations, locale: string): string { return translations.placeholder(locale)[type] } +function isEditableSegment(type: keyof Intl.DateTimeFormatPartTypesRegistry): type is EditableSegmentType { + return EDITABLE_SEGMENTS[type] === true +} + interface ProcessSegmentsProps { dateValue: Date displayValue: DateValue @@ -245,13 +254,13 @@ export function processSegments({ for (const segment of segments) { const type = getSafeType(segment.type) - let isEditable = EDITABLE_SEGMENTS[type] + let isEditable = isEditableSegment(type) if (type === "era" && displayValue.calendar.getEras().length === 1) { isEditable = false } - const isPlaceholder = EDITABLE_SEGMENTS[type] && !validSegments[type] - const placeholder = EDITABLE_SEGMENTS[type] ? getPlaceholder(type, translations, locale) : null + const isPlaceholder = isEditable && !validSegments[type] + const placeholder = isEditableSegment(type) ? getPlaceholder(type, translations, locale) : null const dateSegment = { type, @@ -411,3 +420,7 @@ export function addSegment( throw new Error("Unknown segment: " + type) } + +export function getDefaultValidSegments(value: DateValue[] | undefined, allSegments: Segments) { + return value?.length ? value.map((date) => (date ? { ...allSegments } : {})) : [{}] +} From 1d1840c2875a99b0bb12c8df03c6986c5affed45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Mon, 8 Sep 2025 21:43:25 +0200 Subject: [PATCH 11/23] feat: fix markValid --- examples/next-ts/pages/date-picker-segment-single.tsx | 2 +- packages/machines/date-picker/src/date-picker.machine.ts | 7 ++++--- packages/machines/date-picker/src/date-picker.utils.ts | 9 ++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment-single.tsx b/examples/next-ts/pages/date-picker-segment-single.tsx index b1fea95d40..4a54720d33 100644 --- a/examples/next-ts/pages/date-picker-segment-single.tsx +++ b/examples/next-ts/pages/date-picker-segment-single.tsx @@ -44,7 +44,7 @@ export default function Page() {
- +
diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 324aa82021..92413db5ad 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -1294,7 +1294,7 @@ export const machine = createMachine({ invokeOnSegmentAdjust({ event, context, prop }) { const { segment, amount } = event const type = segment.type as DateSegment["type"] - const validSegments = Array.from(context.get("validSegments")) + const validSegments = context.get("validSegments") const value = context.get("value") const allSegments = prop("allSegments") const formatter = prop("formatter") @@ -1302,7 +1302,7 @@ export const machine = createMachine({ const placeholderValue = context.get("placeholderValue") const activeValidSegments = validSegments[index] const activeValue = value[index] - const validKeys = Object.keys(activeValidSegments) + let validKeys = Object.keys(activeValidSegments) const allKeys = Object.keys(allSegments) // If all segments are valid, use the date from state, otherwise use the placeholder date. @@ -1347,7 +1347,8 @@ export const machine = createMachine({ if (segmentType === "year" && allSegments.era) { activeValidSegments.era = true } - context.set("validSegments", validSegments) + validKeys = Object.keys(activeValidSegments) + // context.set("validSegments", validSegments) } if (!activeValidSegments?.[type]) { diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index 53cbaf65ab..3f75b19a9c 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -108,12 +108,11 @@ export const defaultTranslations: IntlTranslations = { day: "dd", month: "mm", year: "yyyy", - hour: "hh", - minute: "mm", - second: "ss", + hour: "--", + minute: "--", + second: "--", dayPeriod: "AM/PM", era: "era", - literal: "", // TODO: investigate what this should be timeZoneName: "timeZone", weekday: "weekday", unknown: "unknown", @@ -172,7 +171,7 @@ export function eachView(cb: (view: DateView) => void) { } // --------------------------------------------------- -// SEGMENT +// SEGMENTS // --------------------------------------------------- export const EDITABLE_SEGMENTS = { From cf81627069a7da28f1f28f34394c01d15b22edae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Mon, 8 Sep 2025 22:51:30 +0200 Subject: [PATCH 12/23] feat: wip on backspace --- .../date-picker/src/date-picker.connect.ts | 4 + .../date-picker/src/date-picker.machine.ts | 99 ++++++++++++------- 2 files changed, 68 insertions(+), 35 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index d7fe28de77..3b949fde5f 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -951,6 +951,10 @@ export function connect( End() { send({ type: "SEGMENT.END", focus: true }) }, + Backspace() { + send({ type: "SEGMENT.BACKSPACE", segment, focus: true }) + }, + Delete() {}, } const exec = diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 92413db5ad..0c4eab64ae 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -485,6 +485,9 @@ export const machine = createMachine({ "SEGMENT.ARROW_RIGHT": { actions: ["focusNextSegment"], }, + "SEGMENT.BACKSPACE": { + actions: ["clearSegmentValue"], + }, }, }, @@ -1291,7 +1294,26 @@ export const machine = createMachine({ }) }, - invokeOnSegmentAdjust({ event, context, prop }) { + clearSegmentValue({ event }) { + const { segment } = event + if (segment.isPlaceholder) { + // focus previous segment if the current segment is already a placeholder + return + } + + const newValue = segment.text.slice(0, -1) + const newValuePadded = newValue.padStart(segment.text.length, "0") + + if (newValue.length === 0) { + // clear segment value and mark as placeholder + } else { + // update segment value + segment.text = newValuePadded + } + }, + + invokeOnSegmentAdjust(params) { + const { event, context, prop } = params const { segment, amount } = event const type = segment.type as DateSegment["type"] const validSegments = context.get("validSegments") @@ -1311,44 +1333,13 @@ export const machine = createMachine({ ? activeValue : placeholderValue - const setValue = (newValue: DateValue) => { - if (prop("disabled") || prop("readOnly")) return - const date = constrainValue(newValue, prop("min"), prop("max")) - - // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared - if (newValue == null) { - // setDate(null) - // setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)) - // setValidSegments({}) - } else if ( - // (validKeys.length === 0 && clearedSegment.current == null) || // FIXIT: figure out what is clearedSegment.current used for - validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) - // && clearedSegment.current !== "dayPeriod" // FIXIT: figure out what is clearedSegment.current used for - ) { - // If the field was empty (no valid segments) or all segments are completed, commit the new value. - // When committing from an empty state, mark every segment as valid so value is committed. - if (validKeys.length === 0) { - validSegments[index] = { ...allSegments } - context.set("validSegments", validSegments) - } - - const values = Array.from(context.get("value")) - values[index] = date - context.set("value", values) - } else { - context.set("placeholderValue", date) - } - // clearedSegment.current = null // FIXIT: figure out what is clearedSegment.current used for - } - const markValid = (segmentType: SegmentType) => { activeValidSegments[segmentType] = true if (segmentType === "year" && allSegments.era) { activeValidSegments.era = true } validKeys = Object.keys(activeValidSegments) - // context.set("validSegments", validSegments) + context.set("validSegments", validSegments) } if (!activeValidSegments?.[type]) { @@ -1358,10 +1349,10 @@ export const machine = createMachine({ validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) ) { - setValue(displayValue) + setValue(params, displayValue) } } else { - setValue(addSegment(displayValue, type, amount, formatter.resolvedOptions())) + setValue(params, addSegment(displayValue, type, amount, formatter.resolvedOptions())) } }, }, @@ -1405,3 +1396,41 @@ function setAdjustedValue(ctx: Params, value: AdjustDateReturn if (isDateEqual(focusedValue, value.focusedDate)) return context.set("focusedValue", value.focusedDate) } + +function setValue(ctx: Params, value: DateValue) { + const { context, prop } = ctx + if (prop("disabled") || prop("readOnly")) return + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + const validKeys = Object.keys(activeValidSegments) + const allKeys = Object.keys(allSegments) + const date = constrainValue(value, prop("min"), prop("max")) + + // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared + if (value == null) { + // setDate(null) + // setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)) + // setValidSegments({}) + } else if ( + // (validKeys.length === 0 && clearedSegment.current == null) || // FIXIT: figure out what is clearedSegment.current used for + validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) + // && clearedSegment.current !== "dayPeriod" // FIXIT: figure out what is clearedSegment.current used for + ) { + // If the field was empty (no valid segments) or all segments are completed, commit the new value. + // When committing from an empty state, mark every segment as valid so value is committed. + if (validKeys.length === 0) { + validSegments[index] = { ...allSegments } + context.set("validSegments", validSegments) + } + + const values = Array.from(context.get("value")) + values[index] = date + context.set("value", values) + } else { + context.set("placeholderValue", date) + } + // clearedSegment.current = null // FIXIT: figure out what is clearedSegment.current used for +} From 6bb7dc71d9109b9099e8e11c00f3d80bf8d5b78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Wed, 10 Sep 2025 20:01:35 +0200 Subject: [PATCH 13/23] chore: remove extra pages --- .../pages/date-picker-segment-multi.tsx | 152 ------------------ .../pages/date-picker-segment-range.tsx | 152 ------------------ shared/src/routes.ts | 2 - 3 files changed, 306 deletions(-) delete mode 100644 examples/next-ts/pages/date-picker-segment-multi.tsx delete mode 100644 examples/next-ts/pages/date-picker-segment-range.tsx diff --git a/examples/next-ts/pages/date-picker-segment-multi.tsx b/examples/next-ts/pages/date-picker-segment-multi.tsx deleted file mode 100644 index 38851f946c..0000000000 --- a/examples/next-ts/pages/date-picker-segment-multi.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import * as datePicker from "@zag-js/date-picker" -import { normalizeProps, useMachine } from "@zag-js/react" -import { datePickerControls } from "@zag-js/shared" -import { useId } from "react" -import { StateVisualizer } from "../components/state-visualizer" -import { Toolbar } from "../components/toolbar" -import { useControls } from "../hooks/use-controls" - -export default function Page() { - const controls = useControls(datePickerControls) - const service = useMachine(datePicker.machine, { - id: useId(), - locale: "en", - selectionMode: "multiple", - ...controls.context, - }) - - const api = datePicker.connect(service, normalizeProps) - - return ( - <> -
-
- -
-

{`Visible range: ${api.visibleRangeText.formatted}`}

- - -
Selected: {api.valueAsString ?? "-"}
-
Focused: {api.focusedValueAsString}
-
- -
-
- {api.getSegments().map((segment, i) => ( - - {segment.text} - - ))} -
- - -
- -
-
-
- - - -
- - - -
- - - -
-
-
-
- - - - - - ) -} diff --git a/examples/next-ts/pages/date-picker-segment-range.tsx b/examples/next-ts/pages/date-picker-segment-range.tsx deleted file mode 100644 index 61c339b380..0000000000 --- a/examples/next-ts/pages/date-picker-segment-range.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import * as datePicker from "@zag-js/date-picker" -import { normalizeProps, useMachine } from "@zag-js/react" -import { datePickerControls } from "@zag-js/shared" -import { useId } from "react" -import { StateVisualizer } from "../components/state-visualizer" -import { Toolbar } from "../components/toolbar" -import { useControls } from "../hooks/use-controls" - -export default function Page() { - const controls = useControls(datePickerControls) - const service = useMachine(datePicker.machine, { - id: useId(), - locale: "en", - selectionMode: "range", - ...controls.context, - }) - - const api = datePicker.connect(service, normalizeProps) - - return ( - <> -
-
- -
-

{`Visible range: ${api.visibleRangeText.formatted}`}

- - -
Selected: {api.valueAsString ?? "-"}
-
Focused: {api.focusedValueAsString}
-
- -
-
- {api.getSegments().map((segment, i) => ( - - {segment.text} - - ))} -
- - -
- -
-
-
- - - -
- - - -
- - - -
-
-
-
- - - - - - ) -} diff --git a/shared/src/routes.ts b/shared/src/routes.ts index fb0310f666..1c05c67d87 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -41,8 +41,6 @@ export const routesData: RouteData[] = [ { label: "Date Picker (Month + Range)", path: "/date-picker-month-range" }, { label: "Date Picker (Year + Range)", path: "/date-picker-year-range" }, { label: "Date Picker (Segment Single)", path: "/date-picker-segment-single" }, - { label: "Date Picker (Segment Range)", path: "/date-picker-segment-range" }, - { label: "Date Picker (Segment Multi)", path: "/date-picker-segment-multi" }, { label: "Select", path: "/select" }, { label: "Accordion", path: "/accordion" }, { label: "Checkbox", path: "/checkbox" }, From 3fb2682b6c60554980ab6c9ee43a9fa5da082915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Wed, 10 Sep 2025 21:30:00 +0200 Subject: [PATCH 14/23] feat: wip on segment delete --- .../date-picker/src/date-picker.machine.ts | 114 ++++++++++++------ .../date-picker/src/date-picker.utils.ts | 48 ++++++++ 2 files changed, 125 insertions(+), 37 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 0c4eab64ae..2d466cb764 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -39,6 +39,7 @@ import { isBelowMinView, isValidDate, processSegments, + setSegment, sortDates, TYPE_MAPPING, } from "./date-picker.utils" @@ -1294,21 +1295,29 @@ export const machine = createMachine({ }) }, - clearSegmentValue({ event }) { + clearSegmentValue(params) { + const { event, prop } = params const { segment } = event if (segment.isPlaceholder) { // focus previous segment if the current segment is already a placeholder return } + const displayValue = getDisplayValue(params) + const formatter = prop("formatter") + const newValue = segment.text.slice(0, -1) - const newValuePadded = newValue.padStart(segment.text.length, "0") - if (newValue.length === 0) { + console.log("clear segment: ", newValue) + if (newValue === "") { // clear segment value and mark as placeholder + markSegmentInvalid(params, segment.type as DateSegment["type"]) + setValue(params, displayValue) } else { - // update segment value - segment.text = newValuePadded + setValue( + params, + setSegment(displayValue, segment.type as DateSegment["type"], newValue, formatter.resolvedOptions()), + ) } }, @@ -1317,40 +1326,15 @@ export const machine = createMachine({ const { segment, amount } = event const type = segment.type as DateSegment["type"] const validSegments = context.get("validSegments") - const value = context.get("value") - const allSegments = prop("allSegments") const formatter = prop("formatter") const index = context.get("activeIndex") - const placeholderValue = context.get("placeholderValue") const activeValidSegments = validSegments[index] - const activeValue = value[index] - let validKeys = Object.keys(activeValidSegments) - const allKeys = Object.keys(allSegments) - - // If all segments are valid, use the date from state, otherwise use the placeholder date. - const displayValue = - activeValue && Object.keys(activeValidSegments).length >= Object.keys(allSegments).length - ? activeValue - : placeholderValue - - const markValid = (segmentType: SegmentType) => { - activeValidSegments[segmentType] = true - if (segmentType === "year" && allSegments.era) { - activeValidSegments.era = true - } - validKeys = Object.keys(activeValidSegments) - context.set("validSegments", validSegments) - } - if (!activeValidSegments?.[type]) { - markValid(type) + const displayValue = getDisplayValue(params) - if ( - validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) - ) { - setValue(params, displayValue) - } + if (!activeValidSegments?.[type]) { + markSegmentValid(params, type) + setValue(params, displayValue) } else { setValue(params, addSegment(displayValue, type, amount, formatter.resolvedOptions())) } @@ -1397,6 +1381,64 @@ function setAdjustedValue(ctx: Params, value: AdjustDateReturn context.set("focusedValue", value.focusedDate) } +/** + * If all segments are valid, use return value date, otherwise return the placeholder date. + */ +function getDisplayValue(ctx: Params) { + const { context, prop } = ctx + const index = context.get("activeIndex") + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const value = context.get("value")[index] + const placeholderValue = context.get("placeholderValue") + const activeValidSegments = validSegments[index] + + return value && Object.keys(activeValidSegments).length >= Object.keys(allSegments).length ? value : placeholderValue +} + +function markSegmentInvalid(ctx: Params, segmentType: SegmentType) { + const { context } = ctx + const validSegments = context.get("validSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + + if (activeValidSegments?.[segmentType]) { + delete activeValidSegments[segmentType] + context.set("validSegments", validSegments) + } +} + +function markSegmentValid(ctx: Params, segmentType: SegmentType) { + const { context, prop } = ctx + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + + if (!activeValidSegments?.[segmentType]) { + activeValidSegments[segmentType] = true + if (segmentType === "year" && allSegments.era) { + activeValidSegments.era = true + } + context.set("validSegments", validSegments) + } +} + +function isAllSegmentsCompleted(ctx: Params) { + const { context, prop } = ctx + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + const validKeys = Object.keys(activeValidSegments) + const allKeys = Object.keys(allSegments) + + return ( + validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) + ) +} + function setValue(ctx: Params, value: DateValue) { const { context, prop } = ctx if (prop("disabled") || prop("readOnly")) return @@ -1405,7 +1447,6 @@ function setValue(ctx: Params, value: DateValue) { const index = context.get("activeIndex") const activeValidSegments = validSegments[index] const validKeys = Object.keys(activeValidSegments) - const allKeys = Object.keys(allSegments) const date = constrainValue(value, prop("min"), prop("max")) // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared @@ -1415,8 +1456,7 @@ function setValue(ctx: Params, value: DateValue) { // setValidSegments({}) } else if ( // (validKeys.length === 0 && clearedSegment.current == null) || // FIXIT: figure out what is clearedSegment.current used for - validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) + isAllSegmentsCompleted(ctx) // && clearedSegment.current !== "dayPeriod" // FIXIT: figure out what is clearedSegment.current used for ) { // If the field was empty (no valid segments) or all segments are completed, commit the new value. diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index 3f75b19a9c..045c9e05fa 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -420,6 +420,54 @@ export function addSegment( throw new Error("Unknown segment: " + type) } +export function setSegment( + value: DateValue, + part: string, + segmentValue: number | string, + options: Intl.ResolvedDateTimeFormatOptions, +) { + switch (part) { + case "day": + case "month": + case "year": + case "era": + console.log(segmentValue, value.set({ [part]: segmentValue })) + return value.set({ [part]: segmentValue }) + } + + if ("hour" in value && typeof segmentValue === "number") { + switch (part) { + case "dayPeriod": { + let hours = value.hour + let wasPM = hours >= 12 + let isPM = segmentValue >= 12 + if (isPM === wasPM) { + return value + } + return value.set({ hour: wasPM ? hours - 12 : hours + 12 }) + } + case "hour": + // In 12 hour time, ensure that AM/PM does not change + if (options.hour12) { + let hours = value.hour + let wasPM = hours >= 12 + if (!wasPM && segmentValue === 12) { + segmentValue = 0 + } + if (wasPM && segmentValue < 12) { + segmentValue += 12 + } + } + // fallthrough + case "minute": + case "second": + return value.set({ [part]: segmentValue }) + } + } + + throw new Error("Unknown segment: " + part) +} + export function getDefaultValidSegments(value: DateValue[] | undefined, allSegments: Segments) { return value?.length ? value.map((date) => (date ? { ...allSegments } : {})) : [{}] } From 41b7307e289764cc9532739265f3ae1a01c18287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Wed, 10 Sep 2025 21:40:15 +0200 Subject: [PATCH 15/23] chore: remove console log --- packages/machines/date-picker/src/date-picker.machine.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 2d466cb764..29cc3f3e3c 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -1308,7 +1308,6 @@ export const machine = createMachine({ const newValue = segment.text.slice(0, -1) - console.log("clear segment: ", newValue) if (newValue === "") { // clear segment value and mark as placeholder markSegmentInvalid(params, segment.type as DateSegment["type"]) From 7ff3396674088171b956c8431883f33e9e410d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 13 Sep 2025 16:06:33 +0200 Subject: [PATCH 16/23] refactor(date-picker): simplify key handling and segment validation logic --- .../date-picker/src/date-picker.connect.ts | 13 +++-------- .../date-picker/src/date-picker.machine.ts | 23 +++++++------------ 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index d3c194bbf5..99ca8912c5 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -918,9 +918,6 @@ export function connect( } const keyMap: EventKeyMap = { - Enter() { - send({ type: "SEGMENT.ENTER", focus: true }) - }, ArrowLeft() { send({ type: "SEGMENT.ARROW_LEFT", focus: true }) }, @@ -949,16 +946,12 @@ export function connect( focus: true, }) }, - Home() { - send({ type: "SEGMENT.HOME", focus: true }) - }, - End() { - send({ type: "SEGMENT.END", focus: true }) - }, Backspace() { send({ type: "SEGMENT.BACKSPACE", segment, focus: true }) }, - Delete() {}, + Delete() { + send({ type: "SEGMENT.BACKSPACE", segment, focus: true }) + }, } const exec = diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 29cc3f3e3c..bb6a24564e 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -1308,8 +1308,7 @@ export const machine = createMachine({ const newValue = segment.text.slice(0, -1) - if (newValue === "") { - // clear segment value and mark as placeholder + if (newValue === "" || newValue === "0") { markSegmentInvalid(params, segment.type as DateSegment["type"]) setValue(params, displayValue) } else { @@ -1423,6 +1422,7 @@ function markSegmentValid(ctx: Params, segmentType: SegmentTyp } } +// TODO: maybe move this to computed function isAllSegmentsCompleted(ctx: Params) { const { context, prop } = ctx const validSegments = context.get("validSegments") @@ -1448,18 +1448,7 @@ function setValue(ctx: Params, value: DateValue) { const validKeys = Object.keys(activeValidSegments) const date = constrainValue(value, prop("min"), prop("max")) - // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared - if (value == null) { - // setDate(null) - // setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)) - // setValidSegments({}) - } else if ( - // (validKeys.length === 0 && clearedSegment.current == null) || // FIXIT: figure out what is clearedSegment.current used for - isAllSegmentsCompleted(ctx) - // && clearedSegment.current !== "dayPeriod" // FIXIT: figure out what is clearedSegment.current used for - ) { - // If the field was empty (no valid segments) or all segments are completed, commit the new value. - // When committing from an empty state, mark every segment as valid so value is committed. + if (isAllSegmentsCompleted(ctx)) { if (validKeys.length === 0) { validSegments[index] = { ...allSegments } context.set("validSegments", validSegments) @@ -1471,5 +1460,9 @@ function setValue(ctx: Params, value: DateValue) { } else { context.set("placeholderValue", date) } - // clearedSegment.current = null // FIXIT: figure out what is clearedSegment.current used for +} + +function isNumberString(value: string) { + if (Number.isNaN(Number.parseInt(value))) return false + return true } From 8730fd375dbdf385326047dd3d12ff3be64f3031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 13 Sep 2025 19:06:39 +0200 Subject: [PATCH 17/23] feat: initial segment input support --- .../date-picker/src/date-picker.connect.ts | 24 +++-- .../date-picker/src/date-picker.machine.ts | 87 ++++++++++++++++++- .../date-picker/src/date-picker.types.ts | 4 + .../date-picker/src/date-picker.utils.ts | 1 - 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 99ca8912c5..0c567ffbe0 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -919,23 +919,22 @@ export function connect( const keyMap: EventKeyMap = { ArrowLeft() { - send({ type: "SEGMENT.ARROW_LEFT", focus: true }) + send({ type: "SEGMENT.ARROW_LEFT" }) }, ArrowRight() { - send({ type: "SEGMENT.ARROW_RIGHT", focus: true }) + send({ type: "SEGMENT.ARROW_RIGHT" }) }, ArrowUp() { - send({ type: "SEGMENT.ADJUST", segment, amount: 1, focus: true }) + send({ type: "SEGMENT.ADJUST", segment, amount: 1 }) }, ArrowDown() { - send({ type: "SEGMENT.ADJUST", segment, amount: -1, focus: true }) + send({ type: "SEGMENT.ADJUST", segment, amount: -1 }) }, PageUp() { send({ type: "SEGMENT.ADJUST", segment, amount: PAGE_STEP[segment.type] || 1, - focus: true, }) }, PageDown() { @@ -943,14 +942,13 @@ export function connect( type: "SEGMENT.ADJUST", segment, amount: -(PAGE_STEP[segment.type] ?? 1), - focus: true, }) }, Backspace() { - send({ type: "SEGMENT.BACKSPACE", segment, focus: true }) + send({ type: "SEGMENT.BACKSPACE", segment }) }, Delete() { - send({ type: "SEGMENT.BACKSPACE", segment, focus: true }) + send({ type: "SEGMENT.BACKSPACE", segment }) }, } @@ -973,6 +971,16 @@ export function connect( onMouseDown(event) { event.stopPropagation() }, + onBeforeInput(event) { + const { data } = getNativeEvent(event) + if (!isValidCharacter(data, separator)) { + event.preventDefault() + } + }, + onInput(event) { + const { data } = getNativeEvent(event) + send({ type: "SEGMENT.INPUT", segment, input: data }) + }, }) }, diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index bb6a24564e..b00bf5da58 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -153,6 +153,7 @@ export const machine = createMachine({ refs() { return { announcer: undefined, + enteredKeys: "", } }, @@ -489,6 +490,9 @@ export const machine = createMachine({ "SEGMENT.BACKSPACE": { actions: ["clearSegmentValue"], }, + "SEGMENT.INPUT": { + actions: ["setSegmentValue"], + }, }, }, @@ -1299,7 +1303,7 @@ export const machine = createMachine({ const { event, prop } = params const { segment } = event if (segment.isPlaceholder) { - // focus previous segment if the current segment is already a placeholder + // TODO: focus previous segment if the current segment is already a placeholder return } @@ -1337,6 +1341,87 @@ export const machine = createMachine({ setValue(params, addSegment(displayValue, type, amount, formatter.resolvedOptions())) } }, + + setSegmentValue(params) { + const { event, prop, refs } = params + const { segment, input } = event + if (!isNumberString(input)) return + + const type = segment.type as DateSegment["type"] + const validSegments = params.context.get("validSegments") + const index = params.context.get("activeIndex") + const activeValidSegments = validSegments[index] + const formatter = prop("formatter") + const enteredKeys = refs.get("enteredKeys") + + let newValue = enteredKeys + input + + switch (type) { + case "dayPeriod": + // todo + break + case "era": { + // todo + break + } + case "day": + case "hour": + case "minute": + case "second": + case "month": + case "year": { + let numberValue = Number.parseInt(newValue) + let segmentValue = numberValue + let allowsZero = segment.minValue === 0 + if (segment.type === "hour" && formatter.resolvedOptions().hour12) { + switch (formatter.resolvedOptions().hourCycle) { + case "h11": + if (numberValue > 11) { + segmentValue = Number.parseInt(input) + } + break + case "h12": + allowsZero = false + if (numberValue > 12) { + segmentValue = Number.parseInt(input) + } + break + } + + if (segment.value !== undefined && segment.value >= 12 && numberValue > 1) { + numberValue += 12 + } + } else if (segment.maxValue !== undefined && numberValue > segment.maxValue) { + segmentValue = Number.parseInt(input) + } + + if (isNaN(numberValue)) { + return + } + + let shouldSetValue = segmentValue !== 0 || allowsZero + if (shouldSetValue) { + if (!activeValidSegments?.[type]) { + markSegmentValid(params, type) + } + setValue(params, setSegment(getDisplayValue(params), type, newValue, formatter.resolvedOptions())) + } + + if ( + segment.maxValue !== undefined && + (Number(numberValue + "0") > segment.maxValue || newValue.length >= String(segment.maxValue).length) + ) { + refs.set("enteredKeys", "") + if (shouldSetValue) { + // TODO: focus next segment + } + } else { + refs.set("enteredKeys", newValue) + } + break + } + } + }, }, }, }) diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index b2a0a4b074..45aade3b63 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -397,6 +397,10 @@ type Refs = { * The live region to announce changes */ announcer?: LiveRegion | undefined + /** + * Accumulated keys entered in the focused segment + */ + enteredKeys: string } export interface DatePickerSchema { diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index 045c9e05fa..690fae876b 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -431,7 +431,6 @@ export function setSegment( case "month": case "year": case "era": - console.log(segmentValue, value.set({ [part]: segmentValue })) return value.set({ [part]: segmentValue }) } From f553a3d5978e3c9e4223a13b730ffd2627400b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 20 Sep 2025 12:45:00 +0200 Subject: [PATCH 18/23] feat: improve focus management --- .../date-picker/src/date-picker.connect.ts | 3 - .../date-picker/src/date-picker.machine.ts | 74 ++++++++++--------- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 2ebd4248da..5d9cadf880 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -867,9 +867,6 @@ export function connect( style: { unicodeBidi: "isolate", }, - onFocus() { - send({ type: "SEGMENT_GROUP.FOCUS" }) - }, }) }, diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index b00bf5da58..5f0457a895 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -437,10 +437,6 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], - "SEGMENT_GROUP.FOCUS": { - target: "focused", - actions: ["focusFirstSegment"], - }, "SEGMENT.FOCUS": { target: "focused", actions: ["setActiveSegmentIndex"], @@ -476,20 +472,26 @@ export const machine = createMachine({ }, ], "SEGMENT.FOCUS": { - actions: ["setActiveSegmentIndex"], + actions: ["setActiveSegmentIndex", "clearEnteredKeys"], }, "SEGMENT.ADJUST": { actions: ["invokeOnSegmentAdjust"], }, "SEGMENT.ARROW_LEFT": { - actions: ["focusPreviousSegment"], + actions: ["setPreviousActiveSegmentIndex", "clearEnteredKeys"], }, "SEGMENT.ARROW_RIGHT": { - actions: ["focusNextSegment"], - }, - "SEGMENT.BACKSPACE": { - actions: ["clearSegmentValue"], + actions: ["setNextActiveSegmentIndex", "clearEnteredKeys"], }, + "SEGMENT.BACKSPACE": [ + { + guard: "isActiveSegmentPlaceholder", + actions: ["setPreviousActiveSegmentIndex"], + }, + { + actions: ["clearSegmentValue", "clearEnteredKeys"], + }, + ], "SEGMENT.INPUT": { actions: ["setSegmentValue"], }, @@ -796,6 +798,7 @@ export const machine = createMachine({ isInteractOutsideEvent: ({ event }) => event.previousEvent?.type === "INTERACT_OUTSIDE", isInputValueEmpty: ({ event }) => event.value.trim() === "", shouldFixOnBlur: ({ event }) => !!event.fixOnBlur, + isActiveSegmentPlaceholder: (ctx) => getActiveSegment(ctx)?.isPlaceholder === true, }, effects: { @@ -1262,33 +1265,28 @@ export const machine = createMachine({ context.set("activeSegmentIndex", event.index) }, - focusFirstSegment({ scope }) { - raf(() => { - const segmentEls = dom.getSegmentEls(scope) - const firstSegmentEl = segmentEls.find((el) => el.hasAttribute("data-editable")) - firstSegmentEl?.focus({ preventScroll: true }) - }) + clearEnteredKeys({ refs }) { + refs.set("enteredKeys", "") }, - focusNextSegment({ scope, context }) { - raf(() => { - const segmentEls = dom.getSegmentEls(scope) - const nextSegmentEl = segmentEls - .slice(context.get("activeSegmentIndex") + 1) - .find((el) => el.hasAttribute("data-editable")) - nextSegmentEl?.focus({ preventScroll: true }) - }) + setPreviousActiveSegmentIndex({ context, computed }) { + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + const segments = computed("segments")[index] + const previousActiveSegmentIndex = segments.findLastIndex( + (segment, i) => i < activeSegmentIndex && segment.isEditable, + ) + if (previousActiveSegmentIndex === -1) return + context.set("activeSegmentIndex", previousActiveSegmentIndex) }, - focusPreviousSegment({ scope, context }) { - raf(() => { - const segmentEls = dom.getSegmentEls(scope) - const prevSegmentEl = segmentEls - .slice(0, context.get("activeSegmentIndex")) - .reverse() - .find((el) => el.hasAttribute("data-editable")) - prevSegmentEl?.focus({ preventScroll: true }) - }) + setNextActiveSegmentIndex({ context, computed }) { + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + const segments = computed("segments")[index] + const nextActiveSegmentIndex = segments.findIndex((segment, i) => i > activeSegmentIndex && segment.isEditable) + if (nextActiveSegmentIndex === -1) return + context.set("activeSegmentIndex", nextActiveSegmentIndex) }, focusActiveSegment({ scope, context }) { @@ -1304,6 +1302,7 @@ export const machine = createMachine({ const { segment } = event if (segment.isPlaceholder) { // TODO: focus previous segment if the current segment is already a placeholder + return } @@ -1353,6 +1352,7 @@ export const machine = createMachine({ const activeValidSegments = validSegments[index] const formatter = prop("formatter") const enteredKeys = refs.get("enteredKeys") + console.log(enteredKeys) let newValue = enteredKeys + input @@ -1414,6 +1414,7 @@ export const machine = createMachine({ refs.set("enteredKeys", "") if (shouldSetValue) { // TODO: focus next segment + params.send({ type: "SEGMENT.FOCUS_NEXT" }) } } else { refs.set("enteredKeys", newValue) @@ -1551,3 +1552,10 @@ function isNumberString(value: string) { if (Number.isNaN(Number.parseInt(value))) return false return true } + +function getActiveSegment(ctx: Params) { + const { context, computed } = ctx + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + return computed("segments")[index]?.[activeSegmentIndex] +} From ca53ef07e04158d8212d88d5e21d2bfed8b62c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 20 Sep 2025 15:17:30 +0200 Subject: [PATCH 19/23] chore: updates --- .../pages/date-picker-segment-single.tsx | 2 -- .../date-picker/src/date-picker.machine.ts | 18 ++++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/next-ts/pages/date-picker-segment-single.tsx b/examples/next-ts/pages/date-picker-segment-single.tsx index 4a54720d33..66acf46836 100644 --- a/examples/next-ts/pages/date-picker-segment-single.tsx +++ b/examples/next-ts/pages/date-picker-segment-single.tsx @@ -12,8 +12,6 @@ export default function Page() { id: useId(), selectionMode: "single", ...controls.context, - locale: "de", - defaultPlaceholderValue: datePicker.parse("2022-12-25"), }) const api = datePicker.connect(service, normalizeProps) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 5f0457a895..28f38d8b15 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -1300,11 +1300,6 @@ export const machine = createMachine({ clearSegmentValue(params) { const { event, prop } = params const { segment } = event - if (segment.isPlaceholder) { - // TODO: focus previous segment if the current segment is already a placeholder - - return - } const displayValue = getDisplayValue(params) const formatter = prop("formatter") @@ -1342,7 +1337,7 @@ export const machine = createMachine({ }, setSegmentValue(params) { - const { event, prop, refs } = params + const { event, prop, refs, context, computed } = params const { segment, input } = event if (!isNumberString(input)) return @@ -1352,7 +1347,6 @@ export const machine = createMachine({ const activeValidSegments = validSegments[index] const formatter = prop("formatter") const enteredKeys = refs.get("enteredKeys") - console.log(enteredKeys) let newValue = enteredKeys + input @@ -1373,6 +1367,7 @@ export const machine = createMachine({ let numberValue = Number.parseInt(newValue) let segmentValue = numberValue let allowsZero = segment.minValue === 0 + if (segment.type === "hour" && formatter.resolvedOptions().hour12) { switch (formatter.resolvedOptions().hourCycle) { case "h11": @@ -1414,7 +1409,14 @@ export const machine = createMachine({ refs.set("enteredKeys", "") if (shouldSetValue) { // TODO: focus next segment - params.send({ type: "SEGMENT.FOCUS_NEXT" }) + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + const segments = computed("segments")[index] + const nextActiveSegmentIndex = segments.findIndex( + (segment, i) => i > activeSegmentIndex && segment.isEditable, + ) + if (nextActiveSegmentIndex === -1) return + context.set("activeSegmentIndex", nextActiveSegmentIndex) } } else { refs.set("enteredKeys", newValue) From 5fb170c0f7bb99f0d97a6f45a3a675d2095661c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 20 Sep 2025 15:38:00 +0200 Subject: [PATCH 20/23] feat: fix input issures --- .../date-picker/src/date-picker.connect.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 5d9cadf880..d2318a1bdb 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -993,15 +993,20 @@ export function connect( event.stopPropagation() }, onBeforeInput(event) { - const { data } = getNativeEvent(event) - if (!isValidCharacter(data, separator)) { + const { data, inputType } = getNativeEvent(event) + const allowedInputTypes = ["deleteContentBackward", "deleteContentForward", "deleteByCut", "deleteByDrag"] + + if (allowedInputTypes.includes(inputType)) { + return + } + + if (data && isValidCharacter(data, separator)) { + event.preventDefault() + send({ type: "SEGMENT.INPUT", segment, input: data }) + } else { event.preventDefault() } }, - onInput(event) { - const { data } = getNativeEvent(event) - send({ type: "SEGMENT.INPUT", segment, input: data }) - }, }) }, From 9d4cc93574ba4d28b5fe8890fd7c51deeeb166c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Mon, 22 Sep 2025 09:14:18 +0200 Subject: [PATCH 21/23] feat: clear placeholder data --- .../machines/date-picker/src/date-picker.machine.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 28f38d8b15..0a217f20f3 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -345,7 +345,13 @@ export const machine = createMachine({ actions: ["setFocusedDate"], }, "VALUE.CLEAR": { - actions: ["clearDateValue", "clearFocusedDate", "focusFirstInputElement"], + actions: [ + "clearDateValue", + "clearFocusedDate", + "clearPlaceholderDate", + "clearEnteredKeys", + "focusFirstInputElement", + ], }, "INPUT.CHANGE": [ { @@ -1265,6 +1271,11 @@ export const machine = createMachine({ context.set("activeSegmentIndex", event.index) }, + clearPlaceholderDate(params) { + const { prop, context } = params + context.set("placeholderValue", getTodayDate(prop("timeZone"))) + }, + clearEnteredKeys({ refs }) { refs.set("enteredKeys", "") }, From e69314750520d058922f843da5c73bed60268071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 27 Sep 2025 14:17:07 +0200 Subject: [PATCH 22/23] feat: implement home and end key actions --- .../date-picker/src/date-picker.connect.ts | 8 +- .../date-picker/src/date-picker.machine.ts | 199 ++++++++++-------- 2 files changed, 122 insertions(+), 85 deletions(-) diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index d2318a1bdb..c8e7cb760e 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -955,7 +955,7 @@ export function connect( send({ type: "SEGMENT.ADJUST", segment, - amount: PAGE_STEP[segment.type] || 1, + amount: PAGE_STEP[segment.type] ?? 1, }) }, PageDown() { @@ -971,6 +971,12 @@ export function connect( Delete() { send({ type: "SEGMENT.BACKSPACE", segment }) }, + Home() { + send({ type: "SEGMENT.HOME", segment }) + }, + End() { + send({ type: "SEGMENT.END", segment }) + }, } const exec = diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 0a217f20f3..ff4ec9ad34 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -480,6 +480,9 @@ export const machine = createMachine({ "SEGMENT.FOCUS": { actions: ["setActiveSegmentIndex", "clearEnteredKeys"], }, + "SEGMENT.INPUT": { + actions: ["setSegmentValue"], + }, "SEGMENT.ADJUST": { actions: ["invokeOnSegmentAdjust"], }, @@ -498,8 +501,11 @@ export const machine = createMachine({ actions: ["clearSegmentValue", "clearEnteredKeys"], }, ], - "SEGMENT.INPUT": { - actions: ["setSegmentValue"], + "SEGMENT.HOME": { + actions: ["setSegmentToLowestValue", "clearEnteredKeys"], + }, + "SEGMENT.END": { + actions: ["setSegmentToHighestValue", "clearEnteredKeys"], }, }, }, @@ -1265,7 +1271,7 @@ export const machine = createMachine({ send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: event }) }, - // SEGMENT + // SEGMENT ACTIONS [START] ///////////////////////////////////////////////////////////////////////////// setActiveSegmentIndex({ context, event }) { context.set("activeSegmentIndex", event.index) @@ -1348,94 +1354,27 @@ export const machine = createMachine({ }, setSegmentValue(params) { - const { event, prop, refs, context, computed } = params + const { event } = params const { segment, input } = event - if (!isNumberString(input)) return - const type = segment.type as DateSegment["type"] - const validSegments = params.context.get("validSegments") - const index = params.context.get("activeIndex") - const activeValidSegments = validSegments[index] - const formatter = prop("formatter") - const enteredKeys = refs.get("enteredKeys") - - let newValue = enteredKeys + input + updateSegmentValue(params, segment, input) + }, - switch (type) { - case "dayPeriod": - // todo - break - case "era": { - // todo - break - } - case "day": - case "hour": - case "minute": - case "second": - case "month": - case "year": { - let numberValue = Number.parseInt(newValue) - let segmentValue = numberValue - let allowsZero = segment.minValue === 0 - - if (segment.type === "hour" && formatter.resolvedOptions().hour12) { - switch (formatter.resolvedOptions().hourCycle) { - case "h11": - if (numberValue > 11) { - segmentValue = Number.parseInt(input) - } - break - case "h12": - allowsZero = false - if (numberValue > 12) { - segmentValue = Number.parseInt(input) - } - break - } - - if (segment.value !== undefined && segment.value >= 12 && numberValue > 1) { - numberValue += 12 - } - } else if (segment.maxValue !== undefined && numberValue > segment.maxValue) { - segmentValue = Number.parseInt(input) - } + setSegmentToLowestValue(params) { + const { event } = params + const { segment } = event - if (isNaN(numberValue)) { - return - } + updateSegmentValue(params, segment, String(segment.minValue)) + }, - let shouldSetValue = segmentValue !== 0 || allowsZero - if (shouldSetValue) { - if (!activeValidSegments?.[type]) { - markSegmentValid(params, type) - } - setValue(params, setSegment(getDisplayValue(params), type, newValue, formatter.resolvedOptions())) - } + setSegmentToHighestValue(params) { + const { event } = params + const { segment } = event - if ( - segment.maxValue !== undefined && - (Number(numberValue + "0") > segment.maxValue || newValue.length >= String(segment.maxValue).length) - ) { - refs.set("enteredKeys", "") - if (shouldSetValue) { - // TODO: focus next segment - const index = context.get("activeIndex") - const activeSegmentIndex = context.get("activeSegmentIndex") - const segments = computed("segments")[index] - const nextActiveSegmentIndex = segments.findIndex( - (segment, i) => i > activeSegmentIndex && segment.isEditable, - ) - if (nextActiveSegmentIndex === -1) return - context.set("activeSegmentIndex", nextActiveSegmentIndex) - } - } else { - refs.set("enteredKeys", newValue) - } - break - } - } + updateSegmentValue(params, segment, String(segment.maxValue)) }, + + // SEGMENT ACTIONS [END] /////////////////////////////////////////////////////////////////////////////// }, }, }) @@ -1478,6 +1417,8 @@ function setAdjustedValue(ctx: Params, value: AdjustDateReturn context.set("focusedValue", value.focusedDate) } +// SEGMENT UTILS [START] ///////////////////////////////////////////////////////////////////////////// + /** * If all segments are valid, use return value date, otherwise return the placeholder date. */ @@ -1572,3 +1513,93 @@ function getActiveSegment(ctx: Params) { const activeSegmentIndex = context.get("activeSegmentIndex") return computed("segments")[index]?.[activeSegmentIndex] } + +function updateSegmentValue(ctx: Params, segment: DateSegment, input: string) { + const { context, computed, prop, refs } = ctx + const type = segment.type as DateSegment["type"] + const validSegments = context.get("validSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + const formatter = prop("formatter") + const enteredKeys = refs.get("enteredKeys") + + switch (type) { + case "dayPeriod": + // TODO + break + case "era": { + // TODO + break + } + case "day": + case "hour": + case "minute": + case "second": + case "month": + case "year": { + let newValue = enteredKeys + input + let numberValue = Number.parseInt(newValue) + let segmentValue = numberValue + let allowsZero = segment.minValue === 0 + + if (!isNumberString(input)) return + + if (segment.type === "hour" && formatter.resolvedOptions().hour12) { + switch (formatter.resolvedOptions().hourCycle) { + case "h11": + if (numberValue > 11) { + segmentValue = Number.parseInt(input) + } + break + case "h12": + allowsZero = false + if (numberValue > 12) { + segmentValue = Number.parseInt(input) + } + break + } + + if (segment.value !== undefined && segment.value >= 12 && numberValue > 1) { + numberValue += 12 + } + } else if (segment.maxValue !== undefined && numberValue > segment.maxValue) { + segmentValue = Number.parseInt(input) + } + + if (isNaN(numberValue)) { + return + } + + // TODO: `segmentValue` is not used for anything? + let shouldSetValue = segmentValue !== 0 || allowsZero + if (shouldSetValue) { + if (!activeValidSegments?.[type]) { + markSegmentValid(ctx, type) + } + setValue(ctx, setSegment(getDisplayValue(ctx), type, newValue, formatter.resolvedOptions())) + } + + if ( + segment.maxValue !== undefined && + (Number(numberValue + "0") > segment.maxValue || newValue.length >= String(segment.maxValue).length) + ) { + refs.set("enteredKeys", "") + if (shouldSetValue) { + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + const segments = computed("segments")[index] + const nextActiveSegmentIndex = segments.findIndex( + (segment, i) => i > activeSegmentIndex && segment.isEditable, + ) + if (nextActiveSegmentIndex === -1) return + context.set("activeSegmentIndex", nextActiveSegmentIndex) + } + } else { + refs.set("enteredKeys", newValue) + } + break + } + } +} + +// SEGMENT UTILS [END] ///////////////////////////////////////////////////////////////////////////////// From b2658c68fb734e6c35c7c52b3137c094a5c2bf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivica=20Batini=C4=87?= Date: Sat, 27 Sep 2025 14:27:27 +0200 Subject: [PATCH 23/23] feat: prevent default behavior for paste events in date picker input --- packages/machines/date-picker/src/date-picker.connect.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index c8e7cb760e..06ad02a271 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -1006,6 +1006,11 @@ export function connect( return } + if (inputType === "insertFromPaste") { + event.preventDefault() + return + } + if (data && isValidCharacter(data, separator)) { event.preventDefault() send({ type: "SEGMENT.INPUT", segment, input: data }) @@ -1013,6 +1018,9 @@ export function connect( event.preventDefault() } }, + onPaste(event) { + event.preventDefault() + }, }) },