From 119f5de10874046e21f0f3051f99f4caa48f5e8b Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 9 Oct 2026 14:43:22 +0200 Subject: [PATCH 1/4] Use native date and datetime-local inputs for `DateFormField` Closes #6561 --- .../templates/shared_dateFormField.tpl | 5 +- ts/WoltLabSuite/Core/Date/Picker.ts | 85 +++++++++++++++++- .../files/js/WoltLabSuite/Core/Date/Picker.js | 64 +++++++++++++- .../builder/field/DateFormField.class.php | 87 +++++++++++++------ wcfsetup/install/files/style/layout/form.scss | 3 + 5 files changed, 210 insertions(+), 34 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_dateFormField.tpl b/com.woltlab.wcf/templates/shared_dateFormField.tpl index 6481bdb3c8e..9ce69db0f6c 100644 --- a/com.woltlab.wcf/templates/shared_dateFormField.tpl +++ b/com.woltlab.wcf/templates/shared_dateFormField.tpl @@ -1,8 +1,9 @@ getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if}{* *}{if $field->isAutofocused()} autofocus{/if}{* *}{if $field->isRequired()} required{/if}{* diff --git a/ts/WoltLabSuite/Core/Date/Picker.ts b/ts/WoltLabSuite/Core/Date/Picker.ts index ef0647a4149..93b1d75040e 100644 --- a/ts/WoltLabSuite/Core/Date/Picker.ts +++ b/ts/WoltLabSuite/Core/Date/Picker.ts @@ -634,20 +634,51 @@ function click(event: MouseEvent): void { } /** - * Validates given element or id if it represents an active date picker. + * Validates given element or id if it represents an active date picker + * or a native date input. */ function getElement(element: InputElementOrString): HTMLInputElement { if (typeof element === "string") { element = document.getElementById(element) as HTMLInputElement; } - if (!(element instanceof HTMLInputElement) || !element.classList.contains("inputDatePicker") || !_data.has(element)) { + if (!(element instanceof HTMLInputElement)) { + throw new Error("Expected a valid date picker input element or id."); + } + + if (isNativeInput(element)) { + return element; + } + + if (!element.classList.contains("inputDatePicker") || !_data.has(element)) { throw new Error("Expected a valid date picker input element or id."); } return element; } +/** + * Returns true if the element is a native date input that is not + * enhanced by the date picker. + */ +function isNativeInput(element: HTMLInputElement): boolean { + return element.hasAttribute("data-native-date-picker"); +} + +/** + * Returns the value of the given date in the format of the native input. + */ +function formatNativeValue(element: HTMLInputElement, date: Date): string { + switch (element.type) { + case "datetime-local": + return DateUtil.format(date, "Y-m-d\\TH:i"); + case "time": + return DateUtil.format(date, "H:i"); + default: + return DateUtil.format(date, "Y-m-d"); + } +} + const DatePicker = { /** * Initializes all date and datetime input fields. @@ -661,7 +692,7 @@ const DatePicker = { _firstDayOfWeek = parseInt(getPhrase("wcf.date.firstDayOfTheWeek"), 10); wheneverSeen( - `input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)`, + `input[type="date"]:not(.inputDatePicker, [data-native-date-picker]), input[type="datetime"]:not(.inputDatePicker, [data-native-date-picker])`, (element: HTMLInputElement) => { const now = new Date(); element.classList.add("inputDatePicker"); @@ -932,6 +963,23 @@ const DatePicker = { getDate(element: InputElementOrString): Date | null { element = getElement(element); + if (isNativeInput(element)) { + if (element.value === "") { + return null; + } + + if (element.type === "time") { + const [hours, minutes] = element.value.split(":"); + const date = new Date(); + date.setHours(+hours, +minutes, 0, 0); + + return date; + } + + // Date-only strings are parsed as UTC, append the time to parse it as local time. + return new Date(element.type === "date" ? `${element.value}T00:00` : element.value); + } + const value = element.dataset.value || ""; if (value) { return new Date(+value); @@ -948,6 +996,13 @@ const DatePicker = { */ setDate(element: InputElementOrString, date: Date): void { element = getElement(element); + + if (isNativeInput(element)) { + element.value = formatNativeValue(element, date); + + return; + } + const data = _data.get(element) as DatePickerData; element.dataset.value = date.getTime().toString(); @@ -985,6 +1040,11 @@ const DatePicker = { */ getValue(element: InputElementOrString): string { element = getElement(element); + + if (isNativeInput(element)) { + return element.value; + } + const data = _data.get(element); if (data) { @@ -999,6 +1059,13 @@ const DatePicker = { */ clear(element: InputElementOrString): void { element = getElement(element); + + if (isNativeInput(element)) { + element.value = ""; + + return; + } + const data = _data.get(element) as DatePickerData; element.removeAttribute("data-value"); @@ -1017,6 +1084,11 @@ const DatePicker = { */ destroy(element: InputElementOrString): void { element = getElement(element); + + if (isNativeInput(element)) { + return; + } + const data = _data.get(element) as DatePickerData; const container = element.parentNode as HTMLElement; @@ -1041,6 +1113,13 @@ const DatePicker = { */ setCloseCallback(element: InputElementOrString, callback: Callback): void { element = getElement(element); + + if (isNativeInput(element)) { + element.addEventListener("change", () => callback()); + + return; + } + _data.get(element)!.onClose = callback; }, }; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js index 5acf3a12b0e..af8c0ac0139 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js @@ -521,17 +521,44 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", } } /** - * Validates given element or id if it represents an active date picker. + * Validates given element or id if it represents an active date picker + * or a native date input. */ function getElement(element) { if (typeof element === "string") { element = document.getElementById(element); } - if (!(element instanceof HTMLInputElement) || !element.classList.contains("inputDatePicker") || !_data.has(element)) { + if (!(element instanceof HTMLInputElement)) { + throw new Error("Expected a valid date picker input element or id."); + } + if (isNativeInput(element)) { + return element; + } + if (!element.classList.contains("inputDatePicker") || !_data.has(element)) { throw new Error("Expected a valid date picker input element or id."); } return element; } + /** + * Returns true if the element is a native date input that is not + * enhanced by the date picker. + */ + function isNativeInput(element) { + return element.hasAttribute("data-native-date-picker"); + } + /** + * Returns the value of the given date in the format of the native input. + */ + function formatNativeValue(element, date) { + switch (element.type) { + case "datetime-local": + return DateUtil.format(date, "Y-m-d\\TH:i"); + case "time": + return DateUtil.format(date, "H:i"); + default: + return DateUtil.format(date, "Y-m-d"); + } + } const DatePicker = { /** * Initializes all date and datetime input fields. @@ -542,7 +569,7 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", } _didInit = true; _firstDayOfWeek = parseInt((0, Language_1.getPhrase)("wcf.date.firstDayOfTheWeek"), 10); - (0, Selector_1.wheneverSeen)(`input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)`, (element) => { + (0, Selector_1.wheneverSeen)(`input[type="date"]:not(.inputDatePicker, [data-native-date-picker]), input[type="datetime"]:not(.inputDatePicker, [data-native-date-picker])`, (element) => { const now = new Date(); element.classList.add("inputDatePicker"); element.readOnly = true; @@ -782,6 +809,19 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", */ getDate(element) { element = getElement(element); + if (isNativeInput(element)) { + if (element.value === "") { + return null; + } + if (element.type === "time") { + const [hours, minutes] = element.value.split(":"); + const date = new Date(); + date.setHours(+hours, +minutes, 0, 0); + return date; + } + // Date-only strings are parsed as UTC, append the time to parse it as local time. + return new Date(element.type === "date" ? `${element.value}T00:00` : element.value); + } const value = element.dataset.value || ""; if (value) { return new Date(+value); @@ -796,6 +836,10 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", */ setDate(element, date) { element = getElement(element); + if (isNativeInput(element)) { + element.value = formatNativeValue(element, date); + return; + } const data = _data.get(element); element.dataset.value = date.getTime().toString(); element.dataset.empty = "false"; @@ -831,6 +875,9 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", */ getValue(element) { element = getElement(element); + if (isNativeInput(element)) { + return element.value; + } const data = _data.get(element); if (data) { return data.shadow.value; @@ -842,6 +889,10 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", */ clear(element) { element = getElement(element); + if (isNativeInput(element)) { + element.value = ""; + return; + } const data = _data.get(element); element.removeAttribute("data-value"); element.value = ""; @@ -856,6 +907,9 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", */ destroy(element) { element = getElement(element); + if (isNativeInput(element)) { + return; + } const data = _data.get(element); const container = element.parentNode; container.parentNode.insertBefore(element, container); @@ -875,6 +929,10 @@ define(["require", "exports", "tslib", "../Core", "./Util", "../Event/Handler", */ setCloseCallback(element, callback) { element = getElement(element); + if (isNativeInput(element)) { + element.addEventListener("change", () => callback()); + return; + } _data.get(element).onClose = callback; }, }; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php index 8ba7a3adf86..f296bf1060b 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php @@ -69,6 +69,12 @@ class DateFormField extends AbstractFormField implements const TIME_FORMAT = 'Y-m-d\TH:i:sP'; + /** + * format of the value of native `datetime-local` inputs + * @since 6.3 + */ + const NATIVE_TIME_FORMAT = 'Y-m-d\TH:i'; + public function __construct() { $this->addFieldClass('medium'); @@ -129,12 +135,21 @@ public function getEarliestDate() #[\Override] public function getHtmlVariables() { - // the date picker JavaScript code requires the `min` and `max` value to have a - // specific format which is easier to create in PHP than in the template itself - + // the native date input requires the `value`, `min` and `max` value to have + // a specific format without a time zone offset $format = static::DATE_FORMAT; + $timeZone = new \DateTimeZone('UTC'); if ($this->supportsTime()) { - $format = static::TIME_FORMAT; + $format = static::NATIVE_TIME_FORMAT; + $timeZone = $this->getInputTimeZone(); + } + + $formattedValue = ''; + if ($this->getValue() !== null) { + $dateTime = $this->getValueDateTimeObject(); + if ($dateTime !== null) { + $formattedValue = $dateTime->setTimezone($timeZone)->format($format); + } } $formattedEarliestDate = ''; @@ -143,7 +158,7 @@ public function getHtmlVariables() $this->getSaveValueFormat(), $this->getEarliestDate(), new \DateTimeZone('UTC') - )->format($format); + )->setTimezone($timeZone)->format($format); } $formattedLatestDate = ''; @@ -152,15 +167,34 @@ public function getHtmlVariables() $this->getSaveValueFormat(), $this->getLatestDate(), new \DateTimeZone('UTC') - )->format($format); + )->setTimezone($timeZone)->format($format); } return [ + 'dateFormFieldValue' => $formattedValue, 'dateFormFieldEarliestDate' => $formattedEarliestDate, 'dateFormFieldLatestDate' => $formattedLatestDate, ]; } + /** + * Returns the time zone in which the native date time input displays and + * submits its value. + * + * @since 6.3 + */ + protected function getInputTimeZone(): \DateTimeZone + { + if ( + $this->hasFieldAttribute('data-ignore-timezone') + && $this->getFieldAttribute('data-ignore-timezone') === 'true' + ) { + return new \DateTimeZone('UTC'); + } + + return WCF::getUser()->getTimeZone(); + } + /** * Returns the latest valid date in `DateFormField::getSaveValueFormat()` format. * @@ -289,27 +323,28 @@ public function readValue() if ($this->value === '') { $this->value = null; } else { - // Suppressing the time zone causes the generated datetime - // string to omit the time zone entirely. - // - // This is an incorrect behavior of the JS component which we - // cannot fix for compatibility reasons. + // Native `datetime-local` inputs submit the value without a + // time zone offset in the time zone of the input, optionally + // including the seconds. The legacy JavaScript component also + // omits the time zone if it has been told to ignore it. $isValidTime = false; - if ( - $this->supportsTime() - && $this->hasFieldAttribute('data-ignore-timezone') - && $this->getFieldAttribute('data-ignore-timezone') === 'true' - ) { - $dateTime = \DateTime::createFromFormat( - 'Y-m-d\TH:i:s', - $this->getValue(), - new \DateTimeZone('UTC') - ); - - if ($dateTime !== false) { - $isValidTime = true; - - $this->value = $dateTime->format(self::TIME_FORMAT); + if ($this->supportsTime()) { + foreach (['!' . static::NATIVE_TIME_FORMAT, '!Y-m-d\TH:i:s'] as $format) { + $dateTime = \DateTime::createFromFormat( + $format, + $this->getValue(), + $this->getInputTimeZone() + ); + + if ($dateTime !== false) { + $isValidTime = true; + + $this->value = $dateTime + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::TIME_FORMAT); + + break; + } } } diff --git a/wcfsetup/install/files/style/layout/form.scss b/wcfsetup/install/files/style/layout/form.scss index d55a8da2879..98349eed134 100644 --- a/wcfsetup/install/files/style/layout/form.scss +++ b/wcfsetup/install/files/style/layout/form.scss @@ -1,5 +1,6 @@ input[type="date"], input[type="datetime"], +input[type="datetime-local"], input[type="email"], input[type="number"], input[type="password"], @@ -22,6 +23,7 @@ select.disabled[tabindex="-1"] { /* set placeholder color */ input[type="date"], input[type="datetime"], +input[type="datetime-local"], input[type="email"], input[type="number"], input[type="password"], @@ -65,6 +67,7 @@ input[type="text"] { @media (pointer: coarse) { input[type="date"], input[type="datetime"], + input[type="datetime-local"], input[type="email"], input[type="number"], input[type="password"], From e3fbf7c36f40ec88f543c842e771bdc8b00988c1 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 9 Oct 2026 15:04:35 +0200 Subject: [PATCH 2/4] Fix date-only boundaries and empty timestamp values in `DateFormField` --- .../builder/field/DateFormField.class.php | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php index f296bf1060b..955c7790f2e 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php @@ -231,15 +231,17 @@ public function getSaveValueFormat() */ protected function getValueDateTimeObject() { + // The `!` prefix resets all fields not present in the format, otherwise + // a date without a time would implicitly receive the current time. if ($this->supportsTime()) { $dateTime = \DateTime::createFromFormat( - static::TIME_FORMAT, + '!' . static::TIME_FORMAT, $this->getValue(), new \DateTimeZone('UTC') ); } else { $dateTime = \DateTime::createFromFormat( - static::DATE_FORMAT, + '!' . static::DATE_FORMAT, $this->getValue(), new \DateTimeZone('UTC') ); @@ -427,11 +429,7 @@ public function validate() } if ($this->getEarliestDate() !== null) { - $earliestDateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->getEarliestDate(), - new \DateTimeZone('UTC') - ); + $earliestDateTime = $this->getBoundaryDateTimeObject($this->getEarliestDate()); if ($dateTime < $earliestDateTime) { $this->addValidationError(new FormFieldValidationError( @@ -445,11 +443,7 @@ public function validate() } if ($this->getLatestDate() !== null) { - $latestDateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->getLatestDate(), - new \DateTimeZone('UTC') - ); + $latestDateTime = $this->getBoundaryDateTimeObject($this->getLatestDate()); if ($dateTime > $latestDateTime) { $this->addValidationError(new FormFieldValidationError( @@ -467,6 +461,18 @@ public function validate() #[\Override] public function value(mixed $value) { + // Non-nullable fields store an empty value as `0`, which must be + // restored as an empty value instead of 1970-01-01. + if ( + !$this->isNullable() + && $this->getSaveValueFormat() === 'U' + && (int)$value === 0 + ) { + $this->value = null; + + return $this; + } + parent::value($value); $dateTime = \DateTime::createFromFormat( @@ -505,24 +511,49 @@ protected static function getReservedFieldAttributes(): array ); } + /** + * Returns a date time object for the given earliest or latest valid date. + * Without time support, the time is reset to the start of the day to allow + * the day of the boundary itself to be selected. + * + * @since 6.3 + */ + protected function getBoundaryDateTimeObject(string|int $date): \DateTime + { + $dateTime = \DateTime::createFromFormat( + $this->getSaveValueFormat(), + (string)$date, + new \DateTimeZone('UTC') + ); + + if (!$this->supportsTime()) { + $dateTime->setTimezone(new \DateTimeZone('UTC'))->setTime(0, 0); + } + + return $dateTime; + } + /** * Returns an instance of `\IntlDateFormatter' for formatting `\DateTime` objects. * The formatter displays the date and time (if supported) in the user's locale and timezone. + * Dates without a time are displayed in UTC, matching the stored value. * * @since 6.2 */ protected function getDateTimeFormatter(): \IntlDateFormatter { $timeFormat = \IntlDateFormatter::NONE; + $timeZone = new \DateTimeZone('UTC'); if ($this->supportsTime()) { $timeFormat = \IntlDateFormatter::SHORT; + $timeZone = $this->getInputTimeZone(); } return new \IntlDateFormatter( WCF::getLanguage()->getLocale(), \IntlDateFormatter::LONG, $timeFormat, - WCF::getUser()->getTimeZone() + $timeZone ); } } From d6c11f48d16d8fb24f8bd6e12e515cd6153eed85 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 9 Oct 2026 15:16:07 +0200 Subject: [PATCH 3/4] Accept `DateTimeInterface` boundaries in `DateFormField` and resolve them once ref https://www.woltlab.com/community/thread/301400-dateformfield-savevalueformat-savevalueformat-wann-verwenden/ --- .../builder/field/DateFormField.class.php | 210 ++++++++++-------- 1 file changed, 120 insertions(+), 90 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php index 955c7790f2e..597ad383d13 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php @@ -30,24 +30,36 @@ class DateFormField extends AbstractFormField implements use TNullableFormField; /** - * earliest valid date in `DateFormField::$saveValueFormat` format or `null` if no earliest + * earliest valid date as passed to `DateFormField::earliestDate()` or `null` if no earliest * valid date has been set - * @var null|string|int + * @var null|string|int|\DateTimeInterface */ protected $earliestDate; + /** + * resolved earliest valid date or `null` if no earliest valid date has been set + * @since 6.3 + */ + protected ?\DateTimeImmutable $earliestDateTime = null; + /** * @inheritDoc */ protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Date'; /** - * latest valid date in `DateFormField::$saveValueFormat` format or `null` if no latest valid + * latest valid date as passed to `DateFormField::latestDate()` or `null` if no latest valid * date has been set - * @var null|string|int + * @var null|string|int|\DateTimeInterface */ protected $latestDate; + /** + * resolved latest valid date or `null` if no latest valid date has been set + * @since 6.3 + */ + protected ?\DateTimeImmutable $latestDateTime = null; + /** * date time format of the save value * @var ?string @@ -81,39 +93,34 @@ public function __construct() } /** - * Sets the earliest valid date in `DateFormField::$saveValueFormat` format and returns this - * field. If `null` is given, the previously set earliest valid date is unset. + * Sets the earliest valid date and returns this field. If `null` is given, the previously + * set earliest valid date is unset. + * + * Integers are always treated as unix timestamps, strings have to be given in + * `DateFormField::getSaveValueFormat()` format. * * @return static */ - public function earliestDate(null|string|int $earliestDate = null) + public function earliestDate(null|string|int|\DateTimeInterface $earliestDate = null) { $this->earliestDate = $earliestDate; + $this->earliestDateTime = null; - if ($this->earliestDate !== null) { - $earliestDateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->earliestDate, - new \DateTimeZone('UTC') - ); - if ($earliestDateTime === false) { + if ($earliestDate !== null) { + $this->earliestDateTime = $this->resolveBoundary($earliestDate); + if ($this->earliestDateTime === null) { throw new \InvalidArgumentException( - "Earliest date '{$this->earliestDate}' does not have save value format '{$this->getSaveValueFormat()}' for field '{$this->getId()}'." + "Earliest date '{$earliestDate}' does not have save value format '{$this->getSaveValueFormat()}' for field '{$this->getId()}'." ); } - if ($this->getLatestDate() !== null) { - $latestDateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->getLatestDate(), - new \DateTimeZone('UTC') - ); + if ($this->latestDateTime !== null && $this->latestDateTime < $this->earliestDateTime) { + $earliestDateString = $this->getBoundaryString($earliestDate); + $latestDateString = $this->getBoundaryString($this->getLatestDate()); - if ($latestDateTime < $earliestDateTime) { - throw new \InvalidArgumentException( - "Earliest date '{$this->earliestDate}' cannot be later than latest date '{$this->getLatestDate()}' for field '{$this->getId()}'." - ); - } + throw new \InvalidArgumentException( + "Earliest date '{$earliestDateString}' cannot be later than latest date '{$latestDateString}' for field '{$this->getId()}'." + ); } } @@ -121,11 +128,11 @@ public function earliestDate(null|string|int $earliestDate = null) } /** - * Returns the earliest valid date in `DateFormField::getSaveValueFormat()` format. + * Returns the earliest valid date as passed to `DateFormField::earliestDate()`. * * If no earliest valid date has been set, `null` is returned. * - * @return null|string|int + * @return null|string|int|\DateTimeInterface */ public function getEarliestDate() { @@ -153,21 +160,13 @@ public function getHtmlVariables() } $formattedEarliestDate = ''; - if ($this->getEarliestDate() !== null) { - $formattedEarliestDate = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->getEarliestDate(), - new \DateTimeZone('UTC') - )->setTimezone($timeZone)->format($format); + if ($this->earliestDateTime !== null) { + $formattedEarliestDate = $this->earliestDateTime->setTimezone($timeZone)->format($format); } $formattedLatestDate = ''; - if ($this->getLatestDate() !== null) { - $formattedLatestDate = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->getLatestDate(), - new \DateTimeZone('UTC') - )->setTimezone($timeZone)->format($format); + if ($this->latestDateTime !== null) { + $formattedLatestDate = $this->latestDateTime->setTimezone($timeZone)->format($format); } return [ @@ -196,11 +195,11 @@ protected function getInputTimeZone(): \DateTimeZone } /** - * Returns the latest valid date in `DateFormField::getSaveValueFormat()` format. + * Returns the latest valid date as passed to `DateFormField::latestDate()`. * * If no latest valid date has been set, `null` is returned. * - * @return null|string|int + * @return null|string|int|\DateTimeInterface */ public function getLatestDate() { @@ -210,17 +209,13 @@ public function getLatestDate() /** * Returns the type of the returned save value. * - * If no save value format has been set, `U` (unix timestamp) will be set and returned. + * If no save value format has been set, `U` (unix timestamp) is returned. * * @return string */ public function getSaveValueFormat() { - if ($this->saveValueFormat === null) { - $this->saveValueFormat = 'U'; - } - - return $this->saveValueFormat; + return $this->saveValueFormat ?? 'U'; } /** @@ -272,40 +267,34 @@ public function getSaveValue() } /** - * Sets the latest valid date in `DateFormField::$saveValueFormat` format and returns this - * field. If `null` is given, the previously set latest valid date is unset. + * Sets the latest valid date and returns this field. If `null` is given, the previously + * set latest valid date is unset. + * + * Integers are always treated as unix timestamps, strings have to be given in + * `DateFormField::getSaveValueFormat()` format. * * @return static */ - public function latestDate(null|string|int $latestDate = null) + public function latestDate(null|string|int|\DateTimeInterface $latestDate = null) { $this->latestDate = $latestDate; + $this->latestDateTime = null; - if ($this->latestDate !== null) { - $latestDateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->latestDate, - new \DateTimeZone('UTC') - ); - - if ($latestDateTime === false) { + if ($latestDate !== null) { + $this->latestDateTime = $this->resolveBoundary($latestDate); + if ($this->latestDateTime === null) { throw new \InvalidArgumentException( - "Latest date '{$this->latestDate}' does not have save value format '{$this->getSaveValueFormat()}' for field '{$this->getId()}'." + "Latest date '{$latestDate}' does not have save value format '{$this->getSaveValueFormat()}' for field '{$this->getId()}'." ); } - if ($this->getEarliestDate() !== null) { - $earliestDateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->getEarliestDate(), - new \DateTimeZone('UTC') - ); + if ($this->earliestDateTime !== null && $this->latestDateTime < $this->earliestDateTime) { + $latestDateString = $this->getBoundaryString($latestDate); + $earliestDateString = $this->getBoundaryString($this->getEarliestDate()); - if ($latestDateTime < $earliestDateTime) { - throw new \InvalidArgumentException( - "Latest date '{$this->latestDate}' cannot be earlier than earliest date '{$this->getEarliestDate()}' for field '{$this->getId()}'." - ); - } + throw new \InvalidArgumentException( + "Latest date '{$latestDateString}' cannot be earlier than earliest date '{$earliestDateString}' for field '{$this->getId()}'." + ); } } @@ -428,8 +417,8 @@ public function validate() return; } - if ($this->getEarliestDate() !== null) { - $earliestDateTime = $this->getBoundaryDateTimeObject($this->getEarliestDate()); + if ($this->earliestDateTime !== null) { + $earliestDateTime = $this->getBoundaryDateTimeObject($this->earliestDateTime); if ($dateTime < $earliestDateTime) { $this->addValidationError(new FormFieldValidationError( @@ -442,8 +431,8 @@ public function validate() } } - if ($this->getLatestDate() !== null) { - $latestDateTime = $this->getBoundaryDateTimeObject($this->getLatestDate()); + if ($this->latestDateTime !== null) { + $latestDateTime = $this->getBoundaryDateTimeObject($this->latestDateTime); if ($dateTime > $latestDateTime) { $this->addValidationError(new FormFieldValidationError( @@ -475,12 +464,8 @@ public function value(mixed $value) parent::value($value); - $dateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - $this->getValue(), - new \DateTimeZone('UTC') - ); - if ($dateTime === false) { + $dateTime = $this->parseSaveValue($this->getValue()); + if ($dateTime === null) { throw new \InvalidArgumentException( "Given value does not match format '{$this->getSaveValueFormat()}' for field '{$this->getId()}'." ); @@ -512,27 +497,72 @@ protected static function getReservedFieldAttributes(): array } /** - * Returns a date time object for the given earliest or latest valid date. - * Without time support, the time is reset to the start of the day to allow - * the day of the boundary itself to be selected. + * Returns the date time object used to validate the value against the given + * earliest or latest valid date. Without time support, the time is reset to + * the start of the day to allow the day of the boundary itself to be selected. + * + * @since 6.3 + */ + protected function getBoundaryDateTimeObject(\DateTimeImmutable $dateTime): \DateTimeImmutable + { + if (!$this->supportsTime()) { + return $dateTime->setTimezone(new \DateTimeZone('UTC'))->setTime(0, 0); + } + + return $dateTime; + } + + /** + * Returns a date time object for an earliest or latest valid date or `null` + * if the given string does not match the save value format. * * @since 6.3 */ - protected function getBoundaryDateTimeObject(string|int $date): \DateTime + protected function resolveBoundary(string|int|\DateTimeInterface $date): ?\DateTimeImmutable { - $dateTime = \DateTime::createFromFormat( - $this->getSaveValueFormat(), - (string)$date, + if ($date instanceof \DateTimeInterface) { + return \DateTimeImmutable::createFromInterface($date); + } + + if (\is_int($date)) { + return new \DateTimeImmutable('@' . $date); + } + + return $this->parseSaveValue($date); + } + + /** + * Returns a date time object for a value in save value format or `null` if + * the value does not match the format. + * + * @since 6.3 + */ + protected function parseSaveValue(string|int $value): ?\DateTimeImmutable + { + // The `!` prefix resets all fields not present in the format, otherwise + // a date without a time would implicitly receive the current time. + $dateTime = \DateTimeImmutable::createFromFormat( + '!' . $this->getSaveValueFormat(), + (string)$value, new \DateTimeZone('UTC') ); - if (!$this->supportsTime()) { - $dateTime->setTimezone(new \DateTimeZone('UTC'))->setTime(0, 0); + if ($dateTime === false) { + return null; } return $dateTime; } + private function getBoundaryString(string|int|\DateTimeInterface $date): string + { + if ($date instanceof \DateTimeInterface) { + return $date->format(\DateTimeInterface::ATOM); + } + + return (string)$date; + } + /** * Returns an instance of `\IntlDateFormatter' for formatting `\DateTime` objects. * The formatter displays the date and time (if supported) in the user's locale and timezone. From b5c03b2bacd6211cb57cf65e821db4b0f6099031 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 9 Oct 2026 15:21:41 +0200 Subject: [PATCH 4/4] Document time zone semantics of `DateFormField` save values --- .../lib/system/form/builder/field/DateFormField.class.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php index 597ad383d13..4ceec3bad7d 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php @@ -355,6 +355,10 @@ public function readValue() /** * Sets the date time format of the save value. * + * Without time support, the save value represents the selected day at + * 00:00 UTC. Timestamps of such values must be formatted in UTC to get + * the selected day, thus `Y-m-d` is preferable for pure dates. + * * @return static */ public function saveValueFormat(string $saveValueFormat) @@ -371,6 +375,10 @@ public function saveValueFormat(string $saveValueFormat) /** * Sets if not only the date, but also the time can be set. * + * With time support, the value is entered in the user's time zone (or in + * UTC if the `data-ignore-timezone` attribute is `true`) and the save value + * represents that point in time. + * * @return static this field */ public function supportTime(bool $supportsTime = true)