Commit f0de692ed8b for woocommerce

commit f0de692ed8b34168308becb8f64eef4bce9f66ca
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Mon Sep 7 20:19:05 2026 +0200

    Add date field type to additional checkout fields (#68048)

    * Add date field type to additional checkout fields

    * Add changelog entry for date checkout field type

    * Add calendar icon and fix Firefox styling for date checkout fields

    Adds a custom calendar icon overlay for date-type additional checkout
    fields and fixes native date input masking on Firefox, which paints
    its calendar icon with currentColor unlike WebKit.

    * Add changelog entry for date field calendar icon

    * delete extra changelog

    * fix linting

    * Update AdditionalFields test for new date field type

    * Handle unparseable date field values in additional checkout fields

    Browsers report a date input the calendar can't parse (e.g. 2026-02-31)
    as an empty `value`, which made the field look empty and skipped its
    error. Ask `validity.badInput` alongside `value` so the label stays
    floated and the shopper gets the same immediate error as any other
    invalid entry.

    On the server, only reformat a stored date when it round-trips through
    `Y-m-d`, so PHP doesn't silently roll 2026-02-31 forward to March 3, and
    constrain the Store API schema to a real calendar date via `pattern`.

    Also give the Firefox resting border an explicit color, since
    $universal-border-strong derives from currentColor which the calendar
    icon workaround makes transparent.

    * Fix date field validation and preserve validation hooks

diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
index 9111fa6e1f7..e014c20a459 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
@@ -192,6 +192,7 @@ The following field types are supported:
 - `select`
 - `text`
 - `checkbox`
+- `date`

 There are plans to expand this list, but for now these are the types available.

@@ -219,7 +220,7 @@ These options apply to all field types (except in a few circumstances which are
 | `label` | The label shown on your field. This will be the placeholder too. | Yes | `How did you hear about us?` | No default - this must be provided. |
 | `optionalLabel` | The label shown on your field if it is optional. This will be the placeholder too. | No | `How did you hear about us? (Optional)` | The default value will be the value of `label` with `(optional)` appended. |
 | `location` | The location to render your field. | Yes | `contact`, `address`, or `order` | No default - this must be provided. |
-| `type` | The type of field you're rendering. It defaults to `text` and must match one of the supported field types. | No | `text`, `select`, or `checkbox` | `text` |
+| `type` | The type of field you're rendering. It defaults to `text` and must match one of the supported field types. | No | `text`, `select`, `checkbox`, or `date` | `text` |
 | `attributes` | An array of additional attributes to render on the field's input element. This is _not_ supported for `select` fields. | No | `[	'data-custom-data' => 'my-custom-data' ]` | `[]` |
 | `required` | Can be a boolean or a JSON Schema array. If boolean and `true`, the shopper _must_ provide a value for this field during the checkout process. For checkbox fields, the shopper must check the box to place the order. If a JSON Schema array, the field will be required based on the schema conditions. See [Conditional visibility and validation via JSON Schema](#conditional-visibility-and-validation-via-json-schema). | No | `true` or `["type" => "object", "properties" => [...]]` | `false` |
 | `hidden` | Can be a boolean or a JSON Schema array. Must be `false` when used as a boolean. If a JSON Schema array, the field will be hidden based on the schema conditions. See [Conditional visibility and validation via JSON Schema](#conditional-visibility-and-validation-via-json-schema). | No | `false` or `["type" => "object", "properties" => [...]]` | `false` |
@@ -249,6 +250,12 @@ These options apply to all field types (except in a few circumstances which are

 Text fields don't have any additional options beyond the general options listed above.

+#### Options for `date` fields
+
+Date fields don't have any additional options beyond the general options listed above.
+
+The input value will follow the browser's locale settings, the DB value will be in YYYY-MM-DD, and the final rendered value (in pages and emails) will follow the site's date format, set in **Settings -> General**.
+
 #### Options for `select` fields

 As well as the options above, select fields must also be registered with an  `options` option. This is used to specify what options the shopper can select.
@@ -292,7 +299,7 @@ As well as the options above, checkbox field support showing an error message if

 ### Attributes

-Adding additional attributes to checkbox and text fields is supported. Adding them to select fields is **not possible for now**.
+Adding additional attributes to checkbox, text, and date fields is supported. Adding them to select fields is **not possible for now**.

 These attributes have a 1:1 mapping to the HTML attributes on `input` elements (except `pattern` on checkbox).

diff --git a/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md b/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
index 222edf83e0b..8d0a5d6365a 100644
--- a/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
+++ b/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
@@ -28,7 +28,7 @@ add_action( 'woocommerce_init', function() {
             'id'       => 'your-namespace/field-name',
             'label'    => __( 'Your Field Label', 'your-text-domain'),
             'location' => 'contact', // or 'address' or 'order'
-            'type'     => 'text',    // or 'select' or 'checkbox'
+            'type'     => 'text',    // or 'select', 'checkbox' or 'date'
             'required' => false,
         )
     );
@@ -100,7 +100,7 @@ woocommerce_register_additional_checkout_field(

 ## Supported Field Types

-The API supports three field types:
+The API supports four field types:

 ### Text Fields

@@ -164,6 +164,24 @@ woocommerce_register_additional_checkout_field(
 );
 ```

+### Date Fields
+
+Ideal for non-time-zone-sensitive dates like delivery dates, birthdays, and specific dates:
+
+```php
+woocommerce_register_additional_checkout_field(
+    array(
+        'id'       => 'my-plugin/delivery-date',
+        'label'    => __('Preferred delivery date', 'your-text-domain'),
+        'location' => 'order',
+        'type'     => 'date',
+        'required' => true,
+    )
+);
+```
+
+The field input value will follows the shopper's browser and OS locale. The value is always stored as `YYYY-MM-DD`, and it is displayed using the site's date format (**Settings → General**) in emails, order screens, and other places the value is rendered.
+
 ## Adding Field Attributes

 You can enhance your fields with HTML attributes for better user experience:
diff --git a/plugins/woocommerce/changelog/wooplug-6456-add-date-field-type b/plugins/woocommerce/changelog/wooplug-6456-add-date-field-type
new file mode 100644
index 00000000000..d71c1277085
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6456-add-date-field-type
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add support for the `date` field type in the additional checkout fields API.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
index ec6f2f14028..ea81f6a549d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
@@ -28,6 +28,7 @@ import { useInstanceId } from '@wordpress/compose';
 import { dispatch, select } from '@wordpress/data';
 import { useEffect, useRef } from '@wordpress/element';
 import { decodeEntities } from '@wordpress/html-entities';
+import { Icon, calendar } from '@wordpress/icons';
 import isShallowEqual from '@wordpress/is-shallow-equal';
 import clsx from 'clsx';
 import fastDeepEqual from 'fast-deep-equal/es6';
@@ -421,6 +422,16 @@ const Form = <
 						}
 						{ ...fieldProps }
 						type={ field.type }
+						icon={
+							field.type === 'date' ? (
+								<span
+									className="wc-block-components-text-input__date-icon"
+									aria-hidden="true"
+								>
+									<Icon icon={ calendar } size={ 24 } />
+								</span>
+							) : null
+						}
 						ariaDescribedBy={ ariaDescribedBy }
 						value={
 							decodeEntities(
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss
index 70689cab235..49bad1ba6fa 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss
@@ -40,7 +40,8 @@
 	input[type="text"],
 	input[type="number"],
 	input[type="password"],
-	input[type="email"] {
+	input[type="email"],
+	input[type="date"] {
 		@include reset-typography();
 		@include font-for-inputs-locked();
 		padding: $gap $gap-small;
@@ -75,6 +76,115 @@
 		}
 	}

+	&:not(.is-active) input[type="date"] {
+		// Hide browser default mask (dd/mm/YYYY) when a date field is not active (no value, not focused).
+		// Use text-indent here because using transparent would conflict with our calendar icon-hiding hack.
+		text-indent: -9999px;
+
+		&::-webkit-datetime-edit {
+			// Focusing the input would cause browsers to highlight the dd before it's done animating, so we hide it as well.
+			opacity: 0;
+			transition: none;
+		}
+	}
+
+	// We delay the reveal so the mask doesn't sit behind the label while it floats up.
+	input[type="date"]::-webkit-datetime-edit {
+		transition: opacity 150ms ease 150ms;
+	}
+
+	@media screen and (prefers-reduced-motion: reduce) {
+		input[type="date"]::-webkit-datetime-edit {
+			transition: none;
+		}
+	}
+
+	// Clean out chrome default padding on segments that makes the field visually off compared to other fields.
+	input[type="date"] {
+		&::-webkit-datetime-edit,
+		&::-webkit-datetime-edit-fields-wrapper {
+			padding: 0;
+		}
+
+		&::-webkit-datetime-edit-day-field,
+		&::-webkit-datetime-edit-month-field,
+		&::-webkit-datetime-edit-year-field,
+		&::-webkit-datetime-edit-text {
+			padding-inline: 0;
+			// Overrides the tabular figures back to normal.
+			font-variant-numeric: normal;
+		}
+	}
+
+	// The native webkit indicator is hidden but kept in place as the click target that opens the picker.
+	// The visible icon is our own so that we can control its position freely.
+	input[type="date"]::-webkit-calendar-picker-indicator {
+		position: absolute;
+		top: 25px;
+		inset-inline-end: $gap-small;
+		transform: translateY(-50%);
+		width: 24px;
+		height: 24px;
+		margin: 0;
+		padding: 0;
+		opacity: 0;
+		cursor: pointer;
+	}
+
+	// Firefox paints its native calendar icon with currentColor and exposes no pseudo-element to hide it,
+	// so we make the color transparent (hiding the icon) and repaint the text via -webkit-text-fill-color.
+	// Both borders derive from currentColor too, so they get explicit colors instead.
+	@supports (-moz-appearance: none) {
+		input[type="date"] {
+			// $universal-border-strong is derived from currentColor, so the resting border needs its own
+			// explicit color. The dark variant already has one.
+			border-color: color-mix(in srgb, $input-text-light 80%, transparent);
+
+			&,
+			&:focus {
+				color: transparent;
+				-webkit-text-fill-color: $input-text-light;
+			}
+
+			&:focus {
+				border-color: $input-text-light;
+			}
+
+			.has-dark-controls & {
+				&,
+				&:focus {
+					color: transparent;
+					-webkit-text-fill-color: $input-text-dark;
+				}
+
+				&:focus {
+					border-color: $input-text-dark;
+				}
+			}
+		}
+	}
+
+	.wc-block-components-text-input__date-icon {
+		position: absolute;
+		top: 25px;
+		inset-inline-end: $gap-small;
+		transform: translateY(-50%);
+		width: 24px;
+		height: 24px;
+		// The icon is decorative only, and clicks should pass through to the native hidden calendar button.
+		pointer-events: none;
+		color: $input-text-light;
+
+		svg {
+			display: block;
+			fill: currentColor;
+		}
+
+		.has-dark-controls & {
+			color: $input-text-dark;
+		}
+	}
+
 	input[type="number"] {
 		appearance: textfield;
 		-moz-appearance: textfield;
@@ -92,7 +202,8 @@
 	&.is-active input[type="text"],
 	&.is-active input[type="number"],
 	&.is-active input[type="password"],
-	&.is-active input[type="email"] {
+	&.is-active input[type="email"],
+	&.is-active input[type="date"] {
 		padding: $gap-large $gap-smaller + 1px $gap-smaller;

 		&:focus {
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx
index a18553719cd..5e20fa14b1d 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx
@@ -2,7 +2,12 @@
  * External dependencies
  */
 import clsx from 'clsx';
-import { forwardRef, isValidElement, useState } from '@wordpress/element';
+import {
+	forwardRef,
+	isValidElement,
+	useMemo,
+	useState,
+} from '@wordpress/element';
 import { decodeEntities } from '@wordpress/html-entities';
 import type { InputHTMLAttributes, ReactNode } from 'react';

@@ -58,6 +63,15 @@ const TextInput = forwardRef< HTMLInputElement, TextInputProps >(
 	) => {
 		const [ isActive, setIsActive ] = useState( false );

+		// Date-like inputs report a value the browser can't parse (e.g. the 31st of a 30-day month) as an
+		// empty `value`, so the input is asked directly. Focus and blur both re-render, which is when this
+		// can have changed while the field is not active.
+		const isFieldActive = useMemo( () => {
+			const input = typeof ref === 'object' ? ref?.current : null;
+
+			return isActive || !! value || !! input?.validity?.badInput;
+		}, [ isActive, value, ref ] );
+
 		const inputWithLabel = (
 			<>
 				<input
@@ -100,7 +114,7 @@ const TextInput = forwardRef< HTMLInputElement, TextInputProps >(
 		return (
 			<div
 				className={ clsx( 'wc-block-components-text-input', className, {
-					'is-active': isActive || value,
+					'is-active': isFieldActive,
 				} ) }
 			>
 				{ isValidElement( icon ) ? (
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/validated-text-input.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/validated-text-input.tsx
index 424666274c8..780f5d63a92 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/validated-text-input.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/validated-text-input.tsx
@@ -25,6 +25,18 @@ import { ValidationInputError } from '../validation-input-error';
 import { getValidityMessageForInput } from '../../blocks-checkout/utils';
 import { ValidatedTextInputProps } from './types';

+/**
+ * Input types whose value the browser parses rather than storing verbatim. Assigning to `value` on these
+ * clears anything the shopper has partially entered, so their value is left untouched before validating.
+ */
+const PARSED_INPUT_TYPES = [
+	'date',
+	'datetime-local',
+	'month',
+	'time',
+	'week',
+];
+
 export type ValidatedTextInputHandle = {
 	focus?: () => void;
 	revalidate: () => void;
@@ -118,7 +130,9 @@ const ValidatedTextInput = forwardRef<
 				}

 				// Trim white space before validation.
-				inputObject.value = inputObject.value.trim();
+				if ( ! PARSED_INPUT_TYPES.includes( inputObject.type ) ) {
+					inputObject.value = inputObject.value.trim();
+				}
 				inputObject.setCustomValidity( '' );

 				if (
@@ -301,7 +315,11 @@ const ValidatedTextInput = forwardRef<
 					}
 				} }
 				onBlur={ () => {
-					const isEmpty = ! inputRef.current?.value.trim();
+					// A value the browser can't parse reads back as an empty `value`, but the shopper did
+					// enter something, so it gets the same immediate error as any other invalid entry.
+					const isEmpty =
+						! inputRef.current?.value.trim() &&
+						! inputRef.current?.validity?.badInput;

 					if ( isEmpty ) {
 						// If the error was already shown (e.g. after form
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index 0ae6b47fc8e..aa93f77127b 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Blocks\Domain\Services;

 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
+use Automattic\WooCommerce\Utilities\TimeUtil;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\{
 	DocumentObject, Validation
 };
@@ -37,7 +38,7 @@ class CheckoutFields {
 	 *
 	 * @var array
 	 */
-	private $supported_field_types = [ 'text', 'select', 'checkbox' ];
+	private $supported_field_types = [ 'text', 'select', 'checkbox', 'date' ];

 	/**
 	 * Groups of fields to be saved.
@@ -157,7 +158,11 @@ class CheckoutFields {
 	 * @param array $field Field data.
 	 * @return mixed
 	 */
-	public function default_sanitize_callback( $value, $field ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+	public function default_sanitize_callback( $value, $field ) {
+		if ( 'date' === ( $field['type'] ?? '' ) && is_string( $value ) ) {
+			return trim( $value );
+		}
+
 		return $value;
 	}

@@ -885,6 +890,36 @@ class CheckoutFields {
 		return $field_value;
 	}

+	/**
+	 * Validates a value against the constraints of its field type.
+	 *
+	 * This runs for every field regardless of the validate_callback it was registered with, so type level
+	 * constraints cannot be bypassed by supplying a custom callback.
+	 *
+	 * @param array $field       The field.
+	 * @param mixed $field_value The value of the field.
+	 * @return WP_Error|null Error if the value is not valid for the field type, null otherwise.
+	 */
+	private function validate_field_type( $field, $field_value ) {
+		// An empty value is not a type error. Required fields are handled by the field's validation callback.
+		if ( 'date' !== ( $field['type'] ?? '' ) || null === $field_value || '' === $field_value ) {
+			return null;
+		}
+
+		if ( ! is_string( $field_value ) || ! TimeUtil::is_valid_date( $field_value, 'Y-m-d' ) ) {
+			return new WP_Error(
+				'woocommerce_invalid_checkout_field',
+				sprintf(
+					/* translators: %s: is the field label */
+					__( 'Please provide a valid %s in YYYY-MM-DD format.', 'woocommerce' ),
+					$field['label']
+				)
+			);
+		}
+
+		return null;
+	}
+
 	/**
 	 * Validate an additional field.
 	 *
@@ -903,6 +938,12 @@ class CheckoutFields {
 				return $errors;
 			}

+			$type_error = $this->validate_field_type( $field, $field_value );
+
+			if ( is_wp_error( $type_error ) ) {
+				$errors->merge_from( $type_error );
+			}
+
 			if ( ! empty( $field['validate_callback'] ) && is_callable( $field['validate_callback'] ) ) {
 				$validate_callback_result = call_user_func( $field['validate_callback'], $field_value, $field );

@@ -1477,6 +1518,15 @@ class CheckoutFields {
 			$value   = isset( $options[ $value ] ) ? $options[ $value ] : $value;
 		}

+		if ( 'date' === $field['type'] && is_string( $value ) && TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
+			// Parsed in the site timezone so the stored calendar date cannot shift a day when it is formatted.
+			$date = \DateTime::createFromFormat( '!Y-m-d', $value, wp_timezone() );
+
+			if ( $date ) {
+				$value = wp_date( wc_date_format(), $date->getTimestamp() );
+			}
+		}
+
 		return $value;
 	}

diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index 16ae7ce1d58..fcb769fb518 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
 use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
 use Automattic\WooCommerce\StoreApi\SchemaController;
 use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Utilities\TimeUtil;

 /**
  * AddressSchema class.
@@ -244,8 +245,10 @@ abstract class AbstractAddressSchema extends AbstractSchema {
 			}
 		}

-		// Get additional field keys here as we need to know if they are present in the address for validation.
-		$additional_keys = array_keys( $this->get_additional_address_fields_schema() );
+		$additional_fields = array_intersect_key(
+			$this->additional_fields_controller->get_additional_fields(),
+			$this->get_additional_address_fields_schema()
+		);

 		foreach ( array_keys( $address ) as $key ) {
 			// Skip email here it will be validated in BillingAddressSchema.
@@ -253,6 +256,20 @@ abstract class AbstractAddressSchema extends AbstractSchema {
 				continue;
 			}

+			$field_value = $address[ $key ];
+			if ( 'date' === ( $additional_fields[ $key ]['type'] ?? '' ) && '' !== $field_value ) {
+				if ( ! is_string( $field_value ) || ! TimeUtil::is_valid_date( $field_value, 'Y-m-d' ) ) {
+					$errors->add(
+						'invalid_' . $key,
+						sprintf(
+							/* translators: %s: is the field label */
+							__( 'Please provide a valid %s in YYYY-MM-DD format.', 'woocommerce' ),
+							$additional_fields[ $key ]['label']
+						)
+					);
+				}
+			}
+
 			// Only run specific validation on properties that are defined in the schema and present in the address.
 			// This is for partial address pushes when only part of a customer address is sent.
 			// Full schema address validation still happens later, so empty, required values are disallowed.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
index 678ec6ec342..e04e80cdc94 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
@@ -91,6 +91,12 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
 				'location' => 'order',
 				'type'     => 'checkbox',
 			),
+			array(
+				'id'       => 'plugin-namespace/delivery-date',
+				'label'    => 'Preferred delivery date',
+				'location' => 'order',
+				'type'     => 'date',
+			),
 			array(
 				'id'         => 'namespace/vat-number',
 				'label'      => 'VAT Number',
@@ -189,6 +195,124 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
 		$this->assertArrayNotHasKey( 'namespace/vat-number', $fields );
 	}

+	/**
+	 * @testdox Date fields can be registered.
+	 */
+	public function test_date_fields_can_be_registered() {
+		$fields = $this->controller->get_additional_fields();
+
+		$this->assertArrayHasKey( 'plugin-namespace/delivery-date', $fields, 'Date fields should be a supported field type.' );
+		$this->assertSame( 'date', $fields['plugin-namespace/delivery-date']['type'] );
+	}
+
+	/**
+	 * @testdox Date fields only accept a real calendar date in Y-m-d format.
+	 *
+	 * @testWith ["2026-08-26", false]
+	 *           ["2024-02-29", false]
+	 *           ["", false]
+	 *           ["2026-02-31", true]
+	 *           ["2026-02-29", true]
+	 *           ["2026-8-6", true]
+	 *           ["26-08-2026", true]
+	 *           ["not-a-date", true]
+	 *
+	 * @param string $value       The submitted value.
+	 * @param bool   $has_errors  Whether the value should be rejected.
+	 */
+	public function test_date_field_validation( string $value, bool $has_errors ) {
+		$fields = $this->controller->get_additional_fields();
+		$errors = $this->controller->validate_field( $fields['plugin-namespace/delivery-date'], $value );
+
+		$this->assertSame( $has_errors, $errors->has_errors(), sprintf( 'Unexpected validation result for "%s".', $value ) );
+	}
+
+	/**
+	 * @testdox Invalid dates still reach custom validation and both validation hooks.
+	 */
+	public function test_invalid_date_runs_custom_validation_and_hooks(): void {
+		$calls = array();
+		$field = $this->controller->get_additional_fields()['plugin-namespace/delivery-date'];
+
+		$field['validate_callback'] = static function ( $value ) use ( &$calls ) {
+			$calls['callback'] = $value;
+			return new \WP_Error( 'custom_date_error', 'Custom validation error.' );
+		};
+
+		$hooks = array(
+			'__experimental_woocommerce_blocks_validate_additional_field',
+			'woocommerce_validate_additional_field',
+		);
+		$this->setExpectedDeprecated( $hooks[0] );
+
+		foreach ( $hooks as $hook ) {
+			add_action(
+				$hook,
+				static function ( $errors, $key, $value ) use ( &$calls, $hook ) {
+					$calls[ $hook ] = array( $key, $value, $errors->get_error_codes() );
+				},
+				10,
+				3
+			);
+		}
+
+		$errors = $this->controller->validate_field( $field, '2026-02-31' );
+
+		$this->assertSame( '2026-02-31', $calls['callback'] ?? null );
+		$this->assertContains( 'woocommerce_invalid_checkout_field', $errors->get_error_codes() );
+		$this->assertContains( 'custom_date_error', $errors->get_error_codes() );
+		foreach ( $hooks as $hook ) {
+			$this->assertArrayHasKey( $hook, $calls, 'Both validation hooks must run after a type error.' );
+			$this->assertSame( $field['id'], $calls[ $hook ][0] );
+			$this->assertSame( '2026-02-31', $calls[ $hook ][1] );
+			$this->assertContains( 'woocommerce_invalid_checkout_field', $calls[ $hook ][2] );
+			$this->assertContains( 'custom_date_error', $calls[ $hook ][2] );
+		}
+	}
+
+	/**
+	 * @testdox Date field values are displayed using the site date format, in the site timezone.
+	 *
+	 * @testWith ["UTC", "F j, Y", "August 26, 2026"]
+	 *           ["America/New_York", "Y-m-d", "2026-08-26"]
+	 *           ["Pacific/Auckland", "Y-m-d", "2026-08-26"]
+	 *
+	 * @param string $timezone    The site timezone.
+	 * @param string $date_format The site date format.
+	 * @param string $expected    The expected formatted value.
+	 */
+	public function test_date_field_value_formatting( string $timezone, string $date_format, string $expected ) {
+		update_option( 'timezone_string', $timezone );
+		update_option( 'date_format', $date_format );
+
+		$fields = $this->controller->get_additional_fields();
+		$value  = $this->controller->format_additional_field_value( '2026-08-26', $fields['plugin-namespace/delivery-date'] );
+
+		$this->assertSame( $expected, $value, 'The stored calendar date should never shift when it is formatted.' );
+	}
+
+	/**
+	 * @testdox Date values that are not a real calendar date are displayed as stored.
+	 *
+	 * @testWith ["2026-02-31"]
+	 *           ["2026-13-01"]
+	 *           ["not-a-date"]
+	 *           [""]
+	 *
+	 * @param string $value The stored value.
+	 */
+	public function test_invalid_date_field_value_is_not_reformatted( string $value ) {
+		update_option( 'date_format', 'F j, Y' );
+
+		$fields = $this->controller->get_additional_fields();
+
+		$this->assertSame(
+			$value,
+			$this->controller->format_additional_field_value( $value, $fields['plugin-namespace/delivery-date'] ),
+			'A value that is not a real date should be shown as stored rather than rolled forward.'
+		);
+	}
+
 	/**
 	 * Registering a field before after_setup_theme warns the developer.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
index b7440976310..47730995ec9 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
@@ -627,7 +627,7 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 	}

 	/**
-	 * Ensure an error is triggered when a field is registered with an invalid type (text, select, checkbox).
+	 * Ensure an error is triggered when a field is registered with an invalid type (text, select, checkbox, date).
 	 */
 	public function test_invalid_type_in_registration() {
 		$this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
@@ -641,7 +641,7 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 						'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.',
 						$id,
 						'invalid',
-						implode( ', ', array( 'text', 'select', 'checkbox' ) )
+						implode( ', ', array( 'text', 'select', 'checkbox', 'date' ) )
 					)
 				),
 			)
@@ -1439,6 +1439,95 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 		$this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) );
 	}

+	/**
+	 * @testdox Date fields reject invalid dates before persistence and accept clean optional values.
+	 *
+	 * @testWith ["cart/update-customer", "billing", "2026-02-31", 400]
+	 *           ["cart/update-customer", "shipping", "2026-02-29", 400]
+	 *           ["cart/update-customer", "billing", "not-a-date", 400]
+	 *           ["cart/update-customer", "billing", "0", 400]
+	 *           ["cart/update-customer", "billing", "2024-02-29", 200]
+	 *           ["cart/update-customer", "shipping", " 2026-08-26 ", 200]
+	 *           ["cart/update-customer", "billing", "", 200]
+	 *           ["cart/update-customer", "billing", null, 200]
+	 *           ["checkout", "billing", "2026-02-31", 400]
+	 *           ["checkout", "shipping", "2026-02-29", 400]
+	 *           ["checkout", "billing", " 2026-08-26 ", 200]
+	 *           ["checkout", "shipping", "", 200]
+	 *           ["checkout", "contact", "2026-02-31", 400]
+	 *           ["checkout", "order", "2026-02-29", 400]
+	 *           ["checkout", "contact", " 2026-08-26 ", 200]
+	 *           ["checkout", "order", " 2026-08-26 ", 200]
+	 *
+	 * @param string      $route           Store API route.
+	 * @param string      $location        Field location or address group.
+	 * @param string|null $value           Submitted date, or null to omit the field.
+	 * @param int         $expected_status Expected response status.
+	 */
+	public function test_date_field_validation_before_persistence( string $route, string $location, ?string $value, int $expected_status ): void {
+		$this->unregister_fields();
+		$id         = 'test/delivery-date';
+		$is_address = in_array( $location, array( 'billing', 'shipping' ), true );
+		$group      = $is_address ? $location : 'other';
+		$param      = $is_address ? $location . '_address' : 'additional_fields';
+
+		$callback_calls = 0;
+
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'                => $id,
+				'label'             => 'Delivery date',
+				'location'          => $is_address ? 'address' : $location,
+				'type'              => 'date',
+				'validate_callback' => static function () use ( &$callback_calls ) {
+					++$callback_calls;
+					return true;
+				},
+			)
+		);
+		$this->controller->persist_field_for_customer( $id, '2026-08-01', WC()->customer, $group );
+
+		$address = array(
+			'first_name' => 'Jane',
+			'last_name'  => 'Doe',
+			'address_1'  => '123 Main Street',
+			'city'       => 'New York',
+			'state'      => 'NY',
+			'postcode'   => '10001',
+			'country'    => 'US',
+		);
+		$params  = array(
+			'billing_address'   => array_merge( $address, array( 'email' => 'jane@example.com' ) ),
+			'shipping_address'  => $address,
+			'payment_method'    => WC_Gateway_BACS::ID,
+			'additional_fields' => array(),
+		);
+		if ( null !== $value ) {
+			$params[ $param ][ $id ] = $value;
+		}
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/' . $route );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params( $params );
+
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( $expected_status, $response->get_status(), wp_json_encode( $data ) );
+		if ( 'cart/update-customer' === $route ) {
+			$this->assertSame( 0, $callback_calls, 'Address type validation must not add calls to extension callbacks.' );
+		}
+		if ( 400 === $expected_status ) {
+			$this->assertSame( 'rest_invalid_param', $data['code'] );
+			$this->assertStringContainsString( 'Delivery date', wp_json_encode( $data['data']['details'] ) );
+			$this->assertSame( '2026-08-01', $this->controller->get_field_from_object( $id, WC()->customer, $group ) );
+			return;
+		}
+
+		$object   = 'checkout' === $route ? wc_get_order( $data['order_id'] ) : WC()->customer;
+		$expected = null === $value ? '2026-08-01' : trim( $value );
+		$this->assertSame( $expected, $this->controller->get_field_from_object( $id, $object, $group ) );
+	}
+
 	/**
 	 * Ensures that placing an order with the correct values actually work.
 	 */