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 @@
+getPrefixedId()}" {*
+ *}name="{$field->getPrefixedId()}" {*
+ *}value="{$field->getValue()}"{*
+ *}{if !$field->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:
+
+
+