{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"date-field","title":"Date Field","description":"Segmented, locale-aware date entry with optional Calendar action and native form serialization.","dependencies":["@internationalized/date@3.12.3","lucide-react@1.18.0","react-aria-components@1.20.0"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/calendar","@siteplane/input","@siteplane/input-group","@siteplane/locale","@siteplane/popover"],"files":[{"path":"packages/ui/src/components/date-field/date-field.tsx","content":"\"use client\";\n\nimport { type CalendarDate, type DateValue } from \"@internationalized/date\";\nimport { CalendarDaysIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport {\n  DateField as DateFieldPrimitive,\n  type DateFieldState,\n  DateInput as DateInputPrimitive,\n  DateSegment as DateSegmentPrimitive,\n  useLocale,\n} from \"react-aria-components\";\n\nimport { isInvalidAriaValue } from \"../../system/form/aria-invalid\";\nimport { useFormReset } from \"../../system/form/use-form-reset\";\nimport { normalizeFormattedText } from \"../../system/locale/formatted-text\";\nimport { SiteplaneLocaleProvider } from \"../../system/locale/siteplane-locale-provider\";\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport {\n  type FormControlTextSize,\n  type FormControlVariant,\n} from \"../../system/styles/surface\";\nimport { cn } from \"../../system/utils/cn\";\nimport { Button } from \"../button\";\nimport { Calendar } from \"../calendar\";\nimport {\n  calendarMonthBounds,\n  type CalendarOptions,\n  fromNativeDate,\n  isCalendarDayDisabled,\n  toNativeDate,\n} from \"../calendar/calendar\";\nimport {\n  type FieldControlBridge,\n  hasClearedSegment,\n  type SegmentedValue,\n  useFieldControlBridge,\n  useFieldControlProxy,\n  useFieldErrorSegments,\n  useSegmentedValue,\n} from \"../field/field-control-bridge\";\nimport { Input } from \"../input\";\nimport { ExternalInputOwner } from \"../input/input\";\nimport { InputGroup, InputGroupAddon } from \"../input-group\";\nimport { Popover, PopoverPopup, PopoverTrigger } from \"../popover\";\n\nimport \"./date-field.css\";\n\nexport interface DateFieldProps {\n  \"aria-describedby\"?: string | undefined;\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"];\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string | undefined;\n  calendarProps?: CalendarOptions;\n  className?: string;\n  defaultValue?: Date;\n  disabled?: boolean;\n  form?: string;\n  iconMode?: \"action-end\" | \"decorative-start\" | \"none\";\n  /**\n   * The error a required field shows while its date is incomplete, such as\n   * after a segment is cleared. Default: \"Enter a complete date.\"\n   */\n  incompleteLabel?: string;\n  insetStart?: boolean;\n  locale?: string;\n  name?: string;\n  onValueChange?: (value: Date | undefined) => void;\n  openPickerLabel?: string;\n  /**\n   * `input-group` renders only the date segments, without surface, calendar\n   * button or popover, for use inside your own InputGroup.\n   */\n  profile?: \"default\" | \"input-group\";\n  readOnly?: boolean;\n  required?: boolean;\n  size?: SiteplaneControlSize;\n  textSize?: FormControlTextSize;\n  value?: Date | null;\n  variant?: FormControlVariant;\n}\n\nconst dateFieldActionSizes = {\n  md: \"icon-xs\",\n  lg: \"icon-xs\",\n  sm: \"icon-2xs\",\n  xl: \"icon-sm\",\n} as const satisfies Record<\n  SiteplaneControlSize,\n  \"icon-2xs\" | \"icon-xs\" | \"icon-sm\"\n>;\n\nfunction toCalendarDate(value: Date | null | undefined): CalendarDate | null {\n  return value ? fromNativeDate(value) : null;\n}\n\nfunction toOptionalNativeDate(\n  value: DateValue | null | undefined,\n): Date | undefined {\n  return value ? toNativeDate(value) : undefined;\n}\n\nfunction formatDateFieldValue(value: DateValue | null): string {\n  if (!value) return \"\";\n  return `${String(value.year).padStart(4, \"0\")}-${String(value.month).padStart(2, \"0\")}-${String(value.day).padStart(2, \"0\")}`;\n}\n\nexport function DateField(props: DateFieldProps): React.ReactElement {\n  const { locale } = props;\n  const field = <DateFieldRoot {...props} />;\n\n  return locale ? (\n    <SiteplaneLocaleProvider locale={locale}>{field}</SiteplaneLocaleProvider>\n  ) : (\n    field\n  );\n}\n\nfunction DateFieldRoot({\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  calendarProps,\n  className,\n  defaultValue,\n  disabled,\n  form,\n  iconMode = \"action-end\",\n  incompleteLabel = \"Enter a complete date.\",\n  insetStart = false,\n  name,\n  onValueChange,\n  openPickerLabel = \"Open date calendar\",\n  profile = \"default\",\n  readOnly,\n  required,\n  size,\n  textSize,\n  value,\n  variant = \"default\",\n}: DateFieldProps): React.ReactElement {\n  const controlled = value !== undefined;\n  const resolvedSize = useResolvedControlSize(size);\n  const field = useFieldControlBridge({\n    \"aria-describedby\": ariaDescribedBy,\n    \"aria-labelledby\": ariaLabelledBy,\n    disabled,\n    name,\n  });\n  // React Aria validates typed dates unless the application controls the\n  // invalid state through aria-invalid or an invalid Field.\n  const controlledInvalid =\n    ariaInvalid !== undefined\n      ? isInvalidAriaValue(ariaInvalid)\n      : field.invalid || undefined;\n  // A cleared segment empties the field's value.\n  const segmented = useSegmentedValue<CalendarDate>({\n    controlled,\n    defaultValue: toCalendarDate(defaultValue),\n    incompleteMessage: incompleteLabel,\n    onValueChange: (nextValue) =>\n      onValueChange?.(toOptionalNativeDate(nextValue)),\n    required,\n    serialize: formatDateFieldValue,\n    value: toCalendarDate(value),\n  });\n\n  // React Aria's optional props reject `undefined` under\n  // exactOptionalPropertyTypes, so unset values are omitted.\n  return (\n    <DateFieldPrimitive<CalendarDate>\n      {...(field.describedBy === undefined\n        ? {}\n        : { \"aria-describedby\": field.describedBy })}\n      {...(field.labelledBy || ariaLabel === undefined\n        ? {}\n        : { \"aria-label\": ariaLabel })}\n      {...(field.labelledBy === undefined\n        ? {}\n        : { \"aria-labelledby\": field.labelledBy })}\n      className=\"sp-date-field sp-base-date-field contents\"\n      data-foundation=\"date-field\"\n      data-inset-start={insetStart || undefined}\n      data-profile={profile}\n      data-size={resolvedSize}\n      data-slot=\"date-field\"\n      {...(controlled\n        ? { value: segmented.ownerValue }\n        : { defaultValue: toCalendarDate(defaultValue) })}\n      granularity=\"day\"\n      isDateUnavailable={(date) =>\n        isCalendarDayDisabled(toNativeDate(date), calendarProps?.disabled)\n      }\n      isDisabled={field.disabled}\n      {...(controlledInvalid === undefined\n        ? {}\n        : { isInvalid: controlledInvalid })}\n      {...(readOnly === undefined ? {} : { isReadOnly: readOnly })}\n      {...(required === undefined ? {} : { isRequired: required })}\n      {...calendarMonthBounds(calendarProps ?? {})}\n      // Typed segments start from the calendar's today, `todayDate` when set.\n      {...(calendarProps?.todayDate === undefined\n        ? {}\n        : { placeholderValue: fromNativeDate(calendarProps.todayDate) })}\n      onChange={segmented.onOwnerChange}\n      shouldForceLeadingZeros\n      validate={segmented.validate}\n    >\n      {({ state }) => (\n        <DateFieldContent\n          calendarProps={calendarProps}\n          className={className}\n          controlled={controlled}\n          controlledInvalid={controlledInvalid}\n          dateFieldState={state as DateFieldState}\n          defaultValue={defaultValue}\n          field={field}\n          form={form}\n          iconMode={iconMode}\n          insetStart={insetStart}\n          openPickerLabel={openPickerLabel}\n          profile={profile}\n          readOnly={readOnly}\n          required={required}\n          resolvedSize={resolvedSize}\n          segmented={segmented}\n          textSize={textSize}\n          variant={variant}\n        />\n      )}\n    </DateFieldPrimitive>\n  );\n}\n\nfunction DateFieldContent({\n  calendarProps,\n  className,\n  controlled,\n  controlledInvalid,\n  dateFieldState,\n  defaultValue,\n  field,\n  form,\n  iconMode,\n  insetStart,\n  openPickerLabel,\n  profile,\n  readOnly,\n  required,\n  resolvedSize,\n  segmented,\n  textSize,\n  variant,\n}: {\n  calendarProps: CalendarOptions | undefined;\n  className: string | undefined;\n  controlled: boolean;\n  controlledInvalid: boolean | undefined;\n  dateFieldState: DateFieldState;\n  defaultValue: Date | undefined;\n  field: FieldControlBridge;\n  form: string | undefined;\n  iconMode: \"action-end\" | \"decorative-start\" | \"none\";\n  insetStart: boolean;\n  openPickerLabel: string;\n  profile: \"default\" | \"input-group\";\n  readOnly: boolean | undefined;\n  required: boolean | undefined;\n  resolvedSize: SiteplaneControlSize;\n  segmented: SegmentedValue<CalendarDate>;\n  textSize: FormControlTextSize | undefined;\n  variant: FormControlVariant;\n}): React.ReactElement {\n  const [open, setOpen] = React.useState(false);\n  const segmentsRef = React.useRef<HTMLDivElement>(null);\n  const hiddenInputRef = useFormReset<HTMLInputElement>(form, () => {\n    if (!controlled) dateFieldState.setValue(toCalendarDate(defaultValue));\n  });\n  // A cleared segment leaves the field without a value, although React Aria\n  // still holds the last complete date.\n  const cleared = hasClearedSegment(dateFieldState);\n  const ownerValue = dateFieldState.value as CalendarDate | null;\n  const fieldValue = cleared ? null : ownerValue;\n  const serializedValue = formatDateFieldValue(fieldValue);\n  React.useLayoutEffect(() => {\n    segmented.sync({ cleared, effective: fieldValue, value: ownerValue });\n  });\n  const invalid =\n    controlledInvalid ??\n    (dateFieldState.displayValidation.isInvalid || field.valid === false);\n  const proxyProps = useFieldControlProxy({\n    controlled: controlledInvalid !== undefined,\n    disabled: field.disabled,\n    displayValidation: dateFieldState.displayValidation,\n    focusTarget: () => getFirstSegment(segmentsRef.current),\n    form,\n    name: field.name,\n    onRevalidate: dateFieldState.commitValidation,\n    realtimeValidation: dateFieldState.realtimeValidation,\n    required,\n    value: serializedValue,\n  });\n  useFieldErrorSegments(segmentsRef, {\n    controlled: controlledInvalid !== undefined,\n    ownerInvalid: dateFieldState.isInvalid,\n  });\n  // The segments read in the locale's direction, which React Aria's arrow\n  // keys follow, whatever the page's: an en-US date stays 10/14/2026 in a\n  // right-to-left page. The dir attribute also isolates the row from the\n  // text around it.\n  const { direction } = useLocale();\n  const input = (\n    <DateInputPrimitive\n      className=\"sp-date-field-input\"\n      data-inset-start={insetStart || undefined}\n      data-slot=\"date-field-input\"\n      dir={direction}\n      ref={segmentsRef}\n    >\n      {(segment) => (\n        <DateSegmentPrimitive\n          className=\"sp-date-field-segment sp-date-segment\"\n          data-literal-kind={\n            segment.type === \"literal\" && /[./-]/.test(segment.text)\n              ? \"separator\"\n              : undefined\n          }\n          data-slot=\"date-field-segment\"\n          segment={segment}\n        >\n          {/* React Aria builds the segments with formatToParts, whose spaces\n              differ between the server's ICU and the browser's (Node writes a\n              narrow no-break space before the \"г.\" of a bg-BG date, Chrome a\n              plain one); the segment shows the text both agree on, or the\n              prerendered field fails hydration (React error #418). */}\n          {({ text }) => normalizeFormattedText(text)}\n        </DateSegmentPrimitive>\n      )}\n    </DateInputPrimitive>\n  );\n  const control = (\n    <InputGroup\n      aria-label={undefined}\n      // As wide as the segments by default, like the Date Time Picker; a width\n      // utility in className, such as w-full, replaces it. The default width\n      // never shrinks below the segments in a flex row (shrink-0 while w-max).\n      className={cn(\n        \"sp-date-field-control w-max [&.w-max]:shrink-0\",\n        className,\n      )}\n      data-icon-mode={iconMode}\n      data-invalid={invalid || undefined}\n      data-readonly={(readOnly && !field.disabled) || undefined}\n      data-size={resolvedSize}\n      data-slot=\"date-field-control\"\n      // The segments take the field text scale, as Input does: 14 px, or\n      // 16 px at xl; textSize sets one size for every control size.\n      textSize={textSize ?? (resolvedSize === \"xl\" ? \"base\" : undefined)}\n      variant={variant}\n    >\n      {iconMode === \"decorative-start\" ? (\n        <InputGroupAddon\n          align=\"inline-start\"\n          aria-hidden=\"true\"\n          className=\"sp-date-field-icon-addon pointer-events-none\"\n        >\n          <CalendarDaysIcon\n            className=\"sp-date-field-decorative-icon\"\n            data-slot=\"date-field-decorative-icon\"\n          />\n        </InputGroupAddon>\n      ) : null}\n      {input}\n      {iconMode === \"action-end\" ? (\n        <InputGroupAddon align=\"inline-end\">\n          <PopoverTrigger\n            data-slot=\"date-field-trigger\"\n            render={\n              <Button\n                aria-label={openPickerLabel}\n                disabled={field.disabled || readOnly}\n                profile=\"field-action\"\n                size={dateFieldActionSizes[resolvedSize]}\n              />\n            }\n          >\n            <CalendarDaysIcon aria-hidden=\"true\" />\n          </PopoverTrigger>\n        </InputGroupAddon>\n      ) : null}\n    </InputGroup>\n  );\n\n  return (\n    <>\n      {profile === \"input-group\" ? (\n        input\n      ) : iconMode === \"action-end\" ? (\n        <Popover onOpenChange={setOpen} open={open}>\n          {control}\n          <PopoverPopup\n            align=\"end\"\n            className=\"sp-date-field-popover\"\n            data-slot=\"date-field-popover\"\n            sideOffset={6}\n            viewportPadding=\"compact\"\n          >\n            <Calendar\n              {...calendarProps}\n              aria-label=\"Choose date\"\n              data-slot=\"date-field-calendar\"\n              onValueChange={(nextValue) => {\n                dateFieldState.setValue(fromNativeDate(nextValue));\n                setOpen(false);\n              }}\n              value={toOptionalNativeDate(dateFieldState.value) ?? null}\n            />\n          </PopoverPopup>\n        </Popover>\n      ) : (\n        control\n      )}\n      <Input\n        disabled={field.disabled}\n        form={form}\n        name={field.name}\n        readOnly\n        ref={hiddenInputRef}\n        required={required}\n        type=\"hidden\"\n        value={serializedValue}\n      />\n      {proxyProps ? (\n        <ExternalInputOwner>\n          <Input nativeInput unstyled {...proxyProps} />\n        </ExternalInputOwner>\n      ) : null}\n    </>\n  );\n}\n\nfunction getFirstSegment(container: HTMLElement | null): HTMLElement | null {\n  return (\n    container?.querySelector<HTMLElement>(\n      '[role=\"spinbutton\"], [role=\"textbox\"]',\n    ) ?? null\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/date-field/date-field.tsx"},{"path":"packages/ui/src/components/date-field/date-field.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* The heights are the Input scale (28, 32, 36 and 48 px), so a date field is as\n   * tall as the Input, Select and Button of the same size. The width is the\n   * segments' own, like the Date Time Picker and the Number Field; a width utility\n   * in className (w-full in a form column) replaces it. */\n  .sp-date-field-control {\n    --date-field-control-height: var(--sp-rw-control-md);\n    --date-field-segment-start: calc(\n      var(--sp-rw-space-3) - var(--input-segment-padding-inline)\n    );\n    max-width: 100%;\n    height: var(--date-field-control-height);\n  }\n\n  .sp-date-field-control[data-icon-mode=\"action-end\"] {\n    padding-inline-end: var(--date-field-control-height);\n  }\n\n  .sp-date-field-control[data-size=\"sm\"] {\n    --date-field-control-height: var(--sp-rw-control-sm);\n    --date-field-segment-start: calc(\n      var(--sp-rw-space-2-5) - var(--input-segment-padding-inline)\n    );\n  }\n\n  .sp-date-field-control[data-size=\"lg\"] {\n    --date-field-control-height: var(--sp-rw-control-lg);\n  }\n\n  .sp-date-field-control[data-size=\"xl\"] {\n    --date-field-control-height: var(--sp-rw-control-xl);\n    --date-field-segment-start: calc(\n      var(--sp-rw-space-4) - var(--input-segment-padding-inline)\n    );\n  }\n\n  .sp-date-field-input {\n    display: inline-flex;\n    align-items: center;\n    height: 100%;\n    outline: none;\n  }\n\n  /* The insets of the segments belong to the field, which takes the page's\n   * direction, while the segment row takes the locale's (its dir): the field\n   * pads its start, and its end where no picker button ends it. Without the\n   * button the segments keep the inset of the start at the end too, so a\n   * field as wide as its segments is balanced. */\n  .sp-date-field-control {\n    padding-inline-start: var(--date-field-segment-start);\n  }\n\n  .sp-date-field-control:not([data-icon-mode=\"action-end\"]) {\n    padding-inline-end: var(--date-field-segment-start);\n  }\n\n  /* The leading icon is a regular InputGroup addon at the field's start; the\n   * first segment's text starts where an InputGroup input's text starts after\n   * the same icon. */\n  .sp-date-field-control:has(> .sp-date-field-icon-addon) {\n    padding-inline-start: 0;\n  }\n\n  .sp-date-field-control > .sp-date-field-icon-addon {\n    margin-inline-end: calc(\n      var(--sp-composite-control-padding-inline-start) -\n        var(--input-segment-padding-inline)\n    );\n  }\n\n  .sp-date-segment {\n    outline: none;\n  }\n\n  .sp-date-segment:not([data-type=\"literal\"]) {\n    display: inline-flex;\n    flex: none;\n    align-items: center;\n    justify-content: center;\n    border-radius: calc(var(--radius-md) + var(--input-segment-padding-block));\n    background-clip: content-box;\n    caret-color: var(--color-primary);\n    cursor: text;\n    font-variant-numeric: tabular-nums;\n    padding-block: var(--input-segment-padding-block);\n    padding-inline: var(--input-segment-padding-inline);\n    text-align: center;\n  }\n\n  /* Minimum widths keep digits steady; wider placeholders such as \"mm\" or\n   * larger text grow the segment instead of running into its neighbours. */\n  .sp-date-segment:is([data-type=\"day\"], [data-type=\"month\"]) {\n    min-width: calc(var(--spacing) * 7);\n  }\n\n  .sp-date-segment[data-type=\"year\"] {\n    min-width: calc(var(--spacing) * 11);\n  }\n\n  .sp-date-segment[data-literal-kind=\"separator\"] {\n    margin-inline: calc(var(--spacing) * -1);\n  }\n\n  .sp-date-segment[data-focused]:not([data-type=\"literal\"]) {\n    background-color: var(--input-segment-background);\n  }\n\n  .sp-date-segment[data-placeholder]:not([data-type=\"literal\"]) {\n    color: var(--color-muted-foreground);\n  }\n\n  .sp-date-segment::selection {\n    background-color: transparent;\n  }\n\n  .sp-base-date-field[data-profile=\"input-group\"][data-inset-start=\"true\"]\n    .sp-date-segment[data-type=\"day\"] {\n    margin-inline-start: calc(var(--spacing) * 2);\n  }\n\n  .sp-date-field-control > .sp-input-group-addon[data-align=\"inline-end\"] {\n    position: absolute;\n    inset-block: 0;\n    inset-inline-end: 0;\n    width: var(--date-field-control-height);\n    padding-inline: 0 !important;\n  }\n\n  .sp-date-field-popover {\n    --popup-width: auto;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/date-field/date-field.css"},{"path":"packages/ui/src/components/date-field/index.ts","content":"export { DateField } from \"./date-field\";\nexport type { DateFieldProps } from \"./date-field\";\n","type":"registry:ui","target":"~/src/siteplane/components/date-field/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/date-field.md","categories":["Date & color"],"meta":{"avoidWhen":["Avoid when a compact button-triggered date choice is sufficient; use DatePicker instead."],"description":"Segmented, locale-aware date entry with optional Calendar action and native form serialization.","knownLimitations":["Values use native Date calendar fields in the local time zone; the locale changes formatting, not the zone."],"providerNotes":["Use the locale prop for this field, or SiteplaneLocaleProvider for a shared locale. SiteplaneUIProvider configures visual defaults, not locale."],"releaseStatus":"stable","searchTerms":["date","field","calendar","form"],"useWhen":["Use when users should type a date by segment and may also choose it from a calendar."]}}