OfficeGest
Form Builder/Field Types

Date & Time Fields

date, time, datetime, and dateRange, built on the unified DatePicker/TimePicker primitives.

The form builder ships four date-family field types: date, time, datetime, and dateRange. All four are thin renderers over two shared primitives — DatePicker (for date, datetime, and dateRange) and TimePicker (for time). Every one is a controlled component that stores a plain string (or, for dateRange, an object of two strings), so the value you read from form state is always a stable, serializable shape.

Each picker pairs a free-text input — with natural-language parsing via chrono-node ("tomorrow", "next monday", "in 3 days") plus shorthand formats ("20260315", "15-03-2026" for dates; "9"09:00, "930"9:30 for times) — with a popover-anchored calendar or stepper. Invalid text reverts to the last committed value on blur, so a typo never silently overwrites the field.

The time field uses the custom TimePicker primitive (an input group with a popover hour/minute stepper), not a native <input type="time">. The picker UX, keyboard map, and chrono parsing are all owned by the primitive.


Stored values and locale

TypeStored valueFormatExample
datestring | undefinedISO "YYYY-MM-DD""2026-03-15"
timestring | undefined"HH:mm""09:30"
datetimestring | undefinedISO "YYYY-MM-DDTHH:mm""2026-03-15T14:00"
dateRange{ from?: string; to?: string }each endpoint ISO "YYYY-MM-DD"{ from: "2026-03-15", to: "2026-03-20" }

The stored value and the text shown in the input are always ISO and locale-independent. The datetime field is the one exception to "what you store is what you see": it stores "YYYY-MM-DDTHH:mm" (with the T) but displays "YYYY-MM-DD HH:mm" (space-separated) for readability.

Locale comes from the app's useLocale() hook — no per-field locale prop is exposed. The locale only localizes the calendar popover's month and weekday names; it does not change the input text or the stored value.

When the field value is undefined, the input renders empty and nothing is committed until the user picks a value. The form builder honors the "undefined means empty" contract: the legacy default-to-today / default-to-now behaviors are deliberately off and are not exposed through any field config.


date

A single calendar date stored as an ISO "YYYY-MM-DD" string. This is the only date-family type that surfaces bounds.

PropTypeDescription
type"date"Discriminator.
labelstringi18n key for the label.
descriptionstringi18n key for help text under the input.
placeholderstringi18n key for the input placeholder.
minstringISO "YYYY-MM-DD" lower bound. Gates both the calendar's disabled days and the input-blur parse path.
maxstringISO "YYYY-MM-DD" upper bound.
colSpanColSpanGrid column span (number or per-breakpoint object).
disabled, required, visibleboolean | predicateStandard field-config predicates.
const fields: FormFields<typeof schema> = {
  start_date: {
    type: "date",
    label: "Start date",
    description: "Stored as ISO YYYY-MM-DD.",
    placeholder: "Pick a date",
    min: "2026-01-01",
    max: "2026-12-31",
    colSpan: {base: 12, md: 6},
  },
};

Dates entered as free text that fall outside min/max are rejected on blur and the input reverts. The constraints are a UI affordance — keep the hard rule in your Zod schema (e.g. z.iso.date() with a .refine) so it also covers programmatic values.


time

A time of day stored as an "HH:mm" string. The config is FieldConfigBase only — no min, max, or minuteStep is forwarded from the field config (the primitive's default minute step of 5 applies).

PropTypeDescription
type"time"Discriminator.
labelstringi18n key for the label.
descriptionstringi18n key for help text.
placeholderstringi18n key for the input placeholder.
colSpanColSpanGrid column span.
disabled, required, visibleboolean | predicateStandard field-config predicates.
const fields: FormFields<typeof schema> = {
  start_time: {
    type: "time",
    label: "Start time",
    description: "Stored as HH:mm.",
    placeholder: "HH:mm",
    colSpan: {base: 12, md: 6},
  },
};

datetime

A combined date and time in a single popover — calendar above, hour/minute stepper below — stored as ISO "YYYY-MM-DDTHH:mm". The config is FieldConfigBase only.

PropTypeDescription
type"datetime"Discriminator.
labelstringi18n key for the label.
descriptionstringi18n key for help text.
placeholderstringi18n key for the input placeholder.
colSpanColSpanGrid column span.
disabled, required, visibleboolean | predicateStandard field-config predicates.
const fields: FormFields<typeof schema> = {
  starts_at: {
    type: "datetime",
    label: "Starts at",
    description: "Stored as YYYY-MM-DDTHH:mm.",
    placeholder: "Pick a date and time",
    colSpan: {base: 12, md: 6},
  },
};

The built-in datetime field does not forward min / max bounds. If you need to constrain a datetime field, add a custom field type via the slot system — the underlying DatePicker primitive supports bounds, but the built-in renderer does not pass them through.


dateRange

A two-endpoint range — a from input and a to input sharing one two-month calendar popover. Stored as { from?: string; to?: string } with each endpoint an ISO "YYYY-MM-DD" string. The config is FieldConfigBase only.

PropTypeDescription
type"dateRange"Discriminator.
labelstringi18n key for the label.
descriptionstringi18n key for help text.
placeholderstringi18n key for both endpoint inputs.
colSpanColSpanGrid column span.
disabled, required, visibleboolean | predicateStandard field-config predicates.
const fields: FormFields<typeof schema> = {
  window: {
    type: "dateRange",
    label: "Window",
    description: "Stored as { from, to }.",
    placeholder: "Pick a range",
    colSpan: {base: 12, md: 6},
  },
};

dateRange is a date-only range. A time component is not supported on a range — the range mode of the picker has no showTime option. Use two separate datetime fields if you need a time-aware window.

The renderer forwards whatever the picker emits, including a partial range mid-selection (only from set, to still undefined). It is up to your Zod schema to decide whether a partial range is acceptable — see below.


Schema contracts

The renderers are contract-neutral: they store ISO strings and never coerce or default. Your schema decides what is valid.

dateRange: strict vs. lenient

Pick the schema shape based on whether a half-filled range should validate:

const strict = z.object({
  window: z.object({
    from: z.iso.date(),
    to: z.iso.date(),
  }),
});

const lenient = z.object({
  window: z.object({
    from: z.iso.date().optional(),
    to: z.iso.date().optional(),
  }),
});

With the strict shape, the moment the user picks the first endpoint the schema fails (because to is still missing) and an error shows until both endpoints are set. If you want the field to stay quiet until the user commits both ends, use the lenient shape and enforce "both or neither" with a .refine on the object.

Hidden date fields are still validated

A date-family field gated by a visible predicate is unmounted when hidden, but its stored value is retained and is still validated on submit. If the branch can genuinely have no date, make that part of the schema optional so a hidden, empty field does not block submission with an error the user can't see:

const schema = z.object({
  has_deadline: z.boolean(),
  deadline: z.iso.date().optional(),
});

const fields: FormFields<typeof schema> = {
  has_deadline: {type: "switch", label: "Has deadline"},
  deadline: {
    type: "date",
    label: "Deadline",
    visible: (values) => values.has_deadline === true,
  },
};

For a branch where the date is required only when visible, model it with z.discriminatedUnion rather than a plain optional.


Full example

This is the playground's date/time section: all four types side by side, each driven through a single schema and useFormBuilder call.

const schema = z.object({
  start_date: z.string().min(1),
  start_time: z.string().min(1),
  starts_at: z.string().min(1),
  window: z.object({
    from: z.string().optional(),
    to: z.string().optional(),
  }),
});

const fields: FormFields<typeof schema> = {
  start_date: {
    type: "date",
    label: "Start date",
    placeholder: "Pick a date",
    colSpan: {base: 12, md: 6},
  },
  start_time: {
    type: "time",
    label: "Start time",
    placeholder: "HH:mm",
    colSpan: {base: 12, md: 6},
  },
  starts_at: {
    type: "datetime",
    label: "Starts at",
    placeholder: "Pick a date and time",
    colSpan: {base: 12, md: 6},
  },
  window: {
    type: "dateRange",
    label: "Window",
    placeholder: "Pick a range",
    colSpan: {base: 12, md: 6},
  },
};

function FieldsDateTime() {
  const form = useFormBuilder({
    formId: "fields-date-time",
    schema,
    fields,
    defaultValues: {
      start_date: "2026-03-15",
      start_time: "09:30",
      starts_at: "2026-03-15T14:00",
      window: {from: "2026-03-15", to: "2026-03-20"},
    },
    onSubmit: (values) => console.log(values),
  });

  return (
    <form.Provider>
      <form.Fields names={["start_date", "start_time", "starts_at", "window"]} />
      <form.SubmitButton>Save</form.SubmitButton>
    </form.Provider>
  );
}

Submitting with the default values yields:

{
  "start_date": "2026-03-15",
  "start_time": "09:30",
  "starts_at": "2026-03-15T14:00",
  "window": { "from": "2026-03-15", "to": "2026-03-20" }
}