From 50a9336740445bff80e5c0771f052b6e1981db77 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sat, 10 Oct 2026 21:47:13 +0200 Subject: [PATCH] Add TimeFormField for time-of-day input --- .../templates/shared_timeFormField.tpl | 15 + .../builder/field/TimeFormField.class.php | 276 ++++++++++++++++++ wcfsetup/install/lang/de.xml | 3 + wcfsetup/install/lang/en.xml | 3 + 4 files changed, 297 insertions(+) create mode 100644 com.woltlab.wcf/templates/shared_timeFormField.tpl create mode 100644 wcfsetup/install/files/lib/system/form/builder/field/TimeFormField.class.php diff --git a/com.woltlab.wcf/templates/shared_timeFormField.tpl b/com.woltlab.wcf/templates/shared_timeFormField.tpl new file mode 100644 index 00000000000..56b0994b20d --- /dev/null +++ b/com.woltlab.wcf/templates/shared_timeFormField.tpl @@ -0,0 +1,15 @@ +getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if}{* + *}{if $field->isAutofocused()} autofocus{/if}{* + *}{if $field->isRequired()} required{/if}{* + *}{if $field->isImmutable()} disabled{/if}{* + *}{if $field->getEarliestTime() !== null} min="{$field->getEarliestTime()}"{/if}{* + *}{if $field->getLatestTime() !== null} max="{$field->getLatestTime()}"{/if}{* + *}{foreach from=$field->getFieldAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}{* + +*}> diff --git a/wcfsetup/install/files/lib/system/form/builder/field/TimeFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TimeFormField.class.php new file mode 100644 index 00000000000..d51ae79851e --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/TimeFormField.class.php @@ -0,0 +1,276 @@ + + * @since 6.3 + */ +class TimeFormField extends AbstractFormField implements + IAttributeFormField, + IAutoFocusFormField, + ICssClassFormField, + IImmutableFormField, + INullableFormField +{ + use TInputAttributeFormField { + getReservedFieldAttributes as private inputGetReservedFieldAttributes; + } + use TAutoFocusFormField; + use TCssClassFormField; + use TImmutableFormField; + use TNullableFormField; + + /** + * earliest valid time in `H:i` format or `null` if no earliest valid time has been set + */ + protected ?string $earliestTime = null; + + /** + * @inheritDoc + */ + protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Value'; + + /** + * latest valid time in `H:i` format or `null` if no latest valid time has been set + */ + protected ?string $latestTime = null; + + /** + * @inheritDoc + */ + protected $templateName = 'shared_timeFormField'; + + const TIME_FORMAT = 'H:i'; + + public function __construct() + { + $this->addFieldClass('small'); + } + + /** + * Sets the earliest valid time in `H:i` format and returns this field. If `null` + * is given, the previously set earliest valid time is unset. + */ + public function earliestTime(?string $earliestTime = null): static + { + if ($earliestTime !== null) { + $normalizedTime = $this->normalizeTime($earliestTime); + if ($normalizedTime === null) { + throw new \InvalidArgumentException( + "Earliest time '{$earliestTime}' does not have format '" . static::TIME_FORMAT . "' for field '{$this->getId()}'." + ); + } + + if ($this->latestTime !== null && $this->latestTime < $normalizedTime) { + throw new \InvalidArgumentException( + "Earliest time '{$normalizedTime}' cannot be later than latest time '{$this->latestTime}' for field '{$this->getId()}'." + ); + } + + $earliestTime = $normalizedTime; + } + + $this->earliestTime = $earliestTime; + + return $this; + } + + /** + * Returns the earliest valid time in `H:i` format or `null` if no earliest + * valid time has been set. + */ + public function getEarliestTime(): ?string + { + return $this->earliestTime; + } + + /** + * Sets the latest valid time in `H:i` format and returns this field. If `null` + * is given, the previously set latest valid time is unset. + */ + public function latestTime(?string $latestTime = null): static + { + if ($latestTime !== null) { + $normalizedTime = $this->normalizeTime($latestTime); + if ($normalizedTime === null) { + throw new \InvalidArgumentException( + "Latest time '{$latestTime}' does not have format '" . static::TIME_FORMAT . "' for field '{$this->getId()}'." + ); + } + + if ($this->earliestTime !== null && $normalizedTime < $this->earliestTime) { + throw new \InvalidArgumentException( + "Latest time '{$normalizedTime}' cannot be earlier than earliest time '{$this->earliestTime}' for field '{$this->getId()}'." + ); + } + + $latestTime = $normalizedTime; + } + + $this->latestTime = $latestTime; + + return $this; + } + + /** + * Returns the latest valid time in `H:i` format or `null` if no latest + * valid time has been set. + */ + public function getLatestTime(): ?string + { + return $this->latestTime; + } + + /** + * @return ?string + */ + #[\Override] + public function getSaveValue(): ?string + { + if ($this->getValue() === null) { + return $this->isNullable() ? null : ''; + } + + return $this->getValue(); + } + + #[\Override] + public function readValue(): static + { + if ( + $this->getDocument()->hasRequestData($this->getPrefixedId()) + && \is_string($this->getDocument()->getRequestData($this->getPrefixedId())) + ) { + $value = $this->getDocument()->getRequestData($this->getPrefixedId()); + + if ($value === '') { + $this->value = null; + } else { + // Native time inputs submit the seconds if the `step` attribute + // allows values below one minute. Invalid values are kept as is + // to be reported during validation. + $this->value = $this->normalizeTime($value) ?? $value; + } + } + + return $this; + } + + #[\Override] + public function validate(): void + { + if ($this->getValue() === null) { + if ($this->isRequired()) { + $this->addValidationError(new FormFieldValidationError('empty')); + } + + return; + } + + if ($this->normalizeTime($this->getValue()) === null) { + $this->addValidationError(new FormFieldValidationError( + 'format', + 'wcf.form.field.time.error.format' + )); + + return; + } + + if ($this->earliestTime !== null && $this->getValue() < $this->earliestTime) { + $this->addValidationError(new FormFieldValidationError( + 'minimum', + 'wcf.form.field.time.error.earliestTime', + ['earliestTime' => $this->formatTime($this->earliestTime)] + )); + + return; + } + + if ($this->latestTime !== null && $this->getValue() > $this->latestTime) { + $this->addValidationError(new FormFieldValidationError( + 'maximum', + 'wcf.form.field.time.error.latestTime', + ['latestTime' => $this->formatTime($this->latestTime)] + )); + } + } + + #[\Override] + public function value(mixed $value): static + { + if ($value === null || $value === '') { + $this->value = null; + + return $this; + } + + $normalizedTime = \is_string($value) ? $this->normalizeTime($value) : null; + if ($normalizedTime === null) { + throw new \InvalidArgumentException( + "Given value does not match format '" . static::TIME_FORMAT . "' for field '{$this->getId()}'." + ); + } + + return parent::value($normalizedTime); + } + + /** + * @return string[] + */ + protected static function getReservedFieldAttributes(): array + { + return \array_merge( + // @phpstan-ignore staticClassAccess.privateMethod + static::inputGetReservedFieldAttributes(), + [ + 'max', + 'min', + ] + ); + } + + /** + * Returns the given time in `H:i` format or `null` if it is neither given + * in `H:i` nor in `H:i:s` format. Seconds are discarded. + */ + protected function normalizeTime(string $time): ?string + { + if (!\preg_match('~^(?[01]\d|2[0-3]):(?[0-5]\d)(?::[0-5]\d)?\z~', $time, $matches)) { + return null; + } + + return $matches['hour'] . ':' . $matches['minute']; + } + + /** + * Returns the given time in `H:i` format formatted in the user's locale. + */ + protected function formatTime(string $time): string + { + $dateTime = \DateTimeImmutable::createFromFormat( + '!' . static::TIME_FORMAT, + $time, + new \DateTimeZone('UTC') + ); + + $formatter = new \IntlDateFormatter( + WCF::getLanguage()->getLocale(), + \IntlDateFormatter::NONE, + \IntlDateFormatter::SHORT, + new \DateTimeZone('UTC') + ); + + return $formatter->format($dateTime); + } +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 88c5fa3166b..23766c402ff 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4063,6 +4063,9 @@ Dateianhänge: + + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index c970d7690ca..6bf38914d8d 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4009,6 +4009,9 @@ Attachments: + + +