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.
*/